SDK reference
The @hub-ki/app-sdk client — connecting, storage, identity, 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), and displayMode (currently always
"fullscreen"). Apply it on load and subscribe to changes:
document.documentElement.dataset.theme = hub.context.theme;
const stop = hub.onContextChange((context) => {
document.documentElement.dataset.theme = context.theme;
});Subscriptions return an unsubscribe function.
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();
}The list is fixed for the lifetime of the connection. After an admin approves a scope, the app sees it the next time it is opened.
Identity
Read the signed-in user's profile. Requires user:read.
const user = await hub.getUser(); // { id, name, email }Backend access
Obtain the platform URLs, the organization id, and an access token so your
own backend can call HUB.KI APIs as the signed-in user. Requires
api:access. The token is short-lived; subscribe to stay current instead of
caching it.
const { coreUrl, iamUrl, organizationId } = await hub.getConnection();
let token: string | null = null;
const stop = hub.onToken((next) => {
token = next; // delivered immediately, then on every refresh
});
await hub.getToken(); // or pull a fresh token on demandSee 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);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.
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. | 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 entrytoast takes message, an optional description, and a variant:
"default", "success", "error", "warning" or "info".
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. |
REQUEST_FAILED | The host rejected the call (e.g. missing scope). |
INVALID_RESPONSE | The host returned something unexpected. |
hub.responses calls reject with the more specific codes listed under
Error codes instead. All of them are part of the exported
HubErrorCode type.
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. |
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. |
scopes | [] | What hub.scopes and hub.hasScope() report. The mock does not enforce scopes: every call answers. |
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}`,
},
}),
},
},
});Known gaps in the mock: workspace.renameFile and workspace.deleteFile are not
simulated and reject with "Unknown method" unless you answer them through
onCall, and workspace.upload always reports a cancelled picker, since there
is no host UI to open.