HUB.KI DocsCustom apps
HUB.KI Docs
Custom apps

The bridge

The message protocol between your app and HUB.KI — handshake, host context, API, events, agents, and sharing.

View as Markdown

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.

FieldTypeDescription
theme"light" | "dark"The user's active theme.
localestringBCP-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.

MethodParamsReturns
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.

MethodScopeParamsReturns
documents.listdocuments:read{ limit? }{ id, kind, title } per document
documents.getdocuments: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.

MethodParamsReturns
store.get{ key }string | null
store.set{ key, value }
store.remove{ key }
store.keysstring[]

Identity and backend access

MethodScopeReturns
user.currentuser:read{ id, name, email }
connection.getapi:access{ coreUrl, iamUrl, organizationId, token }
token.getapi:accessThe 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:

MethodParamsEffect
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:

CodeMeaning
-32602Invalid params.
-32601Unknown method.
-32000The 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);