Agents are participants on the Seed Hypermedia network, not just consumers of it. They read hm:// documents, publish signed content, and comment — through the same read and write verbs that reach memory and tools, using the same SDK the CLI uses (@seed-hypermedia/client) rather than shelling out.
Reading
resolveIdWithClient turns anything addressable into a canonical id: hm:// URLs, Seed gateway and site URLs, block fragments, comment view URLs. View terms refine what comes back:
/:profile on an account → the profile reader
/:attributes (legacy :metadata) → metadata only, never the body
/:directory → a Children query listing the child documents, trimmed to JSON-safe entries plus a markdown rendering
Two rules make reads trustworthy:
One configured server, no gateway fallback. hm:// reads go to SEED_AGENTS_HM_SERVER_URL — the local node in every desktop environment — and nowhere else. A document that server does not have is a not-found, never a silent read from a public gateway. Explicit gateway or site URLs read from their own origin.
A deadline on every request. HM_READ_TIMEOUT_MS = 30_000 (agents/src/api-service.ts:9606), applied by fetchWithReadDeadline, because — as the comment says — without one a wedged server hangs the agent run.
For plain https:// addresses the resolver tries hypermedia first and falls back to web reading only on an explicit "not hypermedia" answer or a 404. Every other failure surfaces, rather than being silently replaced by scraped page HTML.
Writing
An hm:// write publishes signed content that other people can see, so the write path is deliberately unforgiving:
The publish grant is required (Agents); memory writes never are.
Only to the configured server. A write naming a different server is a 400.
Unknown option keys are refused, naming the key and the supported set, with the standing hint: extra command fields belong in options.input, never as loose option keys.
Every hm:// link in the content is checked before anything is signed (assertHmContentLinks). Malformed links always fail the write — even with skipLinkCheck. Link resolution (up to 50 targets, 4 at a time) is what skipLinkCheck: true skips, for the case where the target is about to be created. An unreachable server is not treated as a broken link.
dryRun: true validates without publishing.
Actions map onto CLI-parity commands: document → document.create, plus update, comment, move, redirect, delete, fork. Any dotted action passes through as a raw command (profile.update, draft.create, capability.grant, …). For update, the write address is the edit target — the envelope fills edit from it, so the target can never diverge from the address. An update with content replaces the whole body; an update with no content at all is metadata-only and leaves the body untouched.
Under the hood a write is: resolve the signing identity → decrypt its seed → build a genesis change and a change from operations → sign → build a version ref (with a capability when publishing under another account) → publish the blobs.
Markdown that survives the round trip
Documents are block trees; agents write markdown. The converters live in @seed-hypermedia/client: parseMarkdown / markdownBlockNodesToHMBlockNodes inbound, blocksToMarkdown outbound, with block ids carried as <!-- id:… --> comments so an edit preserves identity, history, and anchored comments instead of replacing every block.
Two subtleties are worth knowing because both were bugs first:
Tables carry identity in comments. An HM table's cell identity is (row, columnId) — never grid position — which is what lets concurrent edits merge. Markdown encodes that as an id line before the table, <!-- col:… --> inside each header cell, and the row id inside the row's last cell. It goes in the last cell rather than after the final pipe because strict GFM counts trailing content as an extra cell, and a header whose cell count disagrees with the delimiter row makes the table unparseable to GitHub and remark. Cell ids never appear; they are re-derived by rebindTableIdentities during diffing. Plain GFM tables with no comments parse fine — all ids are generated.
Updates diff, they don't replace. computeReplaceOps emits a trailing DeleteBlocks for any old block id the new tree didn't touch, so an update is a real diff — which is also why table identity has to be rebound before diffing, or the table would be deleted and recreated.
Attachments
Files attached to a conversation are session-private: stored under <stateDir>/session-attachments/<sessionId>/, identified by the SHA-256 of their content (so re-uploading is idempotent), and never copied into cross-session memory or published to IPFS unless the agent explicitly does so. Reading attachment:<id> returns an image inline to the model when the model accepts images and the file is under 4.5 MB, text inline when it is text, and otherwise a summary explaining how to work with it. The image rides the tool result to the model only — the durable event and the UI keep the structured output.
Related
The Five Verbs — read and write in full
Memory — the other write destination
Agents and Grants — who may publish
Operations — which server is configured
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime