# SDK reference (https://docs.hub.ki/custom-apps/sdk)



`@hub-ki/app-sdk` wraps the [bridge](https://docs.hub.ki/custom-apps/bridge) in plain
`async` functions. Everything hangs off the client returned by `connect()`.

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

const hub = await connect();
```

Every capability is gated by a [scope](https://docs.hub.ki/custom-apps/manifest#scopes)
your app declares — the scope each method needs is noted below.

## connect
```ts
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](#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](#developing-without-hubki). `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:

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

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

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

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

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

```ts
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](#limits). The wire-level equivalents are in the
[bridge storage table](https://docs.hub.ki/custom-apps/bridge#storing-data).

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

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

## Events
Declared in your [manifest](https://docs.hub.ki/custom-apps/manifest). Delivered to other
open sessions of the app in real time.

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

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

```ts
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](#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
```ts
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:

```js
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](https://docs.hub.ki/custom-apps/bridge#calling-the-platform)
and its parameters, which is enough to replace a single reply:

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

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

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