Custom HTML Pages
A document whose `html` attribute names a UnixFS folder with an index.html opens as that page, sandboxed, and reads, writes and signs Hypermedia as the viewer through a host-owned bridge with progressive read and signing approvals.

A custom HTML page is a document that carries a web page. Its metadata declares type: hm://hyper.media/page and has an html attribute, an ipfs://<cid> that names a UnixFS folder whose index.html is a page. Web-compatible bundles inline their scripts, styles and images; desktop-only bundles can use relative asset files. When the document is opened in Seed, the page fills the main view without document margins, header, tabs or footer. Breadcrumbs and the document browser remain. People, Comments, Citations and Attributes open from the breadcrumb options menu as panels beside the page. Editing still shows the underlying document. The folder is a file the document references, so it syncs with the document: share the document and the page arrives with it.

An agent builds such a page with the ordinary write verb: it writes the HTML under its memory and then writes a document with options.page naming that folder (or single HTML file). The folder is published to IPFS as a UnixFS directory in the same publish as the document change, and the document's html attribute points at it. The write result tells the agent to share the document's hm:// URL, which opens the full page view. Page execution inside chat is temporarily disabled; older seed-page fences render an Open page link. There is no separate page tool and no page that is not a document.

Data before UI

For pages with durable user data, agents must define or reuse HM schemas, establish schema-bound Seed resources, and then build the interface to the user's specifications. A task tracker should have a Task type document (schemaDefinition), a task collection (childAttributesSchema) and individually editable task documents (attributesSchema, name, status and the requested fields). Store real collection/type URLs in the page's metadata. The data remains available through ordinary Seed documents and other pages, independently of the HTML bundle.

The agent's ~/tools/write/pages guide supplies the workflow and an executable task data adapter. seed.directory and seed.resource read records; seed.publishDocument creates and edits them using the current viewer's authority. Updates preserve the document's genesis/generation and use its current base version. Establish the intended user/shared data space, privacy and write access during setup; an agent-owned collection that the user cannot edit is not a completed page.

Use seed.storage only for optional device-local UI preferences or temporary unsaved drafts, unless the user explicitly requests local-only primary data. Never substitute local storage when a Seed read/write fails or is denied. Existing local records need an explicit migration that preserves the originals until publication/read-back succeeds. Stateless pages do not need artificial schemas.

Verify create/read/update and reopen with local state empty, then read the same record through ordinary Seed tools. Distinguish that persistence test from a cross-host sync test. Publication receipts report dataPersistenceTested: false because syntax/asset checks cannot establish a working data layer, even if the source mentions Seed write methods.

Schema-defined URL input

Every page declares inputSchema, a reference to a published HM struct schema. The default is empty page input; pages with prefills, filters or navigation state publish their own contract before the HTML. See the page resource type for the wire format.

Use seed.input.get() for {values, schema, errors}, seed.input.push(values) for a new history entry, seed.input.replace(values) for the current entry, and seed.input.onChange(listener) for live updates and Back/Forward. Writes supply the complete object and validate before navigation. Incoming invalid input is reported in errors. The page remains mounted across input changes. Each field is a p.<name> query parameter holding a JSON value; Seed's own query parameters remain separate. URL input is visible in copied links and browser history, so it must not contain secrets.

Open Page parameters from the page’s breadcrumb options menu to edit URL input in a schema-derived form. Seed renders typed fields, optional values and nested structures, and validates changes before Apply adds a history entry. Cancel leaves the URL unchanged; Reset clears optional fields and restores starting values for required fields. Back/Forward updates both the page and an open parameter form without restarting the page.

What the page may do

The page is untrusted code: once a document is shared, someone else's page runs on the viewer's machine beside the viewer's data. So the page runs in an origin of its own, with no network, no access to the daemon or its secret, and no access to the Seed window. Desktop permits assets from the page folder. Web uses an opaque iframe and HTTP CSP sandbox on the same Seed origin; inline scripts/styles and embedded images are supported. Use self-contained HTML for both platforms. Sandboxing does not guarantee that data disclosed to page code cannot leave it through navigation.

    Origin. The desktop serves the folder at seed-page://<host>/, where the host is a fresh unpredictable value for each launch, bound to the canonical document, immutable bundle CID and selected viewer. Opening a different revision cannot replace assets served to an already-open revision. Native storage starts fresh on each launch and is cleared when the owning window closes or releases it; it cannot silently carry previously disclosed data into another launch. Optional device-local UI state uses seed.storage.get/set, whose host-managed namespace is (viewer account, canonical document HMID), independent of the page revision. Different resources never share that state, even if their HTML is identical.

    Bridge. The page includes <script src="/.seed/client.js"></script>, which defines window.seed. Except for the synchronous display helper seed.stringify, each method posts a request to the Seed window that embeds the page and resolves with its answer; the Seed window answers the allowed calls. Web installs the API before page code runs and ignores the desktop bridge include. Both platforms allow reads within the page’s own space by default and gate other content, viewer identity and saved-state reads through Seed-owned approval UI.

    SDK. seed.context() returns {protocol: 1, document, version, account, page}; account is the selected viewer UID or null, not viewer. seed.profile() returns {id, name} for that viewer or null when signed out. Use const profile = await seed.profile(); heading.textContent = profile?.name ? "Hello, " + profile.name + "!" : "Hello, friend!". Profile data comes from the Account API, not a document at /:profile; that URL is supported as a compatibility alias for the selected viewer. Handle SDK errors separately from signed-out state. seed.resource(hmUrl) and seed.directory(hmUrl, {all}) read within the page’s own space without prompting; other spaces require approval on both platforms. seed.storage.get(key) returns saved JSON or null; seed.storage.set(key, value) persists JSON (64 keys, 128 characters/key, 16 KiB/value). Reading, signing and publishing have independent rules described below.

Account access, writes and signatures

custom HTML pages use a host-owned facade over @seed-hypermedia/client, on both desktop and web:

const content = await seed.hm.parseMarkdown(messageInput.value) const {commentId, version} = await seed.comments.create({content}) // Seed reviews the signature

seed.hm.parseMarkdown returns HM block nodes with IDs and annotations. seed.hm.validateContent(content) validates page-constructed block nodes. Both are asynchronous, use the existing SDK in the host, and have no signing side effects. seed.hm.unpackHmId(url) parses HM URLs. Requests are limited to 8 MiB including binary bytes, with at most 32 pending calls.

Running a page is separate from granting it access. Pages from joined sites, your own site or the current web site's identity run automatically. Other sites show Run page, explaining that code can mislead, consume resources and open links. Joining is optional. Running permits reads of content within the page document’s own space, limited by the viewer’s existing protocol access. Viewer identity, other spaces and saved page state still require approval.

The first operation needing access opens a Seed-owned review, defaulting to Allow once. Allow more like this offers narrower remembered rules alongside all-reading, any-signing or all-publishing, independently. A plain-text explanation below the choices describes the selected policy; broad permissions need no extra confirmation checkbox. Comment reviews show the text and target site; exact payload details and a SHA-256 fingerprint are available. Rules are remembered for (selected viewer, canonical page document ID) on this device, including future code updates. Optional version-pinned permissions are future work.

    Reading: documents, the space profile and queries confined to the page document’s own space are allowed by default, including after resetting additional permissions. Other spaces require approval for a resource, a site, or all reads. Identity/profile/public-key access and saved page data need their own read approval. Search, comments and queries with an unbounded cross-site footprint currently offer once or all-reading; they cannot silently inherit a site grant. Resolved document targets are checked again before disclosure.

    Signing: approve one signature, an established signature type on one site, that type everywhere, or any signing. Comment creation, comment edits/deletions, document changes, references, contact changes and lasting capabilities are distinct. Classification validates the canonical signing bytes and actual signing principal against the protocol schema; the page cannot choose a label to obtain broader access. Types without a provable site binding have no site option. Unrecognized payloads offer only once or any-signing.

    Publishing: exact recognized envelopes signed and approved in this launch may be published without another prompt. Modified blobs, extra attachments and previously signed/imported payloads need publication consent. Generic PublishBlobs requests use the same check.

seed.permissions.request() returns {grants} without granting anything. Page permissions in the breadcrumb menu lets the viewer inspect, revoke individual rules or reset additional permissions; seed.permissions.revoke() resets the page's rules too. Revocation also aborts active/queued calls and propagates to other open views on the same origin. Closing, changing account or changing code cancels in-flight calls while keeping remembered rules. Allow-once approvals never coalesce different operations.

A page can exfiltrate data after you disclose it: the sandbox limits ambient browser authority, not what the page can do with data it receives. Reading and signing are therefore independent. Keys remain in Seed. Revocation cannot recall disclosed information, completed signatures, publications or lasting protocol capabilities already issued.

The asynchronous APIs are identical on web and desktop:

    seed.request(key, input) exposes all requests in the SDK's HMRequest union with schema-validated inputs. seed.resource(url) and seed.directory(url, {all}) are convenience reads.

    seed.publish({blobs: [{cid, data}]}) publishes arbitrary blobs; data is a Uint8Array. seed.publishDocument({account, path, changes, baseVersion?, genesis?, generation?, capability?, visibility?}) prepares, signs and publishes protobuf document operations. account is the target space; signing always uses the selected viewer.

    seed.signer.getPublicKey() and seed.signer.sign(bytes) implement HMSigner, for an SDK bundled inline into the page. The page receives public-key/signature bytes, never private keys. Web sign-in may use a delegated device key whose signing principal differs from the selected account; the protocol still enforces its delegation.

    seed.hm provides the SDK's createComment, updateComment, deleteComment, createDocumentBlobs, createVersionRef, createRedirectRef, createTombstoneRef, createCapability, createContact, updateContact, deleteContact, signDocumentChange and signBlob. Each accepts its SDK input without a signer argument. Signing helpers return their SDK result without publishing (signDocumentChange.changeCid is serialized as a string); signBlob(body) returns {cid, data, ts, signer} for a custom dag-json blob body.

    seed.comments.create({content, target?, version?}) signs and publishes in one call. By default it comments on this page's document at the displayed revision; another target needs a version, and visibility follows the target. It returns {commentId, version}.

For a new document, use the SDK's document builder and publish the resulting signed blobs:

const {account} = await seed.context() const {blobs} = await seed.hm.createDocumentBlobs({ space: account, path: '/pages/data', ops: [{type: 'SetAttributes', attrs: [{key: ['name'], value: 'Page data'}]}], }) await seed.publish({blobs})

Use versioned document changes for edits, rather than recreating an existing document. Keep Submit disabled while an operation is pending, preserve input on denial/error, and clear it only on success. Remembered grants survive page instances and code updates; broader permissions do not merge pages' local storage namespaces. Protocol permissions still govern what the selected account may change.

Linking and runtime compatibility

Use ordinary anchors such as <a href="hm://<account>/notes?v=<version>#<block>">Notes</a> or await seed.open(url, {newWindow: false}). The host opens Hypermedia links outside the page frame. Explicit HTTP(S) links retain their server, query and fragment instead of being rewritten to the current site. _blank, modifier clicks and middle clicks request a new tab/window; browser popup rules still apply. Unsupported schemes and credential-bearing URLs are rejected. Catch SDK errors to preserve page input; host navigation errors also appear as notifications. Anchor failures dispatch seed-link-error on the page window. Navigation needs no account-access grant.

SDK 2.0.0 reports authority: "scoped-page-permissions". SDK 1.1.0 introduced await seed.runtime(): the running SDK version and implementation digest, supported bridge methods, platform, limits, authority and storage semantics. Check for the method before calling it on older hosts; a guide or source checkout does not prove that a running host has been updated. seed.stringify(value) formats SDK BigInts and byte arrays for display, not signing or round-trip serialization.

Publishing with write(..., options: {page: ...}) defaults to pageTarget: "web-desktop". It parses JavaScript without executing it and rejects unsupported external assets or module scripts before publishing. Use inline classic scripts, styles and data images. pageTarget: "desktop" explicitly permits bundled asset files. The write receipt includes pageValidation, bound to the bundle CID, with syntax results and runtimeTested: false, interactionTested: false, dataPersistenceTested: false. These static checks do not prove startup or behavior; generated, syntax-checked, published, read back, opened and interaction-tested are distinct outcomes. Dry runs do not publish. Agents must read back publication state before retrying a failed operation and must never describe seed.storage as cross-device sync, backup or encrypted storage.

Automation can invoke InvokeSessionTool with continueAgent: false to retain the tool result in the session journal without starting a model turn. The default remains true. resultEventId identifies the recorded outcome; this is not an idempotency guarantee for retries. Durable storage commits are serialized before reserving their sequence number, preventing concurrent writes on one storage instance from preparing conflicting journal positions. This does not repair previously corrupted journals.

Reading and signing controls are implemented; optional version-pinned trust, undo/rollback, automated authoring-time startup/interaction validation, and migration of previously broken journals remain separate work.

Where the pieces are

    Daemon. GET /ipfs/{cid}/{path} walks a UnixFS directory the way an IPFS gateway does, types entries by extension, and serves a folder as its index.html (backend/hmnet/filemanager.go). The web page's /hm/api/file/<cid>/<path> proxies the same.

    Shared. @shm/shared/document-page holds the scheme, bridge, DocumentPageSDK TypeScript interface and message guard. document-page-content uses the HM SDK for block helpers and permission-controlled account requests and SDK signing; document-page-permissions classifies actual bytes and manages revocable scoped rules; directoryToIpfsBlobs in @seed-hypermedia/client builds the folder's blocks.

    Desktop. page-document-pages.ts registers the scheme and serves page files from the daemon; page-window-policy.ts admits page frames; DocumentPageView in @shm/ui/document-page embeds the page and answers the bridge.

    Chat. Page embeds are temporarily disabled in the Markdown renderer. Legacy seed-page fences open the full document view through a link; the agent prompt and write result no longer emit embed fences. The reusable embed component is retained for a future return to inline pages.

    Web. Normal document URLs render pages using /hm/page-runner on the same hostname/port. /hm/api/page checks that the requested bundle belongs to the readable document revision, then returns HTML as inert data. The parent transfers it through a launch-bound MessageChannel into the opaque sandbox. No wildcard DNS, per-page domains or separate runtime service are required. Raw /hm/api/file responses are sandboxed without scripts to prevent direct navigation from executing uploads with Seed authority.

Local web sandbox prototype

Run the normal web page with direnv exec . pnpm web, then open the document at http://localhost:3000/hm/<account>/<path>. The daemon must already be running as usual. No workshop server is needed. For the same-origin runtime's Chromium, Firefox and WebKit regression tests, run direnv exec . node --test frontend/apps/web/scripts/document-page-browser.e2e.mjs. These test storage isolation across viewers/resources/revisions, public profile resolution, account-bridge transport, native storage/DOM/fetch blocking, late-result revocation, and direct runner visits.

The earlier workshop in scripts/document-page-demo/ is retained as an experiment; ports 53110/53111 are not part of the integrated runtime. Web pages currently require inline scripts/styles, embedded assets, and click handlers rather than form submission; relative asset files are not implemented. The workshop uses its own experimental permission model; the integrated runtime uses the progressive permissions described above. Local state is browser-local, not synced or encrypted, and updated page code inherits that resource's remembered rules; reading existing state requires read approval. The prior workshop's navigation probe demonstrates that blocking fetch does not prevent every data leak.

Authoring checks and save failures

When publishing a page that creates or edits records, pass options.pageData with the writable collection HM URLs. The publisher checks each collection's schema binding and the agent owner's existing authority to create child records. An agent signing key is not evidence that the user can edit that collection. When access is missing, the result identifies the user, collection owner and required scope. If an enabled signing identity owns that space, it supplies dry-run WRITER grant proposals for the existing collections. The agent uses the signed message sender and current page/conversation context to offer the concrete repair, honors prior authorization or asks for the specified grant, then rechecks access. It does not default to moving data or broader page trust. A writer grant is not permission-management authority. This preflight does not execute the UI or prove persistence; test Add, read-back, edit, and reopening separately. Configuration attributes are optional: store collection/type URLs in one place, and only add metadata when the page reads it.

The browser SDK returns seed.resource(url) directly as {type, id, document} for documents; the agent read tool's resource envelope is not part of this API. seed.directory(url) returns rows with id and metadata. seed.publishDocument(input) selects an existing scoped capability for the viewer and confirms the published version by reading the target before resolving. It rejects missing write authority before signing. A stored blob with an unconfirmed document is not a successful save; read the reported address before retrying.

Desktop SDK startup calls wait for the host's frame-ready signal. Pages should still disable Add/Save until data initialization finishes and retain a visible error/retry state when initialization fails, rather than showing a misleading empty collection.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime

Collaborators