Reading desk: a private page that ranks today's papers and takes verdicts back
Date: 2026-09-29
Status: approved in brainstorm 2026-09-29. Decisions taken there: the page
does both triage and reading, in two tabs; verdicts are imported at the next
render (not written on save, not relayed by the agent); paper links open any
plain https: URL behind the host's existing confirm modal.
Spans: this repo (desk.py, attest desk, the refresh script, feed.ask
/ feed.list) and mzumby/agentmarkit (agentmarkit-site/src/private-pages/host.mjs,
the academic-research-assistant distribution, the rehearsal). The
link-policy change (D3) was approved by Matt on 2026-09-29.
Depends on: ranking (rank_items, _ranking_quality), the tool-surface
rules (2026-08-21-tool-surface-design.md), implicit feedback
(clicks.source provenance), the scheduled refresh
(2026-08-23-scheduled-refresh-design.md), and AgentMarkit's private pages
(shared-skills/agentmarkit-links/scripts/private_pages.py).
Why
Two problems, one fix.
The ranker is starving. The last click was 2026-08-22, with 0 engagement
rows since (HANDOFF-2026-09-28, problem 1). docs/measurement-lessons.md
and the 2026-08-23 measurement say it plainly: feedback that needs a gesture
does not arrive, and a chat conversation is a poor place for a gesture. The
feed-first Research Desk asks for verdicts in chat (SOUL.md step 4) and hopes.
Research Desk leaves nothing behind. Every other AgentMarkit starter's
first win is a durable artifact: Small Business a versioned workbook,
Personal Trainer a saved program and app.html, Network Operator an
Observatory map registered as a private page. Research Desk's
activation.json artifact is "a ranked list of today's papers", which lives
in chat scrollback. research-operator/templates/research-brief.md exists but
no step saves a filled brief anywhere.
A private page with a ranked list and two buttons per paper is a gesture surface on the researcher's phone and the durable artifact at once. Saved briefs close the second gap for the on-demand path.
What the platform allows (read 2026-09-29)
These constrain everything below; each was read from code, not docs.
- A page is a static snapshot.
private_pages.py register --htmlcopies one self-contained HTML file (≤ 8 MB) into~/.hermes/private-pages/<id>/<revision>/index.html. Re-registering with the same--idkeeps the link. - A page has one JSON state blob.
page_state()stores it in~/.hermes/private-pages/<id>/state.sqlite, tablestate(id=1, revision, value), ≤ 32,000 chars of JSON, saved only when the caller'srevisionmatches (otherwiseconflict: true). State is per page id, not per revision, so it survives re-registration. Every read and save runsprivate_pages.py --request-fileon the machine, via the Worker. - The page runs in
sandbox="allow-scripts"with CSPdefault-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; font-src data:; img-src data:; connect-src 'none'; ...(host.mjs:14). No fetch, no external script or style, no web fonts. - External links are LinkedIn-only. A page posts
{type: 'page-link', href};host.mjs:154opens a confirm modal forhttps://(www.)linkedin.com/in/…and drops everything else. A paper link does nothing today. clickshasUNIQUE(user_id, item_id). A verdict is an upsert; a changed mind overwrites.
Decisions
D1. The page does triage and reading, in two tabs
Triage is one row per paper: linked title, source, up to three tags, and
a Useful / Not my area pair. Read is the same papers with the summary
expanded (clipped to 600 chars, the item's own summary). One ranking, two
views of it; a verdict given on either tab shows on both.
D2. Verdicts are imported at the next render
attestation reads the page's state.sqlite and imports verdicts before
every ranking a person sees: attest desk build, feed.ask, feed.list.
A verdict changes nothing until a ranking is computed, so importing then
loses nothing, and attestation needs to know only a file path.
Rejected: a third attestation adapter in private_pages.py that shells out
on every save (instant, but couples agentmarkit's page script to attestation's
install location and venv, and a broken attestation fails the save); and the
agent relaying verdicts in the next conversation (verdicts sit unrecorded
until someone chats, which is the starvation this fixes).
D3. Paper links: any plain https, behind the confirm modal
For the generic page adapter only, page-link accepts any URL whose
protocol is https: with no username, password or port, and shows it in the
existing pages-external modal (full address visible) before opening. The
Observatory adapter keeps its LinkedIn rule. Feed items come from arbitrary
journal RSS, so a scholarly-host allowlist would leave most links dead and
need upkeep; the modal is the safeguard against a misleading agent-written
link. Approved by Matt on 2026-09-29.
D4. attestation never names AgentMarkit
It takes a state path (ATTEST_DESK_STATE), a persona (ATTEST_DESK_USER)
and a publish command (ATTEST_DESK_PUBLISH). All unset: attest desk build
still writes a page, import is a no-op, and attest desk refresh (which the
hourly refresh calls) says "desk not configured" and does nothing.
D5. Briefs are files, not pages
The research-operator skill writes each filled research-brief.md to
~/.hermes/workspace/research-desk/briefs/YYYY-MM-DD-<slug>.md and sends the
files link. No rendering in attestation: a brief is agent prose, and
attestation's composition tools return structure, never prose.
Design
attestation: src/attestation/desk.py
STATE_VERSION = 1
SUMMARY_CHARS = 600
DEFAULT_DESK_LIMIT = 20
@dataclass(frozen=True)
class Imported:
recorded: int # new or changed verdicts written
unchanged: int # already in clicks with the same verdict
skipped: int # unknown item id, or malformed entry
def read_state(path: Path) -> dict # {} when missing/unreadable/wrong v
def import_verdicts(conn, user_id: int, state: dict) -> Imported
def import_pending(conn) -> Imported | None # ATTEST_DESK_STATE + ATTEST_DESK_USER; None when either is unset
def render_desk(conn, embedder, user_id: int, limit: int = DEFAULT_DESK_LIMIT) -> str
read_stateopensfile:<path>?mode=roas a URI, readsstate.valuewhereid = 1, and returns the parsed object only ifv == STATE_VERSION. It never writes and never holds a transaction, so the Worker'sBEGIN IMMEDIATEnever waits on it. Missing file,sqlite3.Error, bad JSON or a wrongvreturn{}and log one line; they never raise.import_verdictsis DB-only and pure overstate: for eachverdicts[<item_id>] = {"useful": bool, "at": iso}, write throughrank.record_click(..., source='ui'), the single click write path (clicked_atis the import time;atonly orders verdicts on the page). An existing row with the sameusefulcountsunchangedand is not rewritten. An id not initems, a non-integer key, or a non-boolusefulcountsskipped.import_pendingresolvesATTEST_DESK_STATEandATTEST_DESK_USERat call time (a local file, not a network reader, so the construction-time rule for network flags does not apply), looks the persona up withget_user(aliases resolve; an unknown name imports nothing and logs), and wraps the two above. Verdicts always belong to the desk persona, whichever persona the current call ranks for. It catchessqlite3.ErrorandValueErroritself, logs, and returnsNone, so no caller needs a handler.render_deskcallsrank_items(conn, embedder, user_id)(14 days, clicked items excluded, the same candidate setfeed.listuses) and_ranking_quality, then emits one HTML string: inline CSS, inline script, the ranked items as a JSON<script type="application/json">block (id, title, url, source, tags[:3], summary[:600]), the caveat whenclassifier_activeis false, and an "N papers rated so far" line fromclicks. Escaping goes through the same helpersserver.pyuses (safe_href,clip) rather than a second copy.- Empty ranking renders the "No papers yet: the refresh fetches new ones hourly" state, never an empty page.
Page script (inline in the rendered HTML)
- On load:
await agentmarkit.state(). Ifwindow.agentmarkitis absent (opened as a local file), hide the buttons and keep the list; nothing else changes. - A tap sets
verdicts[id] = {useful, at: now}, marks the page dirty, and saves with the last known revision. A saved tap shows as selected on both tabs. - Cap: if the serialized blob would exceed 28,000 chars, drop verdicts
oldest
atfirst until it fits. Nothing else ever prunes: by the time a verdict is ~550 verdicts old it has been imported many times over (hourly refresh, everyfeed.ask), and pruning by "not in this snapshot" would race the import. - Conflict (
ok: false, conflict: true, another device saved): take the returned state, merge per key with newestatwinning, retry once. A second conflict leaves the host's "changed elsewhere" notice up and the tap still shown as unsaved. A tap is never silently dropped. - Links post
{type: 'page-link', href}; the page never navigates itself.
Where import runs
attest desk build --user NAME --out PATH [--limit N]: import, then render, then writePATHatomically.attest desk import --user NAME: import only; prints theImportedcounts.feed.list,feed.digestandfeed.ask's reading routes: one call toimport_pending(conn)inmcp/_shared.ranked_items, the helper all three already rank through (feed.askreaches it via_list_feed). A failure there is logged and ranking proceeds on existing clicks. No change tofeed.pyorask.py, both of which sit at their size caps.
Configuration and the refresh
All three values live in the checkout's .env, beside ATTEST_DB and
EMBED_*, which AgentMarkit's installer already writes there
(install_attestation.py:_feed_env). Both attestation entry points load that
file (llm.load_env(), called from cli.main and mcp_server.main), so the
MCP server and the cron refresh see the same values with no env: block and
no shell sourcing. Hermes stripping the parent environment from MCP
subprocesses does not matter here: the server reads the file itself.
ATTEST_DESK_STATE: the page'sstate.sqlite.ATTEST_DESK_USER: the persona to rank for and record verdicts against.ATTEST_DESK_PUBLISH: a command run after a successful build, split withshlex.split, each argument~-expanded, run without a shell, 60 s timeout. It has failed when it exits non-zero OR when its last stdout line is a JSON object with"ok": false(amended 2026-10-02): AgentMarkit'sprivate_pages.pyexits 0 on every outcome, so an unlinked machine printed "desk: published" over a page that was never registered.
attest desk refresh is the configured form: with ATTEST_DESK_STATE and
ATTEST_DESK_USER both set it imports, renders to
<hermes_home>/workspace/research-desk/desk.html (paths.hermes_home()), and
runs ATTEST_DESK_PUBLISH when set; with either unset it prints "desk not
configured" and exits 0. install.py's refresh script calls it after
tagging, as a degraded step like tagging: a failure is logged ("desk FAILED
... will retry next run") and never changes the script's exit status, which
stays ingest's.
agentmarkit changes
host.mjs: inreceive, forcurrent.adapter === 'page', accepthttps:URLs with no userinfo or port into the existing modal. Observatory unchanged.- Distribution env:
install_attestation.py:_feed_envaddsATTEST_DESK_STATE=<home>/.hermes/private-pages/reading-desk/state.sqlite,ATTEST_DESK_USER=owner, andATTEST_DESK_PUBLISH=python3 <home>/.hermes/skills/agentmarkit-links/scripts/private_pages.py register --id reading-desk --title "Reading desk" --html <home>/.hermes/workspace/research-desk/desk.html, with<home>from_hermes_home_parent()so every path is absolute. SOUL.mdstep 3: after showing the list in chat, runattest desk refreshin the terminal (from the checkout), and send thepageslink fromagentmarkit.jsonas a Markdown link. Step 4 accepts verdicts in chat or on the page.activation.json:first_win.artifactbecomes "The Reading desk page: today's papers ranked for this researcher, with a Useful / Not my area choice on each";done_whengains "the Reading desk link was sent" and keeps "at least one paper … recorded", now satisfiable from either path.research-operator/SKILL.md: save each brief per D5.README.md: "First useful path" drops the Research Agent Kit steps; the distribution is feed-first.
Testing
Each guard is mutation-checked before it counts (flip useful in
import_verdicts; drop the import call from feed.ask; widen the host rule
to http:), because this repo's recurring failure is a test that passes
against the bug it was written for.
tests/test_desk.pyimport_verdicts: idempotent on a second run; last verdict wins; unknown id, string key and non-boolusefulareskipped;sourceisui.read_stateagainst astate.sqliteproduced by agentmarkit's ownpage_state()logic (the table shape copied as a fixture builder with a comment naming its source), not a hand-invented file; missing file and wrongvreturn{}.render_desk: no<script src, no<link, nohttp:anywhere, nohttps:except item URLs; under 8 MB; both tabs present; empty-ranking state; caveat present when the classifier is inactive.- Through the real tool: register tools on a FastMCP server with
ATTEST_DESK_STATEpointing at a fixture state, callfeed.ask, and assert aclicksrow withsource='ui'exists. Testing the dict before the tool is how 0.2.0 shipped a brokenAnswer. - Surface rules:
test_architecture.py'sBLE001count and any file-size caps are updated with reasons inline, not loosened silently. - agentmarkit: a
host.mjstest that apageadapter'spage-linkopens the modal forhttps://www.nature.com/articles/xand ignoreshttp:,https://u:p@x,https://x:8443; the Observatory's rule is unchanged. - Rehearsal:
rehearse_starter.sh research-assistantwithWITH_MODEL=1registers the page, saves one verdict throughprivate_pages.py --request-file(the Worker's real path), callsfeed.ask, and passes only if aclicksrow withsource='ui'exists. This is the measurement that says the loop closes; everything above is scaffolding for it.
Out of scope
- A digest page, a knowledge map page, BibTeX export on the page, claim-check reports: the other outputs from the 2026-09-29 survey, each its own spec.
- Rendering briefs to HTML.
- Deleting a verdict (a changed mind is a different verdict).
- Any page for a local, non-AgentMarkit install beyond the file
attest desk buildwrites.