HUB.KI DocsCustom apps
HUB.KI Docs
Custom apps

SDK reference

The @hub-ki/app-sdk client — connecting, theming, storage, identity, the REST API, events, agents, and UI.

View as Markdown

@hub-ki/app-sdk wraps the bridge in plain async functions. Everything hangs off the client returned by connect().

import { connect } from "@hub-ki/app-sdk";

const hub = await connect();

Every capability is gated by a scope your app declares — the scope each method needs is noted below.

connect

const hub = await connect({
  timeoutMs: 10_000, // reject if the host doesn't answer (default 10s)
});

connect() performs the handshake and returns a ready HubApp. On failure it rejects with a HubError (see Errors).

OptionTypeDefaultMeaning
timeoutMsnumber10000How long to wait for the host to answer the handshake before rejecting with TIMEOUT.
mockboolean | MockHostOptionsoffUse the in-memory host instead of HUB.KI — see Developing without HUB.KI. true means defaults.
targetOriginstring"*"Origin the SDK addresses its handshake to. Set it to your HUB.KI origin to refuse any other parent window.
sdkVersionstringSDK_VERSIONVersion reported to the host in the handshake. Defaults to the installed SDK's version; you rarely need to set it.

The package also exports SDK_VERSION, the version string of the installed SDK.

Host context

hub.context carries the user's theme ("light" | "dark"), locale (a BCP-47 language tag such as en or de), displayMode (currently always "fullscreen"), and tokens, the design tokens of the theme the user sees (see Theme tokens). Apply it on load and subscribe to changes:

import { applyThemeTokens } from "@hub-ki/app-sdk";

document.documentElement.dataset.theme = hub.context.theme;
applyThemeTokens(hub.context.tokens);
const stop = hub.onContextChange((context) => {
  document.documentElement.dataset.theme = context.theme;
  applyThemeTokens(context.tokens);
});

Subscriptions return an unsubscribe function.

Theme tokens

hub.context.tokens holds the colors, radius and fonts of the theme the user currently sees in HUB.KI: light, dark, or a custom theme of their organization. Every theme switch delivers the new tokens through onContextChange.

  • Colors: background, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, border, input, ring, sidebar, sidebarForeground, sidebarPrimary, sidebarPrimaryForeground, sidebarAccent, sidebarAccentForeground, sidebarBorder, sidebarRing.
  • Chart colors: chart1 to chart5.
  • Shape and type: radius (the base corner radius), fontSans and fontMono (font stacks).

Values are CSS values such as oklch(0.21 0.012 75) or #1e40af. A theme may leave a key out, so treat every token as optional and keep a fallback in your CSS. The sandbox cannot load web fonts, so the browser uses the first font of a stack that is installed locally.

applyThemeTokens(tokens, element = document.documentElement) writes the tokens as the CSS variables HUB.KI itself uses — background becomes --background, primaryForeground becomes --primary-foreground, chart1 becomes --chart-1 — and removes the variables of keys the new theme no longer sets. Styles written against those variables, such as shadcn/ui or Tailwind themes, pick the theme up directly:

body {
  background: var(--background, #fff);
  color: var(--foreground, #111);
  font-family: var(--font-sans, system-ui, sans-serif);
}
button {
  background: var(--primary);
  color: var(--primary-foreground);
  border-radius: var(--radius);
}

THEME_TOKEN_KEYS lists every key, and themeTokenCssVariable(key) returns the variable name for one (themeTokenCssVariable("chart1") is "--chart-1"). The ThemeTokens and ThemeTokenKey types describe them.

Granted scopes

hub.scopes lists the scopes the host granted to your app when it connected — the automatic ones plus those an organization admin has approved — and hub.hasScope(scope) checks one. Use it to switch features off instead of letting calls fail:

if (hub.hasScope("responses:run")) {
  showSummarizeButton();
}

hub.apiScopes and hub.hasApiScope(scope) do the same for the REST API scopes: the entries of the manifest's apiScopes that an admin has approved.

if (hub.hasApiScope("meetings:read")) {
  showMeetingList();
}

Both lists are fixed for the lifetime of the connection. After an admin approves a scope, the app sees it the next time it is opened. Withdrawing a REST API scope takes effect sooner: REST API requests that need it fail from the next request on, even while hub.apiScopes still names it.

Identity

Read the signed-in user's profile. Requires user:read.

const user = await hub.getUser(); // { id, name, email }

Backend access

Hand your own backend short-lived tokens it can use on the user's behalf. Requires api:access. There are two kinds:

MethodsTokenAlso requires
getToken(), onToken()Proves to your backend who the user is and which scopes your app has.—
getApiToken(), onApiToken()Lets your backend call the HUB.KI REST API as the user, limited to the approved API scopes.An approved REST API scope

getConnection() returns the platform URLs and the organization id alongside the current identity token. Tokens are short-lived; subscribe to stay current instead of caching them.

const { coreUrl, iamUrl, organizationId } = await hub.getConnection();

let token: string | null = null;
const stop = hub.onToken((next, error) => {
  token = next; // delivered immediately, then on every renewal
  if (error) showNotice(error.message);
});

let apiToken: string | null = null;
const stopApi = hub.onApiToken((next) => {
  apiToken = next; // same pattern for the REST API token
});

await hub.getToken(); // or pull a fresh token on demand

Both handlers are called as handler(token, error?), with token a string or null and error a HubError (the TokenHandler type). They run once right away with the current token, then whenever the host renews it, about a minute before it expires. When no token can be issued — the scope is not approved, an admin withdrew the approval, the user lost access to the app, or issuing failed — the handler receives null and a HubError whose code says why:

codeMeaning
scope_not_grantedThe app has no approved api:access, or (for the REST API token) no approved REST API scope.
access_deniedThe organization withdrew the approval, or the user may no longer use the app.
token_unavailableThe platform could not issue a token just now. The host keeps trying and delivers the next token it gets.

After a renewal fails the handler is called with null once, not on every retry. getToken() and getApiToken() reject with the same codes; getConnection() answers with token: null instead.

See Calling HUB.KI from your backend for the full request flow.

Store

A localStorage-style key/value store. Requires store:own. It is private to each user within each organization, and scoped to your app and its version — switching organizations or bumping the version starts a fresh store.

await hub.store.set("theme", "dark");
const theme = await hub.store.get("theme"); // string | null
await hub.store.setJSON("prefs", { compact: true });
const prefs = await hub.store.getJSON<Prefs>("prefs"); // Prefs | null
await hub.store.keys(); // string[]
await hub.store.remove("theme");

Workspace

Your app's private per-user workspace of documents (Markdown) and files (opaque string blobs). Requires workspace:own.

const { documents } = await hub.workspace.list(); // documents and files, each with a `kind`
const file = await hub.workspace.createFile({ name: "board.json", content });
await hub.workspace.updateFile({ fileId: file.id, content });
MethodReturnsNotes
workspace.ensure(){ id, title }Creates the workspace if it does not exist yet and returns it.
workspace.list(){ workspace, documents }documents holds every entry as { id, kind, title }, kind being "document" or "file". workspace is null until it exists.
workspace.createDocument({ title, markdown? }){ id, title }A Markdown document, editable and shareable like any workspace document.
workspace.getDocument(documentId){ id, title, markdown, extraction }extraction is "pending", "ready" or "failed" for uploaded files still being converted.
workspace.updateDocument({ documentId, markdown, title? }){ id, title, markdown, extraction }Replaces the Markdown; title renames.
workspace.createFile({ name, content }){ id, title }content is a string; serialize JSON yourself.
workspace.getFile(fileId){ id, title, content }title is the file name.
workspace.updateFile({ fileId, content }){ id, title }Replaces the content.
workspace.renameFile({ fileId, name }){ id, title }
workspace.deleteFile(fileId){ id }
workspace.upload({ accept? }){ cancelled: true } or { cancelled: false, documentId, title }Opens the host's file picker and stores the chosen file as a document; accept filters the picker like <input accept>.

Sizes are capped — see Limits. The wire-level equivalents are in the bridge storage table.

Shared documents

List the documents from the user's shared organization workspace. Requires documents:read.

const docs = await hub.listDocuments({ limit: 20 });
const { markdown } = await hub.getDocument(docs[0].id);

REST API

Call the HUB.KI REST API (/v1/*) from the app, as the user who has it open. Requires at least one approved REST API scope, declared in the manifest's apiScopes; api:access is not needed for calls from the app itself.

The typed client lives in its own entry point, @hub-ki/app-sdk/api, so apps that don't import it don't ship it. createAppApiClient(hub) builds it from the public OpenAPI spec and sends every request through the host, which attaches the token: the token never reaches the iframe, and no connectOrigins entry is needed.

import { connect } from "@hub-ki/app-sdk";
import { createAppApiClient, HubApiError } from "@hub-ki/app-sdk/api";

const hub = await connect();
const api = createAppApiClient(hub);

const { data: meetings } = await api.openapi.GET("/v1/meetings", {
  params: { query: { limit: 20 } },
});

try {
  await api.openapi.POST("/v1/documents", {
    params: { header: { "Idempotency-Key": crypto.randomUUID() } },
    body: { title: "Meeting notes", markdown: "# Notes" },
  });
} catch (error) {
  if (error instanceof HubApiError && error.code === "insufficient_scope") {
    /* the organization has not approved documents:write */
  }
}

api.openapi is an openapi-fetch client typed by the exported paths, components and operations types.

What a request can do

  • It acts as the viewing user: their permissions apply, capped by the REST API scopes the organization approved for the app. Each user of the app has their own identity, so files, jobs, chats and idempotency keys created through the app stay separate between its users.
  • Approval is rechecked on every request. Withdrawing a scope, removing the user's access to the app, or disabling the app takes effect on the next request.
  • /v1/responses cannot be reached this way; run AI through hub.responses instead.
  • At most 60 requests per minute per user and 600 per minute for the app across all its users in the organization.
  • Bodies are JSON or text of up to 1 MiB. Only the content-type and idempotency-key request headers are forwarded; responses carry only content-type, x-request-id, retry-after and the ratelimit-* headers.
  • POST /v1/files and GET /v1/files/{id} return presigned storage URLs. The app cannot reach them unless the storage origin is listed in connectOrigins and approved; a backend using the REST API token is not affected.

REST API errors

An error status (4xx, 5xx) rejects with a HubApiError carrying the REST API's error envelope: status, code, message, requestId (the X-Request-Id to quote when reporting a problem), retryAfterSeconds, type and param. A 429 is retried automatically, waiting for Retry-After, up to three times before it rejects; isRetriableRateLimit(error) tells you whether a rejection was a rate limit.

When the host refuses the request before it reaches the API, the call rejects with a HubError instead:

codeMeaning
scope_not_grantedNo REST API scope is approved for the app.
invalid_requestThe path is not under /v1/ or is /v1/responses, the body is over 1 MiB, or a field is invalid.
access_denied, token_unavailableNo token could be issued; see Backend access.

Without the typed client

hub.requestApi() is the untyped building block underneath, also available without importing @hub-ki/app-sdk/api:

const { status, headers, body } = await hub.requestApi({
  method: "GET",
  path: "/v1/meetings?limit=20",
});
const meetings = JSON.parse(body);
FieldTypeNotes
method"GET" | "POST" | "PUT" | "PATCH" | "DELETE"
pathstringPath and query, starting with /v1/.
headers{ "content-type"?, "idempotency-key"? }Optional; no other headers are accepted.
bodystringOptional. Serialize JSON yourself and set content-type.

It resolves with { status, headers, body }, body being the response text.

Pass { signal } as a second argument to cancel a request: HUB.KI aborts the call to the REST API and the promise rejects with the signal's reason. The typed client does this for you with the request's own signal.

const controller = new AbortController();
const pending = hub.requestApi(
  { method: "GET", path: "/v1/meetings" },
  { signal: controller.signal }
);
controller.abort();

Unlike the typed client, error statuses resolve too: check status yourself.

Events

Declared in your manifest. Delivered to other open sessions of the app in real time.

await hub.publishEvent("board-updated", { boardId });
const stop = hub.onEvent(({ event, payload }) => {
  /* another session changed something */
});

AI responses

Run the platform's AI through the Responses API. Requires the responses:run scope — an org admin must approve it, and runs are billed to the organization through an automatically issued, app-bound API key. Your app never sees a credential.

const response = await hub.responses.create(
  { input: "Summarize this board" },
  {
    onEvent: (event) => {
      if (event.type === "response.output_text.delta") {
        append(event.delta); // streamed output
      }
    },
  }
);
console.log(response.output_text);

// Continue the conversation
const followUp = await hub.responses.create({
  input: "Shorter, please",
  previousResponseId: response.id,
});

// Structured output
const structured = await hub.responses.create({
  input: "List the three most urgent cards",
  text: {
    format: {
      type: "json_schema",
      name: "urgent_cards",
      schema: {
        type: "object",
        properties: { cards: { type: "array", items: { type: "string" } } },
        required: ["cards"],
      },
    },
  },
});
const { cards } = JSON.parse(structured.output_text);

hub.responses.get(responseId) re-reads a response and hub.responses.cancel(responseId) stops one that is running; pass an AbortSignal as signal to abandon a create in flight. Every user of the app sees only their own responses — previousResponseId cannot continue another user's conversation, even inside the same app and organization.

Request size

One create call may send at most 256 KB: the JSON-serialized input, text.format and the other parameters, measured in UTF-8 bytes. Umlauts take two bytes, and quotes, backslashes and line breaks are escaped, so 256 KB holds fewer than 256,000 characters of real text. A larger call rejects with invalid_request before anything runs, and the message gives the actual size.

The limit is per call, not per conversation. To send more, split the input across turns: each call continues the previous one through previousResponseId, so the model still sees every part.

let previousResponseId: string | undefined;
for (const part of parts) {
  const response = await hub.responses.create({
    input: part,
    previousResponseId,
  });
  previousResponseId = response.id;
}

Models and output formats

modelRuns on
omitted, "hub", "hub:default"The organization's default model tier.
"hub:powerful"The more capable tier, for harder tasks at a higher cost.

Any other value is rejected.

text.formatOutput
omitted, { type: "text" }Free text in output_text.
{ type: "json_object" }Valid JSON in output_text, without a schema.
{ type: "json_schema", name, schema, strict?, description? }JSON matching your schema, as in the example above.

Error codes

Failures reject with a HubError whose code your app can interpret:

codeMeaningRetry?
scope_not_grantedThe app does not request responses:run, or the organization has not approved it.No — needs an admin.
app_not_availableThe app is unknown, or not available to this user or organization.No.
key_revokedAn admin revoked the app's key; it works again after re-approval.No — needs an admin.
key_resolution_unavailableThe platform could not verify the app's key just now.Yes, shortly.
rate_limitedToo many requests.Yes, after a pause.
rate_limiter_unavailableThe rate limiter was briefly unreachable.Yes, shortly.
concurrency_exceededToo many runs at once.Yes, when one has finished.
over_budgetThe organization's budget is used up.No.
invalid_requestThe request is malformed, or larger than 256 KB (see Request size).No — fix the call.
previous_response_not_foundpreviousResponseId does not exist, belongs to someone else, or passed its retention.No — start a new thread.
previous_response_in_progressThe response you want to continue is still running.Yes, once it has finished.
response_not_foundget or cancel was given an unknown id.No.
structured_output_failedThe model did not produce JSON matching the requested format.Possibly, with a simpler schema.
cancelledThe run was cancelled.—
run_failedThe run failed for another reason.Possibly.

UI

await hub.toast({ message: "Saved", variant: "success" });
await hub.setTitle("My board"); // sets the browser tab title
await hub.share(file.id); // opens the host share dialog for a workspace entry
await hub.download({
  name: "report.pdf",
  data: pdfBytes,
  mimeType: "application/pdf",
});
await hub.download({ fileId: file.id }); // a file from the app's workspace

toast takes message, an optional description, and a variant: "default", "success", "error", "warning" or "info".

download saves a file to the user's computer — the sandbox cannot start downloads itself. It has two modes:

  • Bytes: { name, data, mimeType? } saves what your app generated. data is a string, Blob, ArrayBuffer or Uint8Array and is saved byte for byte, so binary formats and legacy encodings survive. mimeType defaults to the Blob's own type, else application/octet-stream.
  • Workspace file: { fileId, name? } saves a file from the app's own workspace — one it created with createFile or the user uploaded through workspace.upload — exactly as stored. name defaults to the file's name. Files outside the app's workspace are refused.

Call it from a click handler: right after a user interaction the download starts immediately; otherwise (say, after a long export) the host shows a notification with a download button instead. At most three such notifications wait at a time, and they disappear when the user leaves the app. Invalid parameters, data over 100 MB and a file outside the app's workspace reject with invalid_request; a fileId download without workspace:own rejects with scope_not_granted. Pass either data or fileId, never both.

The promise resolves once the host has accepted the request for delivery. It does not confirm that a file was saved: the user may dismiss the notification, the host drops a download whose signed-in user changed in the meantime, and a save can still fail.

Errors

Rejections are a typed HubError:

import { HubError } from "@hub-ki/app-sdk";

try {
  await hub.getConnection();
} catch (error) {
  if (error instanceof HubError && error.code === "TIMEOUT") {
    /* … */
  }
}
codeMeaning
TIMEOUTThe host didn't respond in time.
scope_not_grantedThe call needs a scope the app has not been granted.
REQUEST_FAILEDThe host rejected the call for another reason (e.g. invalid params).
INVALID_RESPONSEThe host returned something unexpected.

hub.responses calls reject with the more specific codes listed under Error codes instead, and the token methods and requestApi with those under Backend access and REST API errors. All of them are part of the exported HubErrorCode type. Error statuses from the typed REST API client are a HubApiError, not a HubError.

Limits

The host validates every call and rejects one that exceeds these limits with REQUEST_FAILED:

CallLimit
toastmessage 1–500 characters, description up to 500.
setTitleUp to 200 characters.
listDocumentslimit from 1 to 100.
workspace.createDocument / updateDocumentmarkdown up to 100,000 characters, title 1–200.
workspace.createFile / updateFilecontent up to 1,000,000 characters, name 1–200.
workspace.renameFilename 1–200 characters.
workspace.uploadaccept up to 200 characters.
downloaddata up to 100 MB, name 1–200 characters; rejects with invalid_request instead.
store.set / setJSONKey 1–256 characters, value up to 1,000,000 characters.

Teardown

hub.disconnect(); // detach the bridge listener

Developing without HUB.KI

Outside the platform there is no host to answer the bridge, so connect() would time out. Pass mock to get an in-memory host instead — the same client API, backed by maps that live until the page reloads:

const hub = await connect({
  mock: {
    scopes: ["workspace:own", "documents:read"],
    hostContext: { theme: "dark" },
    documents: [
      { id: "d1", kind: "document", title: "Notes", markdown: "# Notes" },
    ],
  },
});

mock: true is the shorthand for all defaults.

OptionDefaultEffect
hostContext{ theme: "light", locale: "en", displayMode: "fullscreen" }Merged over the defaults; becomes hub.context. Default tokens follow theme.
scopes["workspace:own", "store:own"]What hub.scopes and hub.hasScope() report. The mock enforces workspace:own and store:own for workspace, store and download({ fileId }) calls; every other call answers.
apiScopes[]What hub.apiScopes and hub.hasApiScope() report. With none, requestApi rejects.
apianswers 404(request) => ({ status?, headers?, body? }). Answers requestApi and the typed client.
documents[]The shared documents listDocuments and getDocument return.
onCall—(method, params) => result. Overrides one bridge method; return undefined to fall through.
responses.createreplies "Mock response"(params) => ({ response, events? }). Fakes hub.responses.create; events are delivered to onEvent.

Workspace documents and files, the key/value store, the user, and the connection all answer locally. onCall receives the bridge method name and its parameters, which is enough to replace a single reply:

const hub = await connect({
  mock: {
    onCall: (method) =>
      method === "user.current"
        ? { id: "u1", name: "Test User", email: "test@example.test" }
        : undefined,
  },
});

…or to make the store survive a reload while you develop. Your dev server is an ordinary page, so localStorage is available there even though it is not inside HUB.KI:

const hub = await connect({
  mock: {
    scopes: ["store:own"],
    onCall: (method, params) => {
      if (method === "store.set") {
        localStorage.setItem(params.key, params.value);
        return { ok: true };
      }
      if (method === "store.get") return localStorage.getItem(params.key);
      return undefined;
    },
  },
});

Fake an AI reply the same way:

const hub = await connect({
  mock: {
    scopes: ["responses:run"],
    responses: {
      create: (params) => ({
        response: {
          id: "resp_1",
          status: "completed",
          output_text: `echo: ${params.input}`,
        },
      }),
    },
  },
});

Fake the REST API the same way. The typed client from @hub-ki/app-sdk/api goes through api too:

const hub = await connect({
  mock: {
    apiScopes: ["meetings:read"],
    api: ({ method, path }) =>
      method === "GET" && path.startsWith("/v1/meetings")
        ? { body: { data: [], has_more: false, next_cursor: null } }
        : {
            status: 404,
            body: { error: { code: "not_found", message: path } },
          },
  },
});

status defaults to 200, and a body that is not a string is sent as JSON. The mock context carries HUB.KI's default light tokens, or the dark ones when hostContext.theme is "dark". getToken() and getApiToken() answer with fixed placeholder strings.

Known gaps in the mock: workspace.renameFile is not simulated and rejects with "Unknown method" unless you answer it through onCall, and workspace.upload always reports a cancelled picker, since there is no host UI to open. workspace.deleteFile removes the file from the mock workspace.

Since SDK 1.5.0 the mock behaves closer to production, which can change existing tests:

  • Workspace and store calls require workspace:own and store:own. Without explicit scopes both are granted; if you pass scopes, include them or those calls reject with scope_not_granted before onCall runs.
  • workspace.createFile input is validated before onCall is consulted.
  • Mock file ids are UUIDs instead of mock-file-1, mock-file-2, ….