SDK reference
The @hub-ki/app-sdk client — connecting, theming, storage, identity, the REST API, events, agents, and UI.
@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).
| Option | Type | Default | Meaning |
|---|---|---|---|
timeoutMs | number | 10000 | How long to wait for the host to answer the handshake before rejecting with TIMEOUT. |
mock | boolean | MockHostOptions | off | Use the in-memory host instead of HUB.KI — see Developing without HUB.KI. true means defaults. |
targetOrigin | string | "*" | Origin the SDK addresses its handshake to. Set it to your HUB.KI origin to refuse any other parent window. |
sdkVersion | string | SDK_VERSION | Version 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:
chart1tochart5. - Shape and type:
radius(the base corner radius),fontSansandfontMono(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:
| Methods | Token | Also 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 demandBoth 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:
code | Meaning |
|---|---|
scope_not_granted | The app has no approved api:access, or (for the REST API token) no approved REST API scope. |
access_denied | The organization withdrew the approval, or the user may no longer use the app. |
token_unavailable | The 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 });| Method | Returns | Notes |
|---|---|---|
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/responsescannot be reached this way; run AI throughhub.responsesinstead.- 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-typeandidempotency-keyrequest headers are forwarded; responses carry onlycontent-type,x-request-id,retry-afterand theratelimit-*headers. POST /v1/filesandGET /v1/files/{id}return presigned storage URLs. The app cannot reach them unless the storage origin is listed inconnectOriginsand 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:
code | Meaning |
|---|---|
scope_not_granted | No REST API scope is approved for the app. |
invalid_request | The path is not under /v1/ or is /v1/responses, the body is over 1 MiB, or a field is invalid. |
access_denied, token_unavailable | No 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);| Field | Type | Notes |
|---|---|---|
method | "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | |
path | string | Path and query, starting with /v1/. |
headers | { "content-type"?, "idempotency-key"? } | Optional; no other headers are accepted. |
body | string | Optional. 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
model | Runs 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.format | Output |
|---|---|
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:
code | Meaning | Retry? |
|---|---|---|
scope_not_granted | The app does not request responses:run, or the organization has not approved it. | No — needs an admin. |
app_not_available | The app is unknown, or not available to this user or organization. | No. |
key_revoked | An admin revoked the app's key; it works again after re-approval. | No — needs an admin. |
key_resolution_unavailable | The platform could not verify the app's key just now. | Yes, shortly. |
rate_limited | Too many requests. | Yes, after a pause. |
rate_limiter_unavailable | The rate limiter was briefly unreachable. | Yes, shortly. |
concurrency_exceeded | Too many runs at once. | Yes, when one has finished. |
over_budget | The organization's budget is used up. | No. |
invalid_request | The request is malformed, or larger than 256 KB (see Request size). | No — fix the call. |
previous_response_not_found | previousResponseId does not exist, belongs to someone else, or passed its retention. | No — start a new thread. |
previous_response_in_progress | The response you want to continue is still running. | Yes, once it has finished. |
response_not_found | get or cancel was given an unknown id. | No. |
structured_output_failed | The model did not produce JSON matching the requested format. | Possibly, with a simpler schema. |
cancelled | The run was cancelled. | — |
run_failed | The 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 workspacetoast 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.datais astring,Blob,ArrayBufferorUint8Arrayand is saved byte for byte, so binary formats and legacy encodings survive.mimeTypedefaults to theBlob's own type, elseapplication/octet-stream. - Workspace file:
{ fileId, name? }saves a file from the app's own workspace — one it created withcreateFileor the user uploaded throughworkspace.upload— exactly as stored.namedefaults 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") {
/* … */
}
}code | Meaning |
|---|---|
TIMEOUT | The host didn't respond in time. |
scope_not_granted | The call needs a scope the app has not been granted. |
REQUEST_FAILED | The host rejected the call for another reason (e.g. invalid params). |
INVALID_RESPONSE | The 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:
| Call | Limit |
|---|---|
toast | message 1–500 characters, description up to 500. |
setTitle | Up to 200 characters. |
listDocuments | limit from 1 to 100. |
workspace.createDocument / updateDocument | markdown up to 100,000 characters, title 1–200. |
workspace.createFile / updateFile | content up to 1,000,000 characters, name 1–200. |
workspace.renameFile | name 1–200 characters. |
workspace.upload | accept up to 200 characters. |
download | data up to 100 MB, name 1–200 characters; rejects with invalid_request instead. |
store.set / setJSON | Key 1–256 characters, value up to 1,000,000 characters. |
Teardown
hub.disconnect(); // detach the bridge listenerDeveloping 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.
| Option | Default | Effect |
|---|---|---|
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. |
api | answers 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.create | replies "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:ownandstore:own. Without explicitscopesboth are granted; if you passscopes, include them or those calls reject withscope_not_grantedbeforeonCallruns. workspace.createFileinput is validated beforeonCallis consulted.- Mock file ids are UUIDs instead of
mock-file-1,mock-file-2, ….