Every method, event, error and message shape of the host ⇄ extension bridge, protocol version 1. The normative
definitions are the TypeScript types infrontend/packages/client/src/extensions.ts (ExtensionMethods,ExtensionEvents, ExtensionContext, EXTENSION_METHOD_PERMISSIONS); the host validates params with the zod schemas
infrontend/packages/ui/src/extensions/bridge-schemas.ts
and implements each method in host-handlers.ts beside it. The SDK wrappers are infrontend/packages/extension-sdk/src/connect.ts.
Conventions used below: "Permission" is the entry in EXTENSION_METHOD_PERMISSIONS (— means always allowed); "Errors"
lists codes specific to the method on top of the ones every method can return (invalid_params for params that fail
validation, permission_denied when the permission is missing, not_supported when the host has no handler, internal
for anything unexpected). All params and results are JSON; binary data travels as standard base64.
Methods
hello
Permission | — |
|---|---|
Params |
|
Result | |
SDK | sent by |
The handshake. The SDK sends its EXTENSION_PROTOCOL_VERSION; the host answers with the full context. Stray duplicate
answers to a retried hello are dropped by the SDK.
getContext
Permission | — |
|---|---|
Params |
|
Result |
|
SDK |
|
Re-fetch the context. Rarely needed: the host pushes a context event on every change.
api.query
Permission | — |
|---|---|
Params |
|
Result | the query's output, converted to plain JSON |
Errors |
|
SDK |
|
Read-only access to the host's universal client request(key, input). Only the keys inEXTENSION_READ_QUERY_KEYS are accepted; hm:// strings in id fields are unpacked per the
id-unpacking rule. Results pass through toCloneable: BigInt → number (string when outside the
safe range), Map → object, Set → array, undefined/functions dropped.
const home = await seed.getResource(`hm://${seed.context.site.uid}`)
const docs = await seed.query('Query', {includes: [{space: seed.context.site.uid, mode: 'AllDescendants'}]})file.url
Permission | — |
|---|---|
Params |
|
Result |
|
SDK |
|
A URL the iframe can load the IPFS file from — web: absolute /hm/api/file/<cid> on the site origin (served with
permissive CORS); desktop: the daemon's /ipfs/<cid> URL. Works in <img src>, <video>, and fetch where CORS
allows.
img.src = await seed.fileUrl(block.link.slice('ipfs://'.length))file.read
Permission | — |
|---|---|
Params |
|
Result |
|
SDK |
|
The file's bytes through the host, for when the iframe cannot fetch the URL itself.
const bytes = await seed.readFile(cid, {maxBytes: 1_000_000})
const text = new TextDecoder().decode(bytes)sign.comment
Permission |
|
|---|---|
Params |
|
Result |
|
Errors |
|
SDK |
|
Publishes a comment on targetId as the viewer after a confirmation dialog showing the target, a text preview and
whether it is a reply. targetVersion defaults to the version in the targetId URL, then to the latest known version.markdown is parsed by the host. When replyCommentVersion is given without rootReplyCommentVersion, the host
fetches the parent comment and uses its thread root (the parent itself when the parent is a root), so replies to replies
join the existing discussion.
const {commentId} = await seed.sign.comment({targetId: `hm://${siteUid}/notes`, markdown: 'Looks good.'})sign.document
Permission |
|
|---|---|
Params |
|
Result |
|
Errors |
|
SDK |
|
Creates id if it does not exist, otherwise publishes a change on the latest version. The host builds the Change
client-side, signs a Version Ref and publishes the blobs. metadata is merged key by key (null deletes; unmentioned
keys untouched; values may be strings, numbers, booleans, arrays stored whole, or nested objects — an empty object
writes nothing); blocks replaces the whole body (block ids are kept when supplied, generated otherwise). The dialog
shows the document, whether it exists, summary, each metadata key's before/after and the block count of a body
replace. When the viewer is not the space owner the host resolves a write capability first. When the requested metadata
and blocks equal the published document, the call resolves with {id, version: <current version>} without a dialog and
without publishing; invalid_params "nothing to change" is raised only when the document does not exist. A change that
touches the extensions or seedExtension metadata keys is always confirmed, even with a session grant.
await seed.sign.document({
id: `hm://${siteUid}/${mountPath}`,
metadata: {name: 'Board', kanban: board, draft: null},
summary: 'Update the kanban board',
})sign.data
Permission |
|
|---|---|
Params |
|
Result |
|
Errors |
|
SDK |
|
Signs buildSignDataPayload(extensionId, bytes) = "seed-extension-signature:v1\n" + extensionId + "\n" + bytes with
the viewer's signer. The dialog shows purpose, the byte length and a hex preview. signer is the principal of the key
that signed — on the web usually a delegated device key, so it can differ from accountId. Verification is described in
the developer guide.
const {signature, signer} = await seed.sign.data('hello', 'Prove you are the viewer')Confirmation dialog (all sign.* methods)
The host opens one dialog at a time, naming the extension, the site, the account and the effect. When a dev override is
active the dialog shows a warning with the override URL. Approve is inert for ~500 ms after the dialog opens. "Allow
this extension to sign for the rest of this session" skips the dialog for later calls; the grant is in-memory, keyed on(extension, site, account, code source), and never covers a sign.document that writes extensions orseedExtension.
navigate
Permission |
|
|---|---|
Params |
|
Result |
|
Errors |
|
SDK |
|
Navigates the host app: hm:// URLs open the corresponding route, /path navigates within the current site.
await seed.navigate(doc.id.id)openExternal
Permission |
|
|---|---|
Params |
|
Result |
|
Errors |
|
SDK |
|
New tab (web) or the system browser (desktop).
route.set
Permission | — |
|---|---|
Params |
|
Result |
|
SDK |
|
Updates the URL beneath the mount without a host navigation. The host then pushes a context event with the newsubPath and query.
await seed.setRoute(['card', id], {tab: 'notes'})storage.get / storage.set / storage.remove / storage.keys
Permission |
|
|---|---|
Params |
|
Result |
|
Errors |
|
SDK |
|
String key/value store in the viewer's browser, namespaced seed.ext.<extensionId>.<siteUid>.<key>. keys() returns
the un-prefixed keys of this extension on this site.
ui.toast
Permission | — |
|---|---|
Params |
|
Result |
|
SDK |
|
ui.setTitle
Permission | — |
|---|---|
Params |
|
Result |
|
SDK |
|
Sets the host page/window title.
ui.resize
Permission | — |
|---|---|
Params |
|
Result |
|
SDK |
|
Asks the host to size the frame to height CSS pixels. Page extensions fill the available height and the host ignores
this; it exists for embedded kinds (custom blocks) on the roadmap.
ExtensionContext
Sent as the hello result and again, whole, in every context event.
Field | Type | Meaning |
|---|---|---|
|
| Bridge protocol the host speaks ( |
|
| Host kind |
|
|
|
|
| Document version actually loaded (pinned or latest) |
|
| The parsed manifest of that version |
|
| Space the extension is installed on |
|
| Site home document name |
|
| Public origin of the site when known (web) |
|
| Where it is mounted, e.g. |
|
| Segments after the mount, e.g. |
|
| Query string of the current URL |
|
|
|
|
| Signed-in viewer, or null |
|
| Host theme |
|
| Permissions actually granted (intersection of manifest and host policy) |
|
| True when loaded from a developer override instead of the published entry |
Events
Event | Data | When |
|---|---|---|
|
| User signed in/out, theme changed, route changed ( |
The SDK folds events into seed.context and calls every onContext listener.
Error codes
Wire shape: {code, message, data?}. The SDK rethrows them as ExtensionError with the same fields.
Code | Raised when |
|---|---|
| Method needs a permission the manifest does not grant ( |
| The viewer denied or closed the confirmation dialog |
| A |
|
|
| Params fail the method's zod schema, an id is not a valid |
| No host answered |
| Unexpected failure in the host (message only, no stack); or the SDK was disconnected while a call was pending |
Wire format
Every message carries the tag "seed-extension": 1 (tag name EXTENSION_MESSAGE_TAG, valueEXTENSION_PROTOCOL_VERSION) and a type. Untagged traffic is ignored by both sides.
Request (iframe → host, window.parent.postMessage(msg, '*')):
{"seed-extension": 1, "type": "request", "id": 7, "method": "storage.set", "params": {"key": "counter", "value": "3"}}Response (host → iframe, iframe.contentWindow.postMessage(msg, '*')), exactly one per request id:
{"seed-extension": 1, "type": "response", "id": 7, "result": null}{
"seed-extension": 1,
"type": "response",
"id": 8,
"error": {
"code": "permission_denied",
"message": "Method navigate requires the \"navigate\" permission, which this extension does not have",
"data": {"permission": "navigate"}
}
}Event (host → iframe):
{"seed-extension": 1, "type": "event", "event": "context", "data": {"protocol": 1, "platform": "web", "…": "…"}}Rules:
id is a positive integer allocated by the SDK; responses to unknown ids are dropped.
result is never undefined on the wire — the host sends null.
'*' is the target origin in both directions because the sandboxed frame has an opaque origin. Trust comes from the
source check instead: the host only handles messages whose event.source === iframe.contentWindow; the SDK only
handles messages whose event.source === window.parent.
Requests only flow iframe → host; the SDK ignores any request it receives.
Handshake sequence
extension host
│ request hello {protocol: 1, sdkVersion} │ (repeated every 250 ms)
│ ─────────────────────────────────────────▶│
│ │ host listener attached; validates params,
│ │ answers with getContext()
│ response {id, result: ExtensionContext} │
│ ◀─────────────────────────────────────────│
│ connect() resolves; later: │
│ event context {…} │ on user / theme / route / settings change
│ ◀─────────────────────────────────────────│If no response arrives within timeoutMs (default 5000) connect() rejects with not_supported. The host does not
check minProtocol here — it checks the manifest before mounting the frame and shows "Extension needs a newer app"
instead of loading it.
Id-unpacking rule
The host's query API takes UnpackedHypermediaId objects; the SDK does not carry the URL parser, so it sends packed
strings and the host unpacks them (normalizeHmIdInput in host-utils.ts). Accepted forms in an id position:
an hm:// string — 'hm://z6Mk…/docs?v=bafy…';
{id: 'hm://…'} (what hmRef(url, version?) builds);
an already-unpacked object with uid (path, version, latest, blockRef optional).
A v query parameter pins the document version; without it the id resolves to the latest known version. Anything else
is invalid_params. The fields unpacked per key:
Key | Field |
|---|---|
| the whole input |
|
|
|
|
|
|
Other keys are passed through untouched.
Read query keys
EXTENSION_READ_QUERY_KEYS, with the purpose and the input shape (from the HM*InputSchemas infrontend/packages/client/src/hm-types.ts and the implementations infrontend/packages/shared/src/api-*.ts). Ids marked _hm id_ accept the forms above.
Key | Purpose | Input |
|---|---|---|
| A document, comment, redirect or tombstone by id → | _hm id_ |
| Just the metadata of a resource (cheaper than | _hm id_ |
| An account's profile metadata → |
|
| Contact records published by an account |
|
| Contact records naming an account as their subject |
|
| One comment → |
|
| Full-text search → |
|
| List documents in a space/folder → |
|
| The same query as rendered by a query block, with per-item summaries |
|
| All comments on a document → |
|
| Comments grouped into discussion threads, optionally one thread |
|
| Comments whose target is the given resource (incl. citations) |
|
| Comments written by an account → |
|
| Edit history of a comment → |
|
| Number of replies to a comment |
|
| Activity feed (document updates, comments, citations, …) → |
|
| Resources that cite (link to) the target |
|
| Version history of a document (change ids, authors, deps) |
|
| Access-control capabilities granted on the target |
|
| Who may write to the document, resolved from capabilities → |
|
| Comment / citation / change counts for a document |
|
| Decode a raw blob by CID → |
|
Write keys (PublishBlobs, PrepareDocumentChange) are not reachable through api.query; writes go through sign.*.
Protocol versioning
EXTENSION_PROTOCOL_VERSION (currently 1) is stamped on every message as the value of the seed-extension tag and
reported in hello and context.protocol. It is bumped only when the wire format changes incompatibly; adding
methods, optional params or context fields does not bump it.
A manifest may set minProtocol. The host compares it with its own version before mounting the frame and shows
"Extension needs a newer app" when the host is too old. Omit it unless you depend on something introduced after
protocol 1.
Hosts accept any hello.protocol; an SDK newer than the host will get unknown_method for methods the host lacks,
and should fall back or check context.protocol.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime