The bridge
The message protocol between your app and HUB.KI — handshake, host context, API, events, agents, and sharing.
Most apps should use @hub-ki/app-sdk, which
wraps everything on this page. This page documents the underlying protocol — read
it to understand what the SDK does, or to implement the bridge yourself.
Your app and the host speak JSON-RPC 2.0
over postMessage. The protocol is small enough to implement directly; a
complete helper is at the bottom of this page.
- Requests (with an
id) expect a matching response. - Notifications (no
id) are fire-and-forget, in either direction.
Handshake
Before anything else, initialize. The host replies with the current context, and you acknowledge that you are ready.
Your app runs in a sandboxed iframe with an opaque origin, so the host cannot
address it by origin — the first message is the only one exchanged on the window.
The host answers ui/initialize with a
MessagePort attached
to the response, and every later message travels over that port. Adopt it, or
you will stop receiving responses.
addEventListener("message", (event) => {
const [port] = event.ports;
if (port) {
hostPort = port;
hostPort.addEventListener("message", ({ data }) => receive(data));
hostPort.start();
}
receive(event.data);
});
const { hostContext } = await request("ui/initialize", { sdkVersion: "1.0.0" });
hostPort.postMessage({
jsonrpc: "2.0",
method: "ui/notifications/initialized",
});Send the initial ui/initialize with parent.postMessage(message, "*"), and
everything after it with hostPort.postMessage(message) — no target origin, and
no other frame can read or forge it.
If the host does not support your sdkVersion, ui/initialize fails and the app
is replaced with an upgrade notice.
The result also carries scopes — the permissions your organization actually
approved. Read them to adapt the UI rather than calling a method and catching the
failure:
if (hub.hasScope("documents:read")) {
showDocumentPicker();
}Host context
hostContext carries the current environment. Apply it on load and whenever it
changes.
| Field | Type | Description |
|---|---|---|
theme | "light" | "dark" | The user's active theme. |
locale | string | BCP-47 language tag, e.g. en. |
displayMode | "fullscreen" | How the app is presented. |
The host pushes a notification when the user switches theme or language. It arrives on the port from the handshake:
hostPort.addEventListener("message", ({ data }) => {
if (data?.method === "ui/notifications/host-context-changed") {
applyContext(data.params.hostContext);
}
});Calling the platform
Send hub/api/call with a method and its params. Each method requires a
scope; calling one without the scope throws.
const api = (method, params) => request("hub/api/call", { method, params });
await api("workspace.createDocument", { title: "Ideas", markdown: "# Ideas" });Storing data
The workspace:own scope gives your app a private workspace, one per user.
It holds two kinds of entries:
- Documents — Markdown, editable and shareable like any workspace document.
- Files — opaque string blobs (up to 1 MB), ideal for JSON app state.
The host validates every call; the size limits are listed under Limits in the SDK reference.
| Method | Params | Returns |
|---|---|---|
workspace.ensure | – | { id, title } |
workspace.listDocuments | – | { workspace, documents } |
workspace.createDocument | { title, markdown } | { id, title } |
workspace.getDocument | { documentId } | { id, title, markdown } |
workspace.updateDocument | { documentId, markdown, title? } | { id, title, markdown } |
workspace.createFile | { name, content } | { id, title } |
workspace.getFile | { fileId } | { id, title, content } |
workspace.updateFile | { fileId, content } | { id, title } |
workspace.renameFile | { fileId, name } | { id, title } |
workspace.deleteFile | { fileId } | { id } |
workspace.upload | { accept? } | { cancelled, documentId?, title? } |
workspace.listDocuments returns every entry in the workspace; each has an id
and a title (its name). A workspace is private by default — the user shares
individual entries themselves (see Sharing).
Uploading a file
workspace.upload asks the host to open a file picker. The file never
crosses the sandbox boundary: the host reads it, uploads it as the signed-in
user, and puts it in your app's workspace. You get back a document id, or
{ cancelled: true } if the user dismissed the picker.
Uploads run through the platform's normal ingest, so text extraction — including OCR for scanned PDFs — happens automatically. Poll the document until extraction finishes:
const upload = await hub.workspace.upload({ accept: "application/pdf" });
if (!upload.cancelled) {
let document = await hub.workspace.getDocument(upload.documentId);
while (document.extraction === "pending") {
await new Promise((resolve) => setTimeout(resolve, 1000));
document = await hub.workspace.getDocument(upload.documentId);
}
// document.extraction is "ready" (markdown populated) or "failed"
}Because the host owns the picker, an app cannot upload bytes it generated itself — only files the user chose. If you need to persist generated output, write it as a workspace file (text) for now.
extraction is "pending" until the extractor writes back, "ready" when
markdown reflects the current file, and "failed" if extraction gave up.
Guard the polling loop with a timeout — a large scan can take a while.
Reading shared documents
With the documents:read scope, list the documents from the user's shared
organization workspace and read one by id. list returns metadata only —
content never rides along with a listing, so fetch it deliberately with get.
| Method | Scope | Params | Returns |
|---|---|---|---|
documents.list | documents:read | { limit? } | { id, kind, title } per document |
documents.get | documents:read | { documentId } | The above plus markdown |
get resolves against what the user may see, not what the app knows about:
an id the user cannot access returns a not-found error.
Key/value store
The store:own scope gives your app a localStorage-style store, private to
each user and scoped to the app and its version.
| Method | Params | Returns |
|---|---|---|
store.get | { key } | string | null |
store.set | { key, value } | – |
store.remove | { key } | – |
store.keys | – | string[] |
Identity and backend access
| Method | Scope | Returns |
|---|---|---|
user.current | user:read | { id, name, email } |
connection.get | api:access | { coreUrl, iamUrl, organizationId, token } |
token.get | api:access | The current access token |
Access tokens are short-lived. For api:access apps the host also pushes a
hub/token/changed notification (params { token }) whenever it refreshes, so
the app can hold a current token without polling.
Host UI
These take effect on the host immediately and need no scope:
| Method | Params | Effect |
|---|---|---|
ui.toast | { message, variant?, description? } | Shows a host toast |
ui.setTitle | { title } | Sets the browser tab title |
Events
Declare event names in your manifest, then publish them. The host delivers each event to other open sessions of the app in real time, so separate tabs stay in sync. Publishing an undeclared event fails.
// Publish
await request("hub/events/publish", {
event: "board-updated",
payload: { boardId },
});
// Receive
hostPort.addEventListener("message", ({ data }) => {
if (data?.method === "hub/events/message") {
const { event, payload } = data.params;
}
});Running AI responses
With the responses:run scope, run the platform's AI through the Responses
API. Send hub/responses/create with a client-generated requestId; streamed
Responses API events arrive as hub/responses/event notifications carrying
that requestId, and the request itself resolves with the final response
object.
The requestId must be unique among your in-flight runs: the host rejects a
create whose requestId already belongs to a run it is still streaming, with
the code invalid_request. Generate a fresh one per call with
crypto.randomUUID() rather than reusing a fixed string, or two concurrent
creates will fail.
const requestId = crypto.randomUUID();
hostPort.addEventListener("message", ({ data }) => {
if (
data?.method === "hub/responses/event" &&
data.params.requestId === requestId &&
data.params.event.type === "response.output_text.delta"
) {
appendText(data.params.event.delta);
}
});
const response = await request("hub/responses/create", {
input: "Summarize this board",
requestId,
});A single run forwards at most 10 000 events. Once a stream goes past that cap,
the host emits one synthetic hub.response.events_truncated event carrying
max_events (the cap) and stops forwarding the deltas that follow. The
create call still resolves with the complete final response object, so an app
that sees this event should render the resolved result instead of the text it
accumulated from deltas.
hub/responses/get and hub/responses/cancel take a responseId;
hub/responses/abort takes the requestId of an in-flight create and tears
its stream down. A failed call rejects with a JSON-RPC error whose
error.data.code is a stable, machine-readable code (app_not_available,
scope_not_granted, key_revoked, key_resolution_unavailable,
rate_limited, rate_limiter_unavailable, concurrency_exceeded,
over_budget, structured_output_failed, invalid_request,
previous_response_not_found, previous_response_in_progress,
response_not_found, cancelled, run_failed). Two of these are worth retrying rather than surfacing as a
permanent failure: rate_limiter_unavailable and key_resolution_unavailable
both mean a platform dependency was briefly unreachable, not that the request
was wrong.
Unlike the bearer-token Responses API, this path has no
Idempotency-Key: one app-bound key is shared by every user of the app, so a
per-key replay cache would let two people void each other's guarantee. Retries
are the app's own concern — pass a previousResponseId to continue a
conversation, and treat each create as a new run.
Sharing
To let the user share one of your workspace entries, ask the host to open its share dialog with the entry's id:
await request("ui/share/request", { documentId: file.id });The host verifies the document belongs to your app's workspace and presents the standard share controls (specific people, or the whole organization).
Errors
Failed requests return a JSON-RPC error:
| Code | Meaning |
|---|---|
-32602 | Invalid params. |
-32601 | Unknown method. |
-32000 | The call failed (e.g. missing scope). |
A reusable helper
Drop this into your artifact and import the exported functions.
const pending = new Map();
let nextId = 1;
let hostPort = null;
const listeners = { context: new Set(), event: new Set() };
function post(message) {
if (hostPort) hostPort.postMessage(message);
else parent.postMessage(message, "*");
}
function request(method, params) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
post({ jsonrpc: "2.0", id, method, params });
});
}
function receive(data) {
if (data?.jsonrpc !== "2.0") return;
if (data.id && pending.has(data.id)) {
const { resolve, reject } = pending.get(data.id);
pending.delete(data.id);
data.error ? reject(new Error(data.error.message)) : resolve(data.result);
} else if (data.method === "ui/notifications/host-context-changed") {
for (const fn of listeners.context) fn(data.params.hostContext);
} else if (data.method === "hub/events/message") {
for (const fn of listeners.event) fn(data.params);
}
}
addEventListener("message", (event) => {
const [port] = event.ports;
if (port) {
hostPort = port;
hostPort.addEventListener("message", ({ data }) => receive(data));
hostPort.start();
}
receive(event.data);
});
export async function initialize() {
const { hostContext } = await request("ui/initialize", {
sdkVersion: "1.0.0",
});
post({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
return hostContext;
}
export const callApi = (method, params) =>
request("hub/api/call", { method, params });
export const publishEvent = (event, payload) =>
request("hub/events/publish", { event, payload });
export const requestShare = (documentId) =>
request("ui/share/request", { documentId });
export const onHostContext = (fn) => listeners.context.add(fn);
export const onEvent = (fn) => listeners.event.add(fn);