The bridge
The message protocol between your app and HUB.KI — handshake, host context, API, REST 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 — and apiScopes, the REST API scopes
it approved. Read them to adapt the UI rather than calling a method and catching
the failure:
if (hub.hasScope("documents:read")) {
showDocumentPicker();
}User activity
Organizations can sign people out after a period of inactivity. Input inside your
app's frame never reaches HUB.KI, so report it: after ui/initialize, send
ui/notifications/user-activity over the port when the person clicks, types,
scrolls or touches, at most every 30 seconds. HUB.KI only counts it while its tab
is visible and focused. The SDK (1.6.0 and later) does this for you. Calls your
app makes on its own do not count, so an app that never reports input leaves its
users looking idle while they work inside it.
let lastReportAt = -Infinity;
for (const name of ["pointerdown", "keydown", "wheel", "touchstart"]) {
addEventListener(
name,
(event) => {
if (!event.isTrusted || Date.now() - lastReportAt < 30_000) return;
lastReportAt = Date.now();
hostPort.postMessage({
jsonrpc: "2.0",
method: "ui/notifications/user-activity",
});
},
{ capture: true, passive: true }
);
}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. |
tokens | Record<string, string> | Design tokens of the theme the user sees, keyed in camelCase (primary, chart1, fontSans, …). See Theme tokens. |
The host pushes a notification when the user switches theme or language, or the theme's tokens change. 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, or hand it to the user with
ui.download.
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 |
apiToken.get | api:access and an approved REST API scope | The current REST API token |
token.get returns the token that identifies the user to your backend;
apiToken.get returns one your backend can use against the HUB.KI REST API (see
Calling HUB.KI from your backend). When no token can be
issued, both fail with error.data.code set to scope_not_granted,
access_denied or token_unavailable; connection.get answers with
token: null instead.
Tokens are short-lived. For api:access apps the host pushes a fresh one about a
minute before the current one expires, so the app can hold a current token
without polling:
| Notification | Params | Carries |
|---|---|---|
hub/token/changed | { token } | The token token.get returns. |
hub/apiToken/changed | { token } | The token apiToken.get returns; only if a REST API scope is approved. |
When a renewal fails, the host sends the notification once with
{ token: null, error: { code, message } }, using the codes above, and keeps
trying; the next token it gets arrives as a normal notification.
REST API
api.request calls the HUB.KI REST API as the user who has the app open. The
host attaches the REST API token itself, so the token never enters the iframe.
It needs an approved REST API scope; what the
request may do is described under REST API in the SDK
reference.
const response = await api("api.request", {
method: "POST",
path: "/v1/documents",
headers: {
"content-type": "application/json",
"idempotency-key": crypto.randomUUID(),
},
body: JSON.stringify({ title: "Meeting notes" }),
});
// { status: 201, headers: { "content-type": "application/json", … }, body: "{…}" }| Param | Type | Notes |
|---|---|---|
method | "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | |
path | string | Path and query under /v1/. /v1/responses and anything below it are refused. |
headers | { "content-type"?, "idempotency-key"? } | Optional. Any other header is rejected. |
body | string | Optional JSON or text, up to 1 MiB. |
The result is { status, headers, body }: the HTTP status, the response
headers content-type, x-request-id, retry-after, ratelimit-limit,
ratelimit-remaining and ratelimit-reset (when present), and the body as
text. Error statuses are returned, not thrown — check status. The call
itself fails only when the host refuses it: error.data.code is
scope_not_granted when no REST API scope is approved, invalid_request for a
disallowed path, an oversized body or malformed params, and access_denied or
token_unavailable when no token could be issued.
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 |
ui.download | { name, data, mimeType? } or { fileId, name? } | Saves a file to the user's computer |
ui.download has two modes. With data — a string, Blob, ArrayBuffer or
Uint8Array (postMessage clones all of them), up to 100 MB — it saves those
bytes. With fileId it saves a file from the app's own workspace as stored;
that mode needs workspace:own, and a file outside the app's workspace is
refused. Right after a user interaction the download starts immediately;
otherwise the host shows a notification with a download button. A rejected
download fails with error.data.code invalid_request, or scope_not_granted
for a fileId download without workspace:own. A { ok: true } result means
the host accepted the download; it does not confirm that a file was saved, since
the user can dismiss the notification and the host drops a download whose
signed-in user changed.
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.
The relayed request body — input, text and the other parameters as JSON,
in UTF-8 bytes — may be at most 256 KB. The host rejects a larger create with
invalid_request before it reaches the platform. Split long input across turns
with previousResponseId.
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). |
When a failed call has a stable, machine-readable reason, error.data.code
carries it — for example scope_not_granted for a missing scope, or the codes
listed under REST API and
Running AI responses.
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(
Object.assign(new Error(data.error.message), {
data: data.error.data,
})
)
: 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);