HUB.KI DocsCustom apps
HUB.KI Docs
Custom apps

The bridge

The message protocol between your app and HUB.KI — handshake, host context, API, REST 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 — 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.

FieldTypeDescription
theme"light" | "dark"The user's active theme.
localestringBCP-47 language tag, e.g. en.
displayMode"fullscreen"How the app is presented.
tokensRecord<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.

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

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.keys–string[]

Identity and backend access

MethodScopeReturns
user.currentuser:read{ id, name, email }
connection.getapi:access{ coreUrl, iamUrl, organizationId, token }
token.getapi:accessThe current access token
apiToken.getapi:access and an approved REST API scopeThe 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:

NotificationParamsCarries
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: "{…}" }
ParamTypeNotes
method"GET" | "POST" | "PUT" | "PATCH" | "DELETE"
pathstringPath and query under /v1/. /v1/responses and anything below it are refused.
headers{ "content-type"?, "idempotency-key"? }Optional. Any other header is rejected.
bodystringOptional 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:

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

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