# The bridge (https://docs.hub.ki/custom-apps/bridge)



Most apps should use [`@hub-ki/app-sdk`](https://docs.hub.ki/custom-apps/quick-start), 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](https://www.jsonrpc.org/specification)
over `postMessage`. The protocol is small enough to implement directly; a
complete helper is at the [bottom of this page](#a-reusable-helper).

* **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`](https://developer.mozilla.org/docs/Web/API/MessagePort) attached
to the response, and **every later message travels over that port**. Adopt it, or
you will stop receiving responses.

```js
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:

```js
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:

```js
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.

```js
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](https://docs.hub.ki/custom-apps/sdk#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](#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:

```js
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](https://docs.hub.ki/custom-apps/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.

```js
// 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.

```js
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:

```js
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.

```js
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);
```
