HUB.KI DocsCustom apps
HUB.KI Docs
Custom apps

SDK reference

The @hub-ki/app-sdk client — connecting, storage, identity, 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), 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 demand

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

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

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

toast 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") {
    /* … */
  }
}
codeMeaning
TIMEOUTThe host didn't respond in time.
REQUEST_FAILEDThe host rejected the call (e.g. missing scope).
INVALID_RESPONSEThe 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:

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.
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.
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.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}`,
        },
      }),
    },
  },
});

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.