# Introduction (https://docs.hub.ki/custom-apps) A **custom app** is a single, self-contained HTML document that HUB.KI embeds in a sandboxed iframe. Your app talks to the platform through a small message bridge — enough to store data, know the current user, react to their theme and language, publish and receive events, run AI agents, and share content. Apps are ordinary web pages. Bring any framework, or none. ## How it works The platform serves your artifact into a sandboxed iframe and exchanges [JSON-RPC 2.0](https://www.jsonrpc.org/specification) messages with it over `postMessage`. The [`@hub-ki/app-sdk`](https://docs.hub.ki/custom-apps/quick-start) package wraps that exchange in plain `async` functions. Through the bridge your app can: * read and write its **own private workspace** (per user) * keep app state in a **key/value store** (per user, per version) * read the signed-in **user's identity** and the shared **workspace documents** (with permission) * get an **access token** to call HUB.KI APIs from its own backend (with permission) * **publish and receive events** in real time, and **run AI agents** (with permission) * show **toasts**, set the tab **title**, and open a **share dialog** See the [SDK reference](https://docs.hub.ki/custom-apps/sdk) for the full client API. Everything a custom app can do is gated by the [scopes](https://docs.hub.ki/custom-apps/manifest#scopes) it declares, so the platform — and the user's organization — stays in control. ## Anatomy An app is a folder with two required files: ``` my-app/ ├── manifest.json # metadata, scopes, versioning └── app.html # the single-file artifact ``` Optionally add an icon image (`icon.svg`, `icon.png`, …) referenced from the manifest. ## Constraints * **Single file.** The iframe loads exactly one HTML document of at most 10 MB. Inline all CSS and JavaScript — external URLs resolved relative to the artifact will not load. * **Sandboxed.** The app runs with `allow-scripts allow-forms` and no same-origin access, so cookies and `localStorage` are unavailable. Persist state through the app's [key/value store](https://docs.hub.ki/custom-apps/sdk#store) (the `localStorage` replacement) or the [workspace](https://docs.hub.ki/custom-apps/sdk#workspace). * **Locked-down content policy.** See below — it decides what may be inlined. * **SDK major version 1.** Declare `sdkVersion` in the manifest; the host currently supports major version `1`. ### Content Security Policy The platform serves every app with this policy: ``` default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; worker-src blob:; connect-src 'none'; base-uri 'none'; form-action 'none'; sandbox allow-scripts allow-forms ``` What that means in practice: | You want to… | Works? | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Inline ` ``` `src/main.ts` — connect, follow the theme, load and save the note: ```ts import { connect } from "@hub-ki/app-sdk"; // Outside HUB.KI nothing answers the handshake, so `vite dev` uses the SDK's in-memory host. const hub = await connect({ mock: import.meta.env.DEV ? { scopes: ["store:own"], hostContext: { theme: "light" } } : undefined, }); // Apply the host theme, and keep it in sync. document.documentElement.dataset.theme = hub.context.theme; hub.onContextChange((context) => { document.documentElement.dataset.theme = context.theme; }); // Load the saved note, and persist it on demand. const note = document.querySelector("#note")!; const save = document.querySelector("#save")!; note.value = (await hub.store.get("note")) ?? ""; save.addEventListener("click", () => { void hub.store.set("note", note.value); }); ``` `vite.config.ts` — inline all JavaScript and CSS into one HTML file: ```ts import { defineConfig } from "vite"; import { viteSingleFile } from "vite-plugin-singlefile"; export default defineConfig({ plugins: [viteSingleFile()] }); ``` Prefer no dependencies? The SDK only wraps `postMessage` — you can implement the protocol directly. See [The bridge](https://docs.hub.ki/custom-apps/bridge). ## 3. Run it locally ```sh npm run dev ``` Outside the platform there is no host to answer the bridge, and `connect()` would time out. The `mock` option above swaps in an in-memory host while developing, so the store, the theme and every other call answer locally. It is only enabled in dev mode; the production build talks to the real host. See [Developing without HUB.KI](https://docs.hub.ki/custom-apps/sdk#developing-without-hubki) for everything the mock can simulate. ## 4. Build and package ```sh npm run build ``` The build writes a single file, `dist/index.html`. An app is a folder holding that artifact next to a `manifest.json`: ```sh mkdir -p publish/notes cp dist/index.html publish/notes/app.html ``` `publish/notes/manifest.json` — the `store:own` scope lets the app use its private key/value store: ```json { "appId": "com.example.notes", "slug": "notes", "name": { "en": "Notes" }, "version": "1.0.0", "sdkVersion": "1.0.0", "artifactPath": "app.html", "icon": "sticky-note", "scopes": ["store:own"] } ``` `artifactPath` must name the file you copied (`app.html` here — or keep `index.html` and point the manifest at it). `sdkVersion` is the bridge contract you target, not your app's version; any `1.x.y` runs on the current host. See the [Manifest reference](https://docs.hub.ki/custom-apps/manifest) for every field and the validation rules. ## 5. Publish it Upload the folder to a source the platform reads, then have an administrator sync it and make it available — see [Publishing](https://docs.hub.ki/custom-apps/publishing) for the upload and [Administration](https://docs.hub.ki/custom-apps/administration) for the admin side. A freshly synced app is listed in the Marketplace as **Not available** until an organization admin chooses who may use it. --- # Build with an AI agent (https://docs.hub.ki/custom-apps/agent-skill) If you work with a coding agent such as [Claude Code](https://claude.com/claude-code), you can give it a **skill** for HUB.KI custom apps. A skill is a short instruction file the agent loads when a task matches it. This one tells the agent how to approach a custom app and where to look things up — it contains no API details of its own. The agent reads the pages of this documentation live, so it follows the current SDK, manifest rules and sandbox policy rather than whatever it remembers. ## Install The skill is a single file: [`docs.hub.ki/skills/hub-custom-apps/SKILL.md`](https://docs.hub.ki/skills/hub-custom-apps/SKILL.md). For one project, from the project root: ```sh mkdir -p .claude/skills/hub-custom-apps curl -fsSL https://docs.hub.ki/skills/hub-custom-apps/SKILL.md \ -o .claude/skills/hub-custom-apps/SKILL.md ``` For every project on your machine, install it into `~/.claude/skills/` instead: ```sh mkdir -p ~/.claude/skills/hub-custom-apps curl -fsSL https://docs.hub.ki/skills/hub-custom-apps/SKILL.md \ -o ~/.claude/skills/hub-custom-apps/SKILL.md ``` Commit the project copy if your team should share it. Other agents that support the Agent Skills format load the same file from their own skills folder. Because the skill only points at the documentation, it rarely changes. Run the same command again to update it. ## Use it Describe the app you want; the agent picks the skill up on its own. For example: > Build a HUB.KI custom app that keeps a per-user reading list: title, link, and a > done checkbox. It should follow the host theme and run locally while I develop. > Here is my custom app. Check the manifest against the validation rules and tell > me why the sync skips it. > I need this app to call our own API at `https://api.example.com`. What do I have > to change, and what does our admin have to approve? The agent will: 1. read [`llms.txt`](https://docs.hub.ki/llms.txt) and the pages it needs, 2. ask you for anything only you can decide — above all your **`appId` namespace** and **where data should live**, since the [key/value store](https://docs.hub.ki/custom-apps/sdk#store) starts empty on every new app version, 3. scaffold, develop against the SDK's [mock host](https://docs.hub.ki/custom-apps/sdk#developing-without-hubki), build a single HTML file, and check the manifest against every [validation rule](https://docs.hub.ki/custom-apps/manifest#validation), 4. tell you exactly what to [upload](https://docs.hub.ki/custom-apps/publishing) and what to ask your [administrator](https://docs.hub.ki/custom-apps/administration) for. ## What to expect * **It needs a GitHub token to install the SDK**, exactly as in the [Quick start](https://docs.hub.ki/custom-apps/quick-start). Export `GITHUB_TOKEN` in the shell the agent uses; the skill tells it never to write the token to a file or print it. * **It does not publish.** Uploading to a source is yours to do, and registering, syncing and making the app available are an administrator's. * **It cannot run the production build inside HUB.KI.** The mock host covers development. The agent will say what it could not test and list what to check after the first sync. ## Without skills Any assistant can use the same material. Point it at [`llms.txt`](https://docs.hub.ki/llms.txt), an index of every page as Markdown, or paste [`llms-full.txt`](https://docs.hub.ki/llms-full.txt), the whole documentation in one file. **Copy Markdown** at the top of each page gives you a single page. --- # 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 | 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 ``. | 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. --- # Calling HUB.KI from your backend (https://docs.hub.ki/custom-apps/backend) Some apps need a server of their own — to run privileged logic, hold secrets, or integrate a third‑party service. With the `api:access` scope, your app can hand its backend a short‑lived token that proves **who the signed‑in user is**, which organization they're in, and **which scopes they granted your app**. > This token is **scoped to your app** — it is *not* the user's HUB.KI session > token and cannot act as the user across HUB.KI. It is an identity + scope > assertion for **your** backend to verify. See [What the token can do](#what-the-token-can-do). ## What you get ```ts const { coreUrl, iamUrl, organizationId, token } = await hub.getConnection(); ``` | Field | Use | | ---------------- | ------------------------------------------------------------------- | | `iamUrl` | Base URL of the **identity** service — its JWKS verifies the token. | | `organizationId` | The active organization. | | `token` | A short‑lived, app‑scoped bearer token (see below). | | `coreUrl` | Base URL of the HUB.KI **core** API. | ## Reaching your backend The sandbox blocks all network requests by default. Before your app can `fetch` its backend, list that backend's origin in the manifest's [`connectOrigins`](https://docs.hub.ki/custom-apps/manifest#network-access) and have an organization admin approve it — otherwise the request is blocked by the app's Content-Security-Policy. Two more things to know about requests from the sandbox: * **The request's `Origin` is `null`.** The sandbox has no stable origin, so your backend must CORS-allow `Origin: null` (e.g. reply `Access-Control-Allow-Origin: null`). Use bearer tokens, not cookies — credentialed requests can't use a `null` allow-origin. * **Approved origins are for `fetch`, not navigation.** Only declared, approved origins are reachable; treat approval as the trust boundary for where the app may send data. ## The flow The app sends the token to **your** backend; your backend verifies it against HUB.KI's identity service and now trusts the user's identity and granted scopes. ```mermaid sequenceDiagram participant App as App (iframe) participant Host as HUB.KI host participant Backend as Your backend participant Iam as HUB.KI identity App->>Host: hub.getConnection() Host-->>App: iamUrl, organizationId, token App->>Backend: your request + token + organizationId Backend->>Iam: fetch JWKS (cached) Backend->>Backend: verify signature, aud, token_use, exp Backend-->>App: result (now trusts user + scopes) ``` Typically you validate the token once to **establish your own session** (a cookie or your own JWT), then serve subsequent requests from that — the HUB.KI token is a bootstrap credential, not your per‑request session. ## Validating the token Verify the JWT against the identity service's JWKS at `${iamUrl}/api/auth/jwks`, then check its claims: | Claim | Expected value | | --------------- | ---------------------------------------------------- | | `token_use` | `custom_app` | | `aud` | `urn:hub:custom-app::` | | `app_id` | your app's `appId` | | `sub` | the user's stable id | | `tenant_id` | the organization id | | `scope` | space‑delimited scopes the org approved for your app | | `email`, `name` | present only if `user:read` was granted | ```ts import { createRemoteJWKSet, jwtVerify } from "jose"; const jwks = createRemoteJWKSet(new URL(`${iamUrl}/api/auth/jwks`)); const { payload } = await jwtVerify(token, jwks, { audience: `urn:hub:custom-app:${organizationId}:${appId}`, }); if (payload.token_use !== "custom_app") throw new Error("wrong token type"); // payload.sub, payload.scope, payload.email are now trusted. ``` ## What the token can do * **It authenticates the user to *your* services** and tells you which scopes the org approved. That's its job. * **It cannot act as the user on HUB.KI's own APIs.** Its `token_use`/`aud` are app‑scoped, so HUB.KI's user‑facing endpoints reject it. This is deliberate: a leaked app token can't be replayed against the user's account — it only carries the identity + scopes you already see. ## Keeping the token fresh Tokens are short‑lived. The host mints a fresh one before expiry and **pushes** each new value — subscribe once and always hold a current token instead of polling. ```mermaid sequenceDiagram participant Host as HUB.KI host participant App as App (iframe) App->>Host: hub.onToken(handler) Host-->>App: current token (immediately) Note over Host: ~60s before expiry Host->>Host: mint a fresh app token Host-->>App: hub/token/changed (new token) ``` ```ts let token = await hub.getToken(); hub.onToken((next) => { token = next; // send the fresh token to your backend before it expires }); ``` ## Security * The token is **app‑scoped and short‑lived** — treat it as a credential; never log it or expose it to third parties. * Don't persist it. Re‑read it from `onToken` / `getToken` when you need one. * `api:access` is opt‑in: an organization admin must approve it before `getConnection` returns a token. * If your backend needs to call HUB.KI **while the user is offline**, this browser‑delivered token cannot help — that requires a separate server‑to‑server credential, which isn't part of this flow. --- # Manifest (https://docs.hub.ki/custom-apps/manifest) Every app folder contains a `manifest.json`. The platform validates it when it syncs a source; a manifest that fails validation is skipped with an error. ```json { "appId": "ai.justadd.stickyboard", "slug": "stickyboard", "name": { "en": "Stickyboard", "de": "Stickyboard" }, "version": "2.3.0", "sdkVersion": "1.0.0", "artifactPath": "app.html", "iconPath": "icon.svg", "events": ["board-updated"], "scopes": ["workspace:own"], "connectOrigins": ["https://api.example.com"] } ``` ## Fields | Field | Type | Required | Description | | ---------------- | ------------------------ | -------- | ------------------------------------------------------------------------------------------------ | | `appId` | `string` | yes | Globally unique identifier. Reverse-DNS is recommended (`com.example.app`). | | `slug` | `string` | yes | Short, URL-safe name used in the app's route. | | `name` | `Record` | yes | Localized display names. Include at least `en`; it is the fallback. | | `version` | `string` | yes | App version, numeric and dotted (`1.4.0`, optionally `1.4.0-beta.1`). Bump it to ship an update. | | `sdkVersion` | `string` | yes | Bridge contract you target, as exact `major.minor.patch`. The host supports major `1`. | | `artifactPath` | `string` | yes | Path to the single HTML file, relative to the folder. | | `icon` | `string` | \* | A [Lucide](https://lucide.dev/icons) icon name, e.g. `sticky-note`. | | `iconPath` | `string` | \* | Path to an icon image instead (`svg`, `png`, `jpg`, `webp`, `gif`; ≤ 256 KB). | | `events` | `string[]` | no | Event names the app may [publish or receive](https://docs.hub.ki/custom-apps/bridge#events). | | `description` | `{ [locale]: string }` | no | Short description shown on the marketplace card, keyed by locale like `name`. | | `scopes` | `string[]` | no | Capabilities the app requests (see below). | | `connectOrigins` | `string[]` | no | Backend origins the app may call from the browser (see [Network access](#network-access)). | \* Provide **either** `icon` or `iconPath`. ## Scopes Scopes are the only capabilities an app has beyond rendering itself. Request the minimum you need. | Scope | Grants | Approval | | ----------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------- | | `workspace:own` | The app's own private, per-user workspace (documents and files). | None — always available. | | `store:own` | A per-user, per-version key/value store for the app. | None — always available. | | `documents:read` | Read the user's shared organization workspace documents. | Org admin must approve the scope. | | `user:read` | Read the signed-in user's name and email. | Org admin must approve the scope. | | `api:access` | Get an access token to call HUB.KI APIs from the app's own backend, as the user. | Org admin must approve the scope. | | `responses:run` | Run the HUB.KI AI (Responses API) at the organization's expense via an automatically issued API key. | Org admin must approve the scope. | | `capability:wasm` | Run WebAssembly in the app sandbox (`wasm-unsafe-eval`). | Org admin must approve the scope. | `workspace:own` and `store:own` are granted automatically. Any other scope must be approved by an organization admin from **Manage availability** before the app can use it — an un-approved call is rejected. Web Workers are available to every app (`worker-src blob:`), so heavy work can leave the main thread without asking for anything. WebAssembly is different: it widens what may execute in the sandbox, so it is opt-in through `capability:wasm` and, like any other approval, the org admin sees it before turning it on. Libraries such as pdf.js that can run either way will fall back to their non-WASM path when it is not granted. ## Validation A sync validates every manifest and its files. An app that fails any rule is skipped as a whole — and if it was published before, it goes offline (see [Syncing](https://docs.hub.ki/custom-apps/publishing#syncing)). The admin interface only reports the number of skipped apps, so check these before uploading: | Rule | Typical mistake | | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | | The file is valid JSON and every required field is present. | Trailing comma; missing `name` or `artifactPath`. | | `appId`: letters, digits, `.`, `-`, `_`; segments never empty; at most 255 characters. | Spaces, a leading or trailing dot. | | `slug`: lowercase letters and digits separated by single hyphens; at most 128 characters. | Uppercase letters, underscores, spaces. | | `sdkVersion`: exactly `major.minor.patch` with a supported major (`1`). | `"1"`, `"1.0"`, `"^1.0.0"`, `"2.0.0"`. | | `icon` **or** `iconPath` is set. An icon file is `svg`, `png`, `jpg`, `webp` or `gif`, ≤ 256 KB. | Neither given; icon too large. | | Every entry in `scopes` is a known scope (table above); at most 20. | A typo such as `storage:own`. | | Every entry in `connectOrigins` is an exact `https://` origin (see below); at most 8 are used. | A path, a wildcard, plain `http://`. | | `artifactPath` points to an existing file of at most 10 MB. | Manifest says `app.html`, folder holds `index.html`. | | The artifact starts like an HTML document (` { const [port] = event.ports; if (port) { hostPort = port; hostPort.addEventListener("message", ({ data }) => receive(data)); hostPort.start(); } receive(event.data); }); const { hostContext } = await request("ui/initialize", { sdkVersion: "1.0.0" }); hostPort.postMessage({ jsonrpc: "2.0", method: "ui/notifications/initialized", }); ``` Send the initial `ui/initialize` with `parent.postMessage(message, "*")`, and everything after it with `hostPort.postMessage(message)` — no target origin, and no other frame can read or forge it. If the host does not support your `sdkVersion`, `ui/initialize` fails and the app is replaced with an upgrade notice. The result also carries `scopes` — the permissions your organization actually approved. Read them to adapt the UI rather than calling a method and catching the failure: ```js if (hub.hasScope("documents:read")) { showDocumentPicker(); } ``` ## Host context `hostContext` carries the current environment. Apply it on load and whenever it changes. | Field | Type | Description | | ------------- | ------------------- | ------------------------------- | | `theme` | `"light" \| "dark"` | The user's active theme. | | `locale` | `string` | BCP-47 language tag, e.g. `en`. | | `displayMode` | `"fullscreen"` | How the app is presented. | The host pushes a notification when the user switches theme or language. It arrives on the port from the handshake: ```js hostPort.addEventListener("message", ({ data }) => { if (data?.method === "ui/notifications/host-context-changed") { applyContext(data.params.hostContext); } }); ``` ## Calling the platform Send `hub/api/call` with a `method` and its `params`. Each method requires a scope; calling one without the scope throws. ```js const api = (method, params) => request("hub/api/call", { method, params }); await api("workspace.createDocument", { title: "Ideas", markdown: "# Ideas" }); ``` ### Storing data The `workspace:own` scope gives your app a **private workspace, one per user**. It holds two kinds of entries: * **Documents** — Markdown, editable and shareable like any workspace document. * **Files** — opaque string blobs (up to 1 MB), ideal for JSON app state. The host validates every call; the size limits are listed under [Limits](https://docs.hub.ki/custom-apps/sdk#limits) in the SDK reference. | Method | Params | Returns | | -------------------------- | ---------------------------------- | ------------------------------------ | | `workspace.ensure` | – | `{ id, title }` | | `workspace.listDocuments` | – | `{ workspace, documents }` | | `workspace.createDocument` | `{ title, markdown }` | `{ id, title }` | | `workspace.getDocument` | `{ documentId }` | `{ id, title, markdown }` | | `workspace.updateDocument` | `{ documentId, markdown, title? }` | `{ id, title, markdown }` | | `workspace.createFile` | `{ name, content }` | `{ id, title }` | | `workspace.getFile` | `{ fileId }` | `{ id, title, content }` | | `workspace.updateFile` | `{ fileId, content }` | `{ id, title }` | | `workspace.renameFile` | `{ fileId, name }` | `{ id, title }` | | `workspace.deleteFile` | `{ fileId }` | `{ id }` | | `workspace.upload` | `{ accept? }` | `{ cancelled, documentId?, title? }` | `workspace.listDocuments` returns every entry in the workspace; each has an `id` and a `title` (its name). A workspace is private by default — the user shares individual entries themselves (see [Sharing](#sharing)). ### Uploading a file `workspace.upload` asks the **host** to open a file picker. The file never crosses the sandbox boundary: the host reads it, uploads it as the signed-in user, and puts it in your app's workspace. You get back a document id, or `{ cancelled: true }` if the user dismissed the picker. Uploads run through the platform's normal ingest, so text extraction — including OCR for scanned PDFs — happens automatically. Poll the document until extraction finishes: ```js const upload = await hub.workspace.upload({ accept: "application/pdf" }); if (!upload.cancelled) { let document = await hub.workspace.getDocument(upload.documentId); while (document.extraction === "pending") { await new Promise((resolve) => setTimeout(resolve, 1000)); document = await hub.workspace.getDocument(upload.documentId); } // document.extraction is "ready" (markdown populated) or "failed" } ``` Because the host owns the picker, an app cannot upload bytes it generated itself — only files the user chose. If you need to persist generated output, write it as a workspace file (text) for now. `extraction` is `"pending"` until the extractor writes back, `"ready"` when `markdown` reflects the current file, and `"failed"` if extraction gave up. Guard the polling loop with a timeout — a large scan can take a while. ### Reading shared documents With the `documents:read` scope, list the documents from the user's shared organization workspace and read one by id. `list` returns metadata only — content never rides along with a listing, so fetch it deliberately with `get`. | Method | Scope | Params | Returns | | ---------------- | ---------------- | ---------------- | ---------------------------------- | | `documents.list` | `documents:read` | `{ limit? }` | `{ id, kind, title }` per document | | `documents.get` | `documents:read` | `{ documentId }` | The above plus `markdown` | `get` resolves against what the *user* may see, not what the app knows about: an id the user cannot access returns a not-found error. ### Key/value store The `store:own` scope gives your app a `localStorage`-style store, private to each user and scoped to the app **and its version**. | Method | Params | Returns | | -------------- | ---------------- | ---------------- | | `store.get` | `{ key }` | `string \| null` | | `store.set` | `{ key, value }` | – | | `store.remove` | `{ key }` | – | | `store.keys` | – | `string[]` | ### Identity and backend access | Method | Scope | Returns | | ---------------- | ------------ | -------------------------------------------- | | `user.current` | `user:read` | `{ id, name, email }` | | `connection.get` | `api:access` | `{ coreUrl, iamUrl, organizationId, token }` | | `token.get` | `api:access` | The current access token | Access tokens are short-lived. For `api:access` apps the host also pushes a `hub/token/changed` notification (params `{ token }`) whenever it refreshes, so the app can hold a current token without polling. ### Host UI These take effect on the host immediately and need no scope: | Method | Params | Effect | | ------------- | ------------------------------------- | -------------------------- | | `ui.toast` | `{ message, variant?, description? }` | Shows a host toast | | `ui.setTitle` | `{ title }` | Sets the browser tab title | ## Events Declare event names in your [manifest](https://docs.hub.ki/custom-apps/manifest), then publish them. The host delivers each event to other open sessions of the app in real time, so separate tabs stay in sync. Publishing an undeclared event fails. ```js // Publish await request("hub/events/publish", { event: "board-updated", payload: { boardId }, }); // Receive hostPort.addEventListener("message", ({ data }) => { if (data?.method === "hub/events/message") { const { event, payload } = data.params; } }); ``` ## Running AI responses With the `responses:run` scope, run the platform's AI through the Responses API. Send `hub/responses/create` with a client-generated `requestId`; streamed Responses API events arrive as `hub/responses/event` notifications carrying that `requestId`, and the request itself resolves with the final response object. The `requestId` must be unique among your in-flight runs: the host rejects a `create` whose `requestId` already belongs to a run it is still streaming, with the code `invalid_request`. Generate a fresh one per call with `crypto.randomUUID()` rather than reusing a fixed string, or two concurrent creates will fail. ```js const requestId = crypto.randomUUID(); hostPort.addEventListener("message", ({ data }) => { if ( data?.method === "hub/responses/event" && data.params.requestId === requestId && data.params.event.type === "response.output_text.delta" ) { appendText(data.params.event.delta); } }); const response = await request("hub/responses/create", { input: "Summarize this board", requestId, }); ``` A single run forwards at most 10 000 events. Once a stream goes past that cap, the host emits one synthetic `hub.response.events_truncated` event carrying `max_events` (the cap) and stops forwarding the deltas that follow. The `create` call still resolves with the complete final response object, so an app that sees this event should render the resolved result instead of the text it accumulated from deltas. `hub/responses/get` and `hub/responses/cancel` take a `responseId`; `hub/responses/abort` takes the `requestId` of an in-flight create and tears its stream down. A failed call rejects with a JSON-RPC error whose `error.data.code` is a stable, machine-readable code (`app_not_available`, `scope_not_granted`, `key_revoked`, `key_resolution_unavailable`, `rate_limited`, `rate_limiter_unavailable`, `concurrency_exceeded`, `over_budget`, `structured_output_failed`, `invalid_request`, `previous_response_not_found`, `previous_response_in_progress`, `response_not_found`, `cancelled`, `run_failed`). Two of these are worth retrying rather than surfacing as a permanent failure: `rate_limiter_unavailable` and `key_resolution_unavailable` both mean a platform dependency was briefly unreachable, not that the request was wrong. Unlike the bearer-token Responses API, this path has no `Idempotency-Key`: one app-bound key is shared by every user of the app, so a per-key replay cache would let two people void each other's guarantee. Retries are the app's own concern — pass a `previousResponseId` to continue a conversation, and treat each `create` as a new run. ## Sharing To let the user share one of your workspace entries, ask the host to open its share dialog with the entry's id: ```js await request("ui/share/request", { documentId: file.id }); ``` The host verifies the document belongs to your app's workspace and presents the standard share controls (specific people, or the whole organization). ## Errors Failed requests return a JSON-RPC error: | Code | Meaning | | -------- | ------------------------------------- | | `-32602` | Invalid params. | | `-32601` | Unknown method. | | `-32000` | The call failed (e.g. missing scope). | ## A reusable helper Drop this into your artifact and import the exported functions. ```js const pending = new Map(); let nextId = 1; let hostPort = null; const listeners = { context: new Set(), event: new Set() }; function post(message) { if (hostPort) hostPort.postMessage(message); else parent.postMessage(message, "*"); } function request(method, params) { return new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); post({ jsonrpc: "2.0", id, method, params }); }); } function receive(data) { if (data?.jsonrpc !== "2.0") return; if (data.id && pending.has(data.id)) { const { resolve, reject } = pending.get(data.id); pending.delete(data.id); data.error ? reject(new Error(data.error.message)) : resolve(data.result); } else if (data.method === "ui/notifications/host-context-changed") { for (const fn of listeners.context) fn(data.params.hostContext); } else if (data.method === "hub/events/message") { for (const fn of listeners.event) fn(data.params); } } addEventListener("message", (event) => { const [port] = event.ports; if (port) { hostPort = port; hostPort.addEventListener("message", ({ data }) => receive(data)); hostPort.start(); } receive(event.data); }); export async function initialize() { const { hostContext } = await request("ui/initialize", { sdkVersion: "1.0.0", }); post({ jsonrpc: "2.0", method: "ui/notifications/initialized" }); return hostContext; } export const callApi = (method, params) => request("hub/api/call", { method, params }); export const publishEvent = (event, payload) => request("hub/events/publish", { event, payload }); export const requestShare = (documentId) => request("ui/share/request", { documentId }); export const onHostContext = (fn) => listeners.context.add(fn); export const onEvent = (fn) => listeners.event.add(fn); ``` --- # Publishing (https://docs.hub.ki/custom-apps/publishing) The platform loads apps from a **source**: a location it reads app folders from. You publish by placing your app folder in a source. A global admin registers the source once and syncs it; an organization admin then decides who can use each app. The admin steps are described in [Administration](https://docs.hub.ki/custom-apps/administration). ## Package the folder Each app is one folder containing its manifest, artifact, and optional icon: ``` apps/ └── notes/ ├── manifest.json ├── app.html └── icon.svg ``` The folder name is arbitrary — the platform identifies an app by the `appId` in its `manifest.json`. Remember the artifact must be a [single file](https://docs.hub.ki/custom-apps#constraints) of at most 10 MB. An app version is the pair `appId` + `version`. To keep several versions available at the same time (an organization can be [pinned to one](https://docs.hub.ki/custom-apps/administration#grant-it-to-organizations)), keep one folder per version: ``` apps/ ├── notes-1.0.0/ └── notes-1.1.0/ ``` The same `appId` + `version` must not appear in two folders; both are skipped. An `appId` belongs to the first source that published it — another source declaring the same `appId` is ignored. ## S3 sources An S3 source is a bucket plus a **prefix**. Every folder below the prefix that contains a `manifest.json` becomes an app, at any depth: ``` s3://my-bucket/apps/notes/manifest.json → prefix "apps" s3://my-bucket/apps/notes/app.html ``` Upload with any S3 client, for example the AWS CLI: ```sh aws s3 sync publish/ s3://my-bucket/apps/ --delete # S3-compatible storage: add --endpoint-url https://s3.example.com ``` Requirements: * A **prefix is mandatory**; the bucket root cannot be a source. * For an external bucket the admin enters the endpoint, region, bucket and an access key. The key needs to **list** the bucket and **read** objects under the prefix (`s3:ListBucket`, `s3:GetObject`); it never writes. * The endpoint must be an **`https://` URL on a publicly resolvable host**. Private addresses, `localhost` and plain `http://` are rejected. * At most **10,000 objects** may live under the prefix; beyond that the sync refuses to run rather than publish a partial catalog. Keep build leftovers out of the prefix. A source can also read from the platform's own storage (no endpoint or credentials), which is how first-party apps are shipped. ## GitHub sources Reading app folders from a GitHub repository, with a webhook that re-syncs on push, is implemented on the platform but **not yet selectable** in the admin interface ("Coming soon"). Use an S3 source until it is enabled. ## Syncing Nothing happens on upload. A source is read when a global admin chooses **Sync now**; S3 sources are **not** synced on a schedule. Each sync: 1. lists the folders and validates every `manifest.json` ([rules](https://docs.hub.ki/custom-apps/manifest#validation)), 2. copies the artifact and icon of each valid app into the platform, 3. publishes the valid apps, and **unpublishes apps and versions that are no longer found** — together with the scope approvals and version pins that organizations had set for a removed app. An app that fails validation is skipped. If it was published before, skipping it means it is no longer found, so **a broken manifest takes a live app offline** on the next sync. As a safeguard, a sync that finds no valid app at all, while the source currently publishes some, is refused entirely. The admin interface reports only how many apps were skipped, not why. Check a manifest against the [validation rules](https://docs.hub.ki/custom-apps/manifest#validation) before uploading; the most common causes are listed there. ## Updating an app Ship changes by bumping `version` in the manifest, replacing the artifact, and asking for a re-sync. Organizations that follow **Latest** move to the highest version immediately; organizations pinned to a version stay on it as long as that version's folder is still in the source. If a pinned version disappears from the source, the app stops being available to that organization until its pin is changed. Two things to know before you bump: * The [key/value store](https://docs.hub.ki/custom-apps/sdk#store) is scoped to the app **version**. Users start with an empty store on every new version — migrate anything that must survive into the [workspace](https://docs.hub.ki/custom-apps/sdk#workspace). * Re-syncing an unchanged `version` with a different artifact overwrites that version in place. That is convenient while testing, but give real releases a new `version` so pinned organizations get what they pinned. Users do not need to reinstall after an update unless the major `sdkVersion` changes. --- # Administration (https://docs.hub.ki/custom-apps/administration) Two roles are involved in getting an app to users: * A **global admin** manages sources for the whole platform: registers them, syncs them, and grants them to organizations. * An **organization admin** decides who in their organization can use each app, and approves the scopes and network origins it asks for. Global admin is a platform-level role, separate from organization roles. On a self-hosted installation the operator designates the first global admin when deploying the identity service (`BOOTSTRAP_ADMIN_EMAIL`). ## Register a source As a global admin, open **Settings → Global → App sources** and choose **Add source**. | Field | Meaning | | -------------------------- | ------------------------------------------------------------------------------ | | **Name** | Label shown in the source list. | | **Type** | **S3 bucket**. GitHub is listed as "Coming soon". | | **Bucket** | Bucket name. Leave empty to read from the platform's own storage. | | **S3 prefix** | Folder the app folders live under, e.g. `apps`. Required. | | **Endpoint URL / Region** | Only for an external bucket. The endpoint must be `https://` on a public host. | | **Access key ID / Secret** | Only for an external bucket. Needs list and read access; stored encrypted. | **Test connection** checks the settings without saving and reports how many manifests it sees under the prefix. **Create source** saves the source; it does not sync it. See [Publishing](https://docs.hub.ki/custom-apps/publishing#s3-sources) for the bucket layout and the limits that apply. ## Sync it A new source shows **Never synced**. Open the source's **⋮** menu and choose **Sync now**. The result is reported as a notification: * **Synced *n* app(s).** — everything under the prefix was valid. * **Synced *n* app(s); *m* skipped as invalid.** — some manifests failed [validation](https://docs.hub.ki/custom-apps/manifest#validation). The interface does not say which or why; ask the app's developer to check the manifest. Sync again whenever a developer has uploaded a change — S3 sources are never synced automatically. The same menu offers **Test connection** and **Delete**. Deleting a source removes all of its apps from the platform. ## Grant it to organizations A synced source is **hidden** from every organization. Choose **Manage access** on the source, then **Add** next to each organization that should receive its apps. For every app you can pin the version an organization runs: **Latest** follows each sync, a specific version stays fixed until you change it. Removing an organization revokes its access to all apps of the source. ## Availability and consent Once a source is granted, its apps appear for the organization's admins under **Marketplace → Apps**, marked **Not available**. The organization admin opens the app's **Manage availability** control to decide who can use it: * **Only chosen people** — hidden from the organization; granted to selected users. This is the default. * **Available to install** — members see the app and can install it themselves. * **Installed for everyone** — installed for the whole organization. * **Public** — usable by anyone, without installing. The same panel is where the admin **approves scopes** beyond `workspace:own` and `store:own` (for example `documents:read` or `responses:run`) and the [`connectOrigins`](https://docs.hub.ki/custom-apps/manifest#network-access) the app wants to reach. Until a requested scope or origin is approved, calls that need it are rejected. Installed apps open from **Marketplace → Apps → Open**, at `/apps/`.