# Oberik > Oberik lets a product give its own customers AI over their own data: retrieval with citations, tools, sandboxed compute and scheduling, isolated per customer and scoped by capability-bounded tokens. This file contains all documentation content in a single document following the llmstxt.org standard. ## Oberik Oberik is the AI layer you put **inside your product**. Your customers ask questions about *their own* data and get grounded answers with citations; the agent can also do the work — run code, transform files, hand results back. You call it from your backend and from your frontend. Oberik handles retrieval, tool calling, sandboxed compute, scheduling, isolation and cost attribution. ```ts import { createClient } from "@oberik/sdk"; // Your backend mints short-lived tokens; the SDK asks for a new one when the // current one expires, so expiry never surfaces at a call site. const ai = createClient({ getToken }); const res = await ai.chat.send({ message: "How did Q3 revenue trend, and why?", tags: ["finance"], }); res.content; // grounded answer res.citations; // document · page · quote ``` ## The model in one minute | Concept | What it is | |---|---| | **Project** | One isolated workspace. Its documents, embeddings, files, sandboxes, credentials and spend are separate from every other project's. Most integrations use one project per customer, or one per environment. | | **Token** | A short-lived JWT your backend mints for one end-user, carrying exactly the capabilities you grant. Your frontend never holds anything privileged. | | **Capability** | A permission (`chat`, `documents:read`, `computer`…). A token can only hold what the project allows, and the API refuses anything outside it — including a tool the model decides to call. | | **Scope** | A hierarchical path (`acme:finance:user_9f3c`) bounding *which data* a token can see, so one customer's users don't read each other's documents. | ## Where things live Two hosts, two credentials. Nothing else to configure. | | URL | Credential | What it's for | |---|---|---|---| | **Control plane** | `https://oberik.com` | `X-API-Key: pk_…` | administering a project: minting end-user tokens, setting the capability ceiling, curating the corpus. Server-side only. | | **Data plane** | `https://api.oberik.com` | `Authorization: Bearer ` | the AI itself: chat, documents, tools, sandboxes. This is what the SDK talks to. | | **Dashboard** | `https://oberik.com/app` | your login | the same control plane with a UI on it. | | **SSH** | `ssh ssh.oberik.com` | your account, or an SSH key | [the same control plane as text](./ssh.md) — for terminals and coding agents. | ```bash npm install @oberik/sdk ``` The SDK defaults to the hosted data plane, so `baseUrl` is only needed for a self-hosted deployment. Self-hosting? Every URL above becomes yours; nothing else in these docs changes. ## What you can turn on Each of these is a capability you grant a project and then, per end-user, a token — and can still decline on any single request. - **Conversation** — [chat with citations](./chat.md) over your customer's data, blocking or streaming, with [steering](./working-together.md#steering--a-message-into-a-running-turn) into a running turn. - **Knowledge** — [documents](./documents.md) you curate or your users upload, and [memory and a wiki](./scheduling.md#memory) the agent keeps between chats. - **Internet** — [web search, page reads, and a real browser](./tools.md#driving-a-browser) the agent can click and type in. - **Agentic work** — [plans it keeps](./working-together.md#todos--the-agents-plan), [subagents](./subagents.md) beside it, a [Linux sandbox](./sandbox.md) per conversation, [scheduled runs](./scheduling.md) and [webhook triggers](./scheduling.md#webhook-triggers). - **Human in the loop** — [questions it asks](./working-together.md#questions-the-agent-asks), [permission before irreversible actions](./working-together.md#asking-permission), [components it draws in your UI](./tools.md#ui-components--tools-that-draw), follow-ups and recaps. - **Extensions** — [your own tools](./tools.md#your-own-tools-client-side), [tools you host](./tools.md#tools-you-host-webhook-tools) that work when nobody is connected, [MCP servers](./tools.md#mcp-servers), and [skills](./skills.md) that teach it your procedures. - **In and out** — [images, audio, files](./multimodal.md) in; documents, media and [downloads](./files.md) back. - **Guardrails** — [injection defence, groundedness, moderation](./guardrails.md), optional PII handling. ## How do I… | | | |---|---| | get one cited answer | [Quickstart](./quickstart.md) | | do all of this from a terminal, or from a coding agent | [Oberik over SSH](./ssh.md) | | mint a token for a signed-in user | [Authentication](./authentication.md#minting-a-token) | | set up a project from a script instead of the dashboard | [Project API](./project-api.md) | | upload a corpus my users can only read | [Documents](./documents.md#a-corpus-you-curate-that-users-only-read) | | let the model call my application's functions | [Tools](./tools.md#your-own-tools-client-side) | | …including in a scheduled run, with no client attached | [Webhook tools](./tools.md#tools-you-host-webhook-tools) | | stream tokens into a UI | [Chat](./chat.md#streaming) | | call the API from a browser | [Browser origins](./browser-origins.md) | | let the agent run code | [Sandboxed compute](./sandbox.md) | | stop it before it does something irreversible | [Asking permission](./working-together.md#asking-permission) | | run something every Monday | [Scheduling](./scheduling.md) | | know what went wrong | [Limits & errors](./limits-and-errors.md) | | find the exact endpoint | [API reference](./api-reference.md) · [Recipes](./recipes.md) | ## Start here 1. **[Quickstart](./quickstart.md)** — a cited answer in about five minutes. 2. **[Authentication](./authentication.md)** — how to mint tokens, and why it belongs on your server. 3. **[Calling from the browser](./browser-origins.md)** — required before your frontend calls us directly. :::tip Working from a terminal, or building with an agent? Everything here is also a shell: `ssh ssh.oberik.com` signs up, creates projects, mints tokens and talks to the agent, with no browser and no client to install. A coding agent can sign in without ever handling your password, and read these docs in place with `docs`, `docs page ` and `docs search `. See [Oberik over SSH](./ssh.md). This site is also published as [`llms.txt`](pathname:///docs/llms.txt) (an index) and [`llms-full.txt`](pathname:///docs/llms-full.txt) (every page, one file), and the data plane serves its own OpenAPI schema at [`api.oberik.com/openapi.json`](https://api.oberik.com/openapi.json). ::: --- ## Quickstart You'll create a project, mint a token for one of your users, upload a document, and get a cited answer. Everything below is copy-pasteable; nothing is pseudocode. :::info Before anything works: register a model A new project has no models, so it cannot answer a question and cannot index a document. Add a provider key **once**, and name two kinds of model with it: - **a chat model** — otherwise every request fails with the provider's own error - **an embedding model** — otherwise every document upload fails ```bash curl -sX POST "https://oberik.com/api/projects/$OBERIK_PROJECT_ID/providers" \ -H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \ -d '{"provider":"openai", "models":["gpt-4o-mini","text-embedding-3-small"], "values":{"api_key":"sk-…"}}' # -> { …, "derived": { "defaultModel": "gpt-4o-mini", # "embeddingModel": "text-embedding-3-small", "embeddingDim": 1536 } } ``` `derived` is the rest of the setup done for you: the chat default, and the embedding model with its dimension **measured** rather than assumed. Nothing else to configure. At any point, ask what is left: ```bash curl -s "https://oberik.com/api/projects/$OBERIK_PROJECT_ID/readiness" -H "X-API-Key: $OBERIK_PROJECT_KEY" # -> { "canAnswer": true, "ready": true, "next": null, "steps": [ … ] } ``` Each unfinished step names what it blocks and the one call that fixes it. In the dashboard the same list is at the top of the project's overview. Two booleans, because there are two questions. **Gate a deploy on `canAnswer`** — it is true once the project can run a turn at all. `ready` is the full checklist and includes steps that only hold back one capability: turning on subagents without choosing subagent models makes `ready` false on a project that answers questions perfectly well. Each step carries `essential` if you want to make the distinction yourself. ::: ## 1. Create a project and a key In the dashboard, **New project** — a project is one isolated workspace, in most integrations one per customer. Then **Project → API keys → Create key**. :::tip No browser? Do the whole of this over SSH `ssh ssh.oberik.com` is the same control plane as text — sign up, create the project, create the key, mint tokens. A coding agent can sign in without ever being handed a password. See [Oberik over SSH](./ssh.md). ::: The key (`pk_…`) is shown once and is for *your server only*. Never ship it to a browser: it can mint tokens with any capability the project allows, and it can administer the project itself. ```bash export OBERIK_PROJECT_KEY="pk_…" # server-side only export OBERIK_PROJECT_ID="7f3c…" # a UUID, from the URL of the project page ``` :::note You can do this without a browser Everything after this — the capability ceiling, the corpus, skills, MCP servers, the system prompt — is driven over HTTP with that key ([Project API](./project-api.md)). And the two steps above are not browser-only either: `ssh ssh.oberik.com` will `signup`, `org new`, `project new` and `key new`, so a coding agent or a CI job can stand a project up end to end. See [Oberik over SSH](./ssh.md). ::: ## 2. Mint a token for one end-user Your backend decides who the user is and what they may do. Nothing downstream can widen it. ```bash npm install @oberik/sdk ``` ```ts title="server/oberik.ts" import { createProjectClient } from "@oberik/sdk"; // The server-side client. It holds the project key, so it is a different object from // the one your app uses — and it refuses to construct in a browser, because a project // key there can mint anything the project allows and delete the project outright. export const oberik = createProjectClient({ projectId: process.env.OBERIK_PROJECT_ID!, projectKey: process.env.OBERIK_PROJECT_KEY!, }); export async function mintToken(user: { id: string; orgId: string }) { const { access_token } = await oberik.tokens.mint({ subject: `${user.orgId}:${user.id}`, // who they are scope: `${user.orgId}:${user.id}`, // what they may see capabilities: ["chat", "documents:read", "documents:write"], expiresIn: 3600, }); return access_token; } ``` `mint` returns what was **actually** granted, which is worth checking the first time: ```ts const t = await oberik.tokens.mint({ subject: "acme:user_1", capabilities: ["chat", "computer"] }); t.capabilities; // ["chat"] — the project's ceiling trimmed `computer` ``` A capability you asked for and did not get is a toggle in the dashboard (Project → Capabilities), not a bug in your code. ### Without the SDK It is one HTTP call, so you can check your key before writing any code: ```bash curl -sX POST "https://oberik.com/api/projects/$OBERIK_PROJECT_ID/token" \ -H "X-API-Key: $OBERIK_PROJECT_KEY" \ -H "content-type: application/json" \ -d '{"subject":"acme:user_1","scope":"acme:user_1","capabilities":["chat","documents:read"],"expiresIn":3600}' ``` ```json { "access_token": "eyJ…", "token_type": "bearer", "expires_in": 3600, "tenant_id": "…", "capabilities": ["chat", "documents:read"], "scope": "acme:user_1" } ``` Note the host: minting is a **control-plane** call (`oberik.com`), while everything your app does afterwards goes to the **data plane** (`api.oberik.com`). ## 3. Upload a document, ask a question ```ts title="ask.ts — a complete script" import { readFile } from "node:fs/promises"; import { createClient } from "@oberik/sdk"; import { oberik } from "./server/oberik.js"; // `getToken` is the recommended way in: the SDK calls it back when a token expires and // replays the request, so expiry never reaches your call sites. `forUser` is that // callback, so the two halves fit together without glue. const ai = createClient({ getToken: oberik.tokens.forUser({ subject: "acme:user_1", capabilities: ["chat", "documents:read", "documents:write"], }), }); const doc = await ai.documents.uploadAndWait(await readFile("q3-report.pdf"), { filename: "q3-report.pdf", tags: ["finance"], }); console.log(doc.status); // "ready" const res = await ai.chat.send({ message: "How did Q3 revenue trend, and why?", tags: ["finance"], }); console.log(res.content); for (const c of res.citations) { // `page` is null for anything without pages — Markdown, CSV, a web page — so guard // it rather than rendering "p.null" for every non-paginated source. const where = c.page != null ? ` p.${c.page}` : ""; console.log(`${c.filename}${where} — "${c.quote}"`); } ``` Run it with whatever you already use for TypeScript — `tsx ask.ts`, or Node 22.6+ with `node --experimental-strip-types ask.ts`. The SDK ships ESM and CJS builds and needs Node 18 or newer. Ingestion is asynchronous. `uploadAndWait` returns once the document is parsed, chunked and searchable; use `ai.documents.upload(...)` for large files, or if you'd rather poll `ai.documents.get(id)` yourself. :::tip Try it before you write any code Every project has a **Try agent** button. It opens a chat client that talks to your real project through this same API, on a disposable identity erased when you close it — so you can check your corpus, capabilities and models answer the way you expect before wiring anything up. To see what a *particular* user sees, mint a token for them under **Connect → Mint a test token** and press **Try agent with this token**. The same chat opens as that subject and scope: their documents, their memories, their scope boundary. Nothing is erased when you close it, because none of it is yours. ::: ## 4. Stream it into a UI ```ts const handle = ai.chat.stream( { message: input, tags: ["finance"] }, { onToken: (_delta, full) => render(full), onCitations: (cs) => showSources(cs), }, ); const done = await handle.done; ``` `handle` is awaitable and also gives you `cancel()`, `disconnect()` and `runId()`. A dropped connection re-attaches to the same run and replays what you missed — [details](./chat.md#dropped-connections-resume). ## 5. Keep the conversation Pass back the `session_id` and Oberik keeps the history — including images from earlier turns. ```ts const follow = await ai.chat.send({ session_id: res.session_id, message: "Which region drove it?", }); ``` ## Without the SDK Every SDK call is one HTTP request. The same turn, over curl: ```bash curl -sX POST https://api.oberik.com/chat \ -H "Authorization: Bearer $TOKEN" \ -H "content-type: application/json" \ -d '{"message":"How did Q3 revenue trend?","tags":["finance"]}' ``` Streaming is Server-Sent Events on `POST /chat/stream` — no WebSocket, no proxy. The full surface is in the [API reference](./api-reference.md), and the data plane serves its own OpenAPI schema at `https://api.oberik.com/openapi.json` if you'd rather generate a client. ## Next - Calling from a browser? Declare your **[origins](./browser-origins.md)** first, or the request is refused. - Want the agent to *do* things, not just answer? **[Tools](./tools.md)** and **[sandboxed compute](./sandbox.md)**. - Setting up projects from code? **[Project API](./project-api.md)**. - Going live? **[Limits & errors](./limits-and-errors.md)**. --- ## Recipes One working call per task, and a link to the page that explains why it looks like that. Everything assumes a client: ```ts import { createClient } from "@oberik/sdk"; const ai = createClient({ getToken }); // see Authentication ``` ## Answer a question over the customer's own documents ```ts const res = await ai.chat.send({ message: "What's our refund window?", tags: ["policies"] }); res.content; res.citations; // document · page · quote — the passages retrieved res.claims; // which sentence came from which passage ``` Naming `tags` (or `document_ids`) also switches web search off for that turn, so the answer cannot quietly come from the internet. → [Chat](./chat.md#grounding) ## Stream the answer into a UI ```ts const h = ai.chat.stream({ message, session_id }, { onToken: (_d, full) => setText(full), onCitations: setSources, onTodos: setPlan, }); const done = await h.done; ``` `h.cancel()` stops generation (and the spend); `h.disconnect()` just stops listening. → [Chat](./chat.md#streaming) ## Let the model call your own functions ```ts const ai = createClient({ getToken, tools: [{ name: "cancel_order", description: "Cancel an order belonging to the signed-in customer.", parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] }, handler: async ({ order_id }) => { await assertOwnedBySignedInUser(order_id as string); // arguments are model-generated return await orders.cancel(order_id as string); }, }], }); await ai.chat.run({ message: "Cancel my most recent order" }); ``` `run` (and `stream`) dispatch the call, submit the result and continue until the agent is done. → [Tools](./tools.md#your-own-tools-client-side) ## Curate a corpus your users can only read ```bash # Once, from your server — the project owns these, not any one user. curl -sX POST "https://oberik.com/api/projects/$ID/documents" \ -H "X-API-Key: $KEY" -F file=@handbook.pdf -F tags=policies curl -sX PATCH "https://oberik.com/api/projects/$ID" \ -H "X-API-Key: $KEY" -H "content-type: application/json" \ -d '{"capabilities":{"documentsMode":"read"}}' ``` With `documentsMode: "read"`, no token minted for this project can ever carry `documents:write`. → [Project API](./project-api.md) · [Documents](./documents.md#a-corpus-you-curate-that-users-only-read) ## Keep one customer's users out of each other's data ```ts // Two users of the same customer, in one project. mint({ subject: "acme:fin:ana", scope: "acme:fin:ana" }); // only Ana's own mint({ subject: "acme:fin:lead", scope: "acme:fin" }); // the whole finance team ``` A token sees its own subtree and nothing above it. Use a project per customer for hard isolation, scopes for the hierarchy inside one. → [Scopes](./authentication.md#scopes-isolating-users-inside-one-project) ## Let the agent run code ```ts await ai.chat.send({ message: "Convert these CSVs to one parquet file and send it back" }); // -> res.attachments: [{ id, kind, url, name, mime_type, size }] ``` The sandbox is provisioned only if the agent actually uses it, and it's bound to the chat session, so the next turn is in the same workspace. → [Sandboxed compute](./sandbox.md) ## Stop it before something irreversible ```ts await ai.chat.stream({ message: "email the Q3 numbers to finance" }, { onApproval: async (req) => showDialog(req), // true | false | { approved, note } }); ``` Without `onApproval` the turn returns with `approvals` set and you resume it with `approval_decisions` — nothing is approved by default, and tool auto-dispatch never sees these. → [Asking permission](./working-together.md#asking-permission) ## Run something on a schedule ```ts await ai.tasks.create({ name: "Monday digest", kind: "recurring", cron: "0 8 * * 1", timezone: "Europe/Istanbul", action: { type: "agent", prompt: "Summarize last week's support tickets.", callback_url: "https://app.acme.com/hooks/oberik", // or poll ai.tasks.get(id) }, }); ``` Scheduled runs never bind `ask_user` or `approvals`: nobody is watching, so a turn that paused would wait forever. → [Scheduling](./scheduling.md) ## Start a turn when something happens in another system ```bash curl -sX POST https://api.oberik.com/triggers \ -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \ -d '{"prompt":"Triage this ticket: {{ body.subject }}","signed":true}' # -> { "url": "https://api.oberik.com/triggers/", "secret": "…" } ``` `prompt` is a template filled in from the event, so the sending system needs to know nothing about talking to an agent. The URL **is** the credential and the run happens as whoever created the trigger, so treat it like a password; `signed: true` additionally requires a matching `X-Signature`. Firing answers `{"accepted": true}` immediately rather than waiting for the turn — a webhook sender retries a slow response, and that would run the agent twice on one event. → [Webhook triggers](./scheduling.md#webhook-triggers) ## Let the agent do something in a scheduled run A client-side tool needs a client. A scheduled run has none — so publish the tool as a URL and Oberik calls it server-side: ```bash curl -sX POST "https://oberik.com/api/projects/$ID/webhook-tools" \ -H "X-API-Key: $KEY" -H "content-type: application/json" -d '{ "name":"open_ticket","description":"Open a support ticket for the signed-in customer.", "url":"https://acme.com/agent/tickets", "parameters":{"type":"object","properties":{"subject":{"type":"string"}},"required":["subject"]}}' ``` Your endpoint gets the arguments **and** the end-user's `subject`, `scope`, `roles` and `groups`, with an `X-Oberik-Signature` over the body so you can trust them. → [Tools you host](./tools.md#tools-you-host-webhook-tools) ## Call it from a browser ```bash curl -sX PUT "https://oberik.com/api/projects/$ID/origins" \ -H "X-API-Key: $KEY" -H "content-type: application/json" \ -d '{"origins":["https://app.acme.com"]}' ``` Exact origins, no wildcards, and the token is still minted on your server. → [Calling from the browser](./browser-origins.md) ## Show the user what a long turn is doing ```ts ai.chat.stream({ message }, { onTodos: setChecklist, // the plan, as it's written and ticked off onToolStart: (n) => setStatus(n), onCommandOutput: (c) => appendLog(c.delta), // a build or test run, live onSubagents: setWorkers, }); ``` → [Working with the user](./working-together.md) · [Subagents](./subagents.md) ## Correct it without waiting for it to finish ```ts const h = ai.chat.stream({ message: "migrate every fetcher" }, { onToken: write }); const landed = await h.steer("only the forfaits-* ones"); if (!landed) { /* the turn ended first — send it as a normal message */ } ``` → [Steering](./working-together.md#steering--a-message-into-a-running-turn) ## Cap what a single turn may cost Per request, a client may only ever ask for *less*: ```ts await ai.chat.send({ message, model: "openai/gpt-4o-mini", reasoning_effort: "low", enable_computer: false, enable_subagents: false, }); ``` The ceilings that a client cannot raise go on the token, at mint time — `maxToolIterations`, `maxEffort`, `maxContextTokens`, `models`. There is no platform-imposed tool-loop limit, so this is the place to set one. → [Bounding a run](./limits-and-errors.md#bounding-a-run) ## Teach it one of your procedures ```ts // From your server, with the project key. Applies to everyone in the project. await oberik.skills.upload(zip, { filename: "acme-support.zip" }); // From your app, with an end-user's token. Private to them; needs `plugins:write`. await ai.plugins.upload(zip); // a packaged plugin, a zipped folder, or one SKILL.md ``` One feature, two clients, and they are spelled differently: the project client calls it **`skills`**, the end-user client calls it **`plugins`**. → [Skills](./skills.md) ## Erase a user ```ts await ai.audit.forget("acme:fin:ana"); // -> { subject, documents_deleted, sessions_deleted, sandboxes_destroyed, // tasks_cancelled, vectors_purged, objects_deleted, rows_deleted } ``` Every row that names them goes — documents, conversations, sandboxes, scheduled tasks, triggers, background jobs, uploaded plugins — and anything with a life outside the database is torn down rather than merely deleted: a sandbox is destroyed on its host, a schedule is cancelled with the scheduler, and their **stored files are removed** along with the rows that named them, unless something that survives still points at one. `rows_deleted` reports what each table gave up; `objects_deleted`, how many files went. → [Guardrails & auditing](./guardrails.md#auditing) ## Find the conversation that went wrong ```ts const failed = await ai.chat.sessions.list(undefined, { status: "error", limit: 20 }); for (const s of failed.items) console.log(s.id, s.last_model, s.last_error); // narrow further — a search over id, title, user or model await ai.chat.sessions.list(undefined, { q: "invoice", model: "gpt-5" }); // what the model filter should offer, read from the traces themselves await ai.chat.sessions.models(); ``` Every session carries what its **last** turn ran on and whether it worked, so finding a broken conversation does not mean opening them one at a time. Filtering happens server-side — the rows you want are a handful out of thousands. ## Find out what the agent could actually do right now ```ts const { tools } = await ai.tools.list(); ``` Filtered exactly the way a turn is — ceiling ∩ token ∩ flags — so it answers "what would my next request get?" rather than "what exists". → [Tools](./tools.md#discover-whats-available) --- ## The SSH interface # Oberik over SSH ```bash ssh ssh.oberik.com ``` Everything the dashboard does, as text: sign up, create a project, set the capability ceiling, upload documents, mint tokens, publish tools, read the docs, talk to the agent. No client to install and nothing to configure — if you have `ssh`, you have Oberik. The command set is not hand-written. The control plane describes its own routes, and the gateway generates commands, help and forms from that description at connect time — so a new dashboard feature appears here without anyone updating a CLI. ## For coding agents This interface exists mostly for you. A browser-based dashboard is unusable to something that only has a shell, and the alternative — asking your user to paste an API key into your terminal — is the habit that makes credential theft work. **Sign in without ever handling a credential:** ```bash ssh ssh.oberik.com 'login link' # {"ok":true,"data":{"url":"https://oberik.com/auth/device?code=7E2R2NZY","code":"7E2R2NZY"}} ``` Show that URL to your user and ask them to open it. Then: ```bash ssh ssh.oberik.com 'login wait' # blocks until they approve, then returns ``` The two are separate commands on purpose. `login link` **returns immediately**, because a command that blocked while waiting would hand you its link only after the request had expired — you would never get to show anyone anything. `login wait` is the part that blocks, which is fine, because by then you know what you are waiting for. What your user sees is a page naming the SSH key being added, with approve and decline. You never see their password, and they can check the code on the page against the one you printed. Once approved, the key is registered: **every later connection is signed in automatically**, with no link at all. If you already have a key registered, none of this is needed — just run commands. **Read the documentation without leaving the shell:** ```bash ssh ssh.oberik.com 'docs' # every page, with what it covers ssh ssh.oberik.com 'docs page quickstart' # one page, in full ssh ssh.oberik.com 'docs search capability' # the lines mentioning something ``` This is the same text as [the docs site](/), so nothing is a summary of something else. **Everything is scriptable:** ```bash ssh ssh.oberik.com 'discover' # the whole command catalog as JSON ssh ssh.oberik.com 'projects --json' # any command, as JSON ssh ssh.oberik.com 'format json; project use acme; capabilities' # several per line ``` **The flag goes inside the quotes.** `ssh ssh.oberik.com --json 'projects'` cannot run: `ssh` parses its own options after the destination too, so the client eats `--json` and answers with its own usage dump — which mentions neither Oberik nor the flag, and reads like a broken host or a bad key. Everything after the destination is one command line for the gateway, so every flag belongs inside it. `format json` is the better answer for a line with several commands: it sets the output format for the rest of the line rather than being repeated per command. If a VALUE starts with `--`, put `--` in front of it — everything after that is an argument rather than a flag: ```bash ssh ssh.oberik.com 'docs search -- --json' # search for the text "--json" ``` `discover` is worth reading first: it gives every command, its parameters and their types, so you can drive this without guessing at syntax. **The JSON envelope.** Every response is one line: ```jsonc {"ok": true, "command": "projects", "message": null, "data": [ … ]} {"ok": false, "command": "playground end", "message": "…", "error": "…", "data": null, "status": 400} ``` Branch on `ok`. The human-readable text is in `message` either way — a failure repeats it in `error`, which is the field to prefer when you want *only* the failure text. It used to appear in `error` alone, so a client logging `message` printed a blank for a perfectly clear three-line error. `status` is the HTTP status behind it, so a retry can tell a `429` from a `400`. **Every line gets one, including the ones that failed, and including a line that was never a command at all.** That holds in a session you hold open as well as in a one-shot invocation — the two used to differ, so a program running several commands down one connection parsed the successes and got human prose for the failures. If you asked for JSON, prompts are off too: a command missing a required field answers with what is missing rather than opening a form into the middle of your stream, and a destructive one answers `re-run with --yes` rather than stopping to ask. ## A first session, end to end ```bash # 1. sign in (or `signup ` if you have no account) ssh ssh.oberik.com 'login link' # show the URL to your user ssh ssh.oberik.com 'login wait' # blocks until approved # 2. an organization and a project — the project provisions an isolated tenant ssh ssh.oberik.com 'org new "Acme"' ssh ssh.oberik.com 'project new "Support Bot"' # 3. what tokens minted for it may do ssh ssh.oberik.com 'project use "Support Bot"; capabilities set --allowTodo on --allowComputer on' # 4. a server-side key, then an end-user token ssh ssh.oberik.com 'key new ci' # pk_… shown once ssh ssh.oberik.com 'token --subject acme:user_1' # a JWT for one end-user # 5. try the agent ssh ssh.oberik.com 'chat "what can you do?"' ``` Every one of those maps to a dashboard screen; `open` browses the same thing as pages with numbered menus if you would rather look around than know the command. ## Two identities on one connection Almost every command acts as **you, the operator** — `documents`, `document upload`, `capabilities`, `providers`, `sources`, `audit`. One does not: ```bash ssh ssh.oberik.com 'dp GET /capabilities' # {"subject":"user", …} ``` `dp` is the raw data-plane escape hatch, and the data plane only speaks end-user tokens, so it mints one — **it sees the project the way one of your customers does.** That makes `documents` and `dp GET /documents` return different corpora on the same connection: ```bash ssh ssh.oberik.com 'document upload notes.txt --visibility self' ssh ssh.oberik.com 'documents' # you see it — you own it ssh ssh.oberik.com 'dp GET /documents' # a customer does not, so neither does dp ``` Nothing is broken there: a `self`-visibility document belongs to whoever uploaded it, and the ACL is doing its job in both answers. But the difference is **silent** — a shorter list and a `200`, never a refusal — so use `documents` and `document` to audit what a project holds, and `dp` to check what a customer actually gets. The corollary is that `dp` cannot do the operator-only things either. `/audit` requires an admin role and an end-user token is not an admin, so `dp GET /audit` answers `403 admin role required` — read the trail with `audit`, which goes through the control plane with the project key like every other operator command: ```bash ssh ssh.oberik.com 'audit --limit 20' ssh ssh.oberik.com 'audit --subject acme:user_1' ``` ## Reporting a bug ```bash ssh ssh.oberik.com 'bug report "Uploads fail" "A 14MB PDF returns a 500."' ``` Two arguments, because everything else is already known: who you are comes from the session, and which client filed it from the connection. Add `--project` when it is about one — by name, the way you would say it: ```bash ssh ssh.oberik.com 'bug report "Retrieval is empty" "No chunks come back." --project "Support Bot"' ``` A project you cannot reach is refused rather than dropped, so a report never ends up filed against nothing when you thought you had said where it happened. `bugs` lists what you have filed and `bug status closed` closes one when it stops happening. The same reports arrive from the dashboard's **Report a bug**, in the sidebar. For anything that is not a bug — a feature you need, a question about your integration, or a look at what you are building — [hello@oberik.com](mailto:hello@oberik.com) reaches a person. ## Registering a key by hand If you already have a session — or you would rather not use the link flow — the key you connected with can be registered directly: ```bash ssh ssh.oberik.com 'ssh-key add' # registers the key this connection presented ssh ssh.oberik.com 'ssh-keys' # what is registered ssh ssh.oberik.com 'ssh-key rm --yes' # remove one — confirmed, like the others ``` `ssh-key add` with no argument uses the key you are already connected with, so there is nothing to paste. **A key is identified by its fingerprint, not by its name.** `ssh-key add` derives the name from the enrolment command, so every key enrolled the same way carries the same one — the picker and the confirmation both name the fingerprint instead. **`status` says which key this connection is using** — it is the `keyFingerprint` field, and it is `null` on a password or guest session because there is no key to name. So you do not have to work it out from `lastUsedAt`; and if you point `ssh-key rm` at that one, the confirmation says so: ``` ! Remove a registered SSH public key (SHA256:kBliaV…) — this is the key this connection is using, and you cannot re-add it over SSH — irreversible. Re-run with --yes to confirm. ``` ## Sending a file There is no SFTP and no file picker: the gateway runs on our side and cannot read your disk. So a command that takes a file takes its **name** as the argument and its **bytes** from the connection — you pipe the file into the `ssh` command itself. ```bash ssh ssh.oberik.com 'use "Support Bot"; document upload handbook.pdf --tags policies' < handbook.pdf ssh ssh.oberik.com 'use "Support Bot"; skill upload refunds.zip' < refunds.zip ``` The filename you pass is the name the document is stored under, so it is worth it matching the file you are piping — nothing checks that they agree. For something short, or from a session with nothing to pipe, `--content` takes the body inline instead: ```bash ssh ssh.oberik.com 'use "Support Bot"; document upload notes.md --content "Refunds are 30 days."' ``` `help document upload` lists both, along with every other field the route takes. This works for any described route with a file field, present or future — `discover --json` reports them as `"type": "file"`, which is how a client can tell a field that needs bytes from one that needs a string. It is the only way a command can read your bytes, so the catalog cannot be wrong about which ones do. An upload is stored `tenant`-visible by default — readable by every end-user of the project, which is what a curated corpus is for. The same fields the dashboard offers are here to narrow it: ```bash ssh ssh.oberik.com 'use "Support Bot"; document upload rates.csv \ --visibility groups --acl_roles finance' < rates.csv ``` Ingestion is asynchronous. The row appears immediately and turns `ready` once it is indexed, so poll it if the next thing you do depends on the corpus: ```bash ssh ssh.oberik.com 'use "Support Bot"; documents' # status per document ``` ## Destructive commands, and how confirming works without a person Some commands ask before they run — `project rm`, `org rm`, `document rm`, `wiki rm`, `ssh-key rm`. `discover` reports which, as `confirm: true` on the command. `ssh-key rm` is the one worth knowing about: every other delete here is re-doable from the same connection — mint another key, re-add the provider, re-add the MCP server — while this one removes the credential the connection is authenticated by. Remove the key you are using and you cannot re-add it over SSH, because you can no longer open a session. What confirming means depends on who is driving, and the safeguard is deliberately *not* the prompt: - **At a terminal**, you are asked to type `yes`, with the target named in the question. - **Non-interactively** — `ssh host 'project rm …'`, a script, an agent — there is nobody to ask, so the command refuses outright unless you pass `--yes`. - **In both cases the target has to be named as an argument.** `--yes` skips the prompt; it never skips the name. `project rm` takes the project's name and the server checks it against the selected project, so the delete either lands on the project you identified or on nothing at all: ```bash ssh ssh.oberik.com 'project use "Support Bot"; project rm "Support Bot" --yes' # and a mistake is a 400, not a deleted workspace: ssh ssh.oberik.com 'project use "Support Bot"; project rm "Staging" --yes' # ! "Staging" is not this project's name — it is "Support Bot". Nothing was deleted. ``` That is the part that matters for an agent. A prompt is a speed bump for someone who is already looking at the right screen; the failure mode for a client that is not looking at a screen is acting on whatever the connection happens to have selected. Naming the target removes that, and the acknowledgement is recorded in the command log either way. `project rm` erases the project's tenant — documents, agent knowledge, conversations, uploaded files, sandboxes, scheduled tasks — along with its keys and provider credentials. It answers with what the erasure reached. There is no undo. ## What it is not The gateway holds no state and no privileges of its own. Every command runs through a control-plane session over HTTP, exactly as the React app does — so it can do what your account can do, and nothing more. Signing out, revoking a key, or deleting the account takes effect here immediately, because there is nothing else to revoke. ## What is recorded Every command you run over SSH is logged, with the address it came from and — once you are signed in — your account. The gateway is a public front door that anyone may connect to anonymously, so this is how abuse is told apart from use. **Credentials are not stored.** A password typed at `login`, and a provider key passed as `--values {"api_key":"…"}`, are replaced with `` before the row is written. The rest of the command is kept as-is, so `login you@example.com ` still records who tried. Alongside it is a hash of the original line, which lets repeated commands be correlated without the credential being recoverable. Records are kept for 30 days, or 100,000 commands, whichever comes first — except for rows an operator has flagged while investigating something. If you would rather not have a credential in a log at all, don't pass one: register your key (`ssh-key add`) or use the browser flow (`login link`), and no password is typed on a command line in the first place. **Repeated failed logins get slower.** After a handful of wrong passwords for the same account (or from the same address) inside ten minutes, each further attempt is held back a little longer, up to a few seconds. Nothing is ever locked: a person who has forgotten which password they used never reaches the delay, and a client whose credentials have gone stale is slowed rather than shut out. If you are automating `login`, the fix is the credential — a retry loop against a wrong password gets progressively less useful, which is the point. --- ## Authentication There are two credentials and they are not interchangeable. | Credential | Lives | Used for | |---|---|---| | **Project API key** (`pk_…`) | your server, only | minting end-user tokens; project administration | | **End-user token** (JWT) | your frontend, or your server | every data-plane call: chat, documents, actions | The rule: **your server mints, your client calls.** A token carries the permissions, so your frontend never holds anything that could widen them. :::tip Issue a mint-only key A project key carries a **scope**, chosen when you create it: | Scope | What it can do | |---|---| | `mint` *(default)* | Mint end-user tokens for this project, and read its capability ceiling. Nothing else. | | `admin` | Every [Project API](./project-api.md) call: the ceiling, the corpus, skills, further keys, deleting the project. | Almost every backend wants `mint`. It is the difference between a leaked key costing you some tokens and costing you the workspace — an `admin` key can raise the project's own ceiling and delete everything in it. An `admin` key satisfies a mint requirement, never the reverse. A mint key used on an admin route is refused with a 403 that says so. Either way: keep it in a secret store, one per environment, and revoke it in the dashboard the moment it might have leaked. ::: ## Minting a token ```bash curl -X POST "https://oberik.com/api/projects/$PROJECT_ID/token" \ -H "X-API-Key: $OBERIK_PROJECT_KEY" \ -H "content-type: application/json" \ -d '{ "subject": "acme:finance:user_9f3c", "scope": "acme:finance:user_9f3c", "capabilities": ["chat", "documents:read"], "expiresIn": 3600 }' ``` ```json { "access_token": "eyJ…", "expires_in": 3600, "capabilities": ["chat", "documents:read"] } ``` Send it as `Authorization: Bearer `. ### Fields | Field | Meaning | |---|---| | `subject` | Who this token is. Owns whatever it creates, and is the default visibility boundary. | | `scope` | What it may **see** — a path prefix. Omit to mean "only this subject's own data". | | `capabilities` | What it may **do**. Intersected with the project's ceiling; you can narrow, never widen. Modalities are the exception — see below. | | `roles` / `groups` | Labels used by document ACLs — **except three role names that are privilege grants.** See below. | | `models` | Restrict this token to a subset of models. | | `maxEffort` | Cap reasoning effort. | | `maxToolIterations` | Cap agent↔tool loops for this token. Unset means unlimited. | | `expiresIn` | Seconds. Keep it short and mint per session. | ### `admin`, `owner` and `service` are not labels :::danger Do not forward your application's own role names Three role names mean something to the data plane. A token carrying `admin`, `owner` or `service`: - reads **every** end-user's chat sessions in the project, not just its subject's — `chat.sessions.list()` returns the whole tenant; - reads every document and memory regardless of owner (`self`-visibility is the one exception, which even an admin cannot see); - is what `ai.audit.list` and `ai.audit.forget` require. That is the entire per-end-user isolation boundary, switched off by a string. If your backend maps its own users' roles onto this field — and `admin` is the most likely name to pass straight through — one customer's admin gets every customer's conversations. Use `groups`, or any other name, for ACL labels; those carry no privilege. Mint an admin token deliberately, for a back-office caller, and never from a role name that arrived in a request. The mint response returns a `warnings` array whenever a privileged role was granted, so you can assert on it in a test. ::: ### Read `warnings` on every mint The same array says when a capability you asked for is **not** on the token, which is otherwise invisible until something else refuses: ```jsonc { "capabilities": ["chat", "input:file", "output:file"], "warnings": [ "'tasks' is not a capability — did you mean 'tasks:read' or 'tasks:write'? Nothing was granted for it, and a request needing it will be refused later by whichever call needs it.", "'computer' was not granted: this project's ceiling does not allow it. Turn it on under Capabilities in the dashboard, or mint without it — the agent will otherwise simply say it cannot do the thing, which is not something you can assert on." ] } ``` Two different mistakes with two different fixes: the first is a spelling, the second is a dashboard toggle. Without them a mistyped name mints happily and the `403` arrives from `tasks.create` forty seconds later, and a ceiling-trimmed capability shows up only as the agent explaining in prose that it cannot help — which no test can assert on. ## Capabilities A capability is a permission the data plane checks on every call. The project's **Capabilities** page is the ceiling; a token can hold any subset of it. List the ones *this end-user* needs. The [modality](./multimodal.md) capabilities — `input:` and `output:` — are the one thing you don’t have to restate: leave them out and the token carries whatever the project is configured for. They describe what the agent you built can perceive and hand back, which is a property of the project rather than a decision about one user, and reading their absence as a refusal meant an app that listed its tool permissions ended up with an agent that could write a document and not give it to anyone. Name any `output:` and you're being explicit: that side narrows to exactly what you listed. The two sides are independent, and neither can exceed the ceiling. The full set, grouped the way the dashboard groups them. Every one is also declinable per request with the `enable_*` flag beside it. | Capability | Grants | Per-request flag | |---|---|---| | **Conversation** | | | | `chat` | conversations | — | | `system_prompt` | send a per-request system message (the project's still applies, and wins) | — | | `steer` | [a message into a running turn](./working-together.md#steering--a-message-into-a-running-turn) | — | | `voice` | [talk to the agent](./voice.md) — a live full-duplex call, not a transcript round trip. The per-request flag governs the agent's own conversation controls (hold, resume), not whether a call can be opened | `enable_voice_control` | | **Knowledge** | | | | `documents:read` / `documents:write` | search / upload and manage [documents](./documents.md). Setting the project to **read-only documents** stops `documents:write` ever being minted | `enable_rag` | | `memory` | [durable memory and the wiki](./scheduling.md#memory) | `enable_memory` | | **Internet** | | | | `web_search` | public web search and page reads | `enable_web_search` | | `browser` | [drive a real browser](./tools.md#driving-a-browser) — click, type, scroll, screenshot | `enable_browser` | | `browser_handoff` | [hand a page to the user](./tools.md#handing-the-page-to-the-user) when only a person can act | — | | **Agentic** | | | | `todo` | [the plan it keeps](./working-together.md#todos--the-agents-plan) across a conversation | `enable_todo` | | `computer` | [sandboxed compute](./sandbox.md) | `enable_computer` | | `subagents` | [work handed to another agent](./subagents.md) beside it | `enable_subagents` | | `tasks:read` / `tasks:write` | [scheduling](./scheduling.md) | `enable_scheduling` | | `triggers` | [webhook triggers](./scheduling.md#webhook-triggers) an end-user may create | — | | **Human in the loop** | | | | `ask_user` | [pause on a question](./working-together.md#questions-the-agent-asks) and wait for an answer | `enable_ask_user` | | `approvals` | [stop before something irreversible](./working-together.md#asking-permission) | `enable_approvals` | | `ui_tools` | [components it draws into your UI](./tools.md#ui-components--tools-that-draw) | — | | `voice:transfer` | on a call, [hand the caller to a person](./voice.md#transfer) — they wait on hold while whoever picks up is briefed, and come back to the agent if nobody does | — | | `followups` | suggested next messages, generated beside the conversation | — | | `recap` | a one-line "where we got to" for someone returning | — | | `auto_title` | name each conversation from its first message; titles stay empty otherwise, and a title you set yourself needs no capability | `enable_auto_title` | | **Extensions** | | | | `webhook_tools` | the agent may call the [tools you publish as URLs](./tools.md#tools-you-host-webhook-tools) — they work in unattended runs, where a client-side tool has no client | `enable_webhook_tools` | | `mcp:manage` | end-users may attach their own [MCP servers](./tools.md#mcp-servers) | — | | `plugins:write` | end-users may **upload** their own [skills](./skills.md#who-can-upload). Using the ones you published needs no capability — decline them for a turn with `enable_plugins: false` | `enable_plugins` | | `action_space` | relevance-based tool pre-selection for large catalogs | `enable_action_space` | | **In and out** | | | | `input:file` | a user may attach **any** file; what the model can't perceive is read as text (OCR, transcript, extraction) | — | | `input:image` / `audio` / `video` | the model may perceive that kind **directly**, rather than through extraction | — | | `output:file` | the agent may hand back **any file it produced** — a document, a sandbox export, a screenshot | — | | `output:image` / `audio` / `video` | the model may **generate** that kind | `output_modalities` | Anything not granted is refused — **including a tool the model tries to call anyway**. That's the point: prompt injection can't reach a capability you didn't grant. ```ts // A read-only research token. capabilities: ["chat", "documents:read"] // Narrow further per request: this turn may only read documents, nothing else. await ai.chat.send({ message, allowed_tools: ["rag_search"] }); ``` :::note "Read-only" here means read-only, network included A token like the one above carries the project's **modality** capabilities too — `output:file` and `output:image` are inherited rather than listed ([Files & media](./multimodal.md)) — so it can hand back a file the agent produced. It cannot reach the network. Every tool that fetches something is behind a capability this token does not list: `web_search` and `browse_url` behind `web_search`, the browser family behind `browser`, and `screenshot_url` behind `web_search` as well (it fetches a URL to make the image it returns). That last one used to be gated on the output modality alone, which meant this exact token could read an external page — [details](./tools.md#driving-a-browser). `allowed_tools` still narrows further per request when you want a capability granted and one tool withheld. ::: ## Scopes: isolating users inside one project `scope` is a path prefix, with `:` as the separator. A token sees its own subtree. | `subject` | `scope` | Sees | |---|---|---| | `acme:fin:ana` | `acme:fin:ana` | only Ana's own documents and sessions | | `acme:fin:lead` | `acme:fin` | everything in the finance team | | `acme:admin` | `acme` | the whole customer | Use one **project per customer** for hard isolation, and scopes for the hierarchy *inside* that customer. ## Rotation and expiry Mint a token per user session with a short `expiresIn`. Rather than handing the SDK a static token and dealing with expiry at every call site, give it `getToken` — the SDK calls it back with `{ expired: true }` when the current token is finished with, replays the request with the new one, and your code never sees the 401: ```ts const ai = createClient({ getToken: async ({ expired }) => { if (!expired && cached) return cached; // your own cache cached = await mintFromYourBackend(); // the endpoint above return cached; }, }); ``` This is the recommended way to authenticate. Details worth knowing: - **Two things call you back with `{ expired: true }`**, and the second is the one that surprises people. The first is a `401` — the server rejected the token. The second is a local check: if the token you returned **expires within the next 60 seconds**, the SDK asks for another one *before sending anything*. So you will see `{ expired: true }` with no request having failed. That is a `401` avoided rather than recovered from. - The check is on the token's **remaining** life, not on the `expiresIn` you minted it with. A short-lived token trips it straight after minting; a long-lived cached one trips it in its final minute — which is the recommended shape working as intended, not a problem. There is no TTL for which it never fires. - Either way it costs **one extra mint per client**, not per request. If the replacement is also inside the margin (a project whose whole TTL is under a minute), the SDK stops checking ahead for that client rather than minting on every call, and expiry goes back to being handled by the `401`. - Concurrent requests share **one** refresh, so a page that fires five calls at once doesn't mint five tokens. - A request is replayed at most once, so a token that's still rejected surfaces as a normal `401` instead of looping. - An interrupted stream re-attaches to the **same run** with the new token — an answer in flight is neither lost nor paid for twice. - If your callback throws, the original `401` is what you get, not the callback's error. A static `token` still works and is fine for a script or a server job shorter than the TTL — but nothing can rescue it once it expires. ### Narrowing a token you already hold A backend that already has a project credential can mint a narrower one without going back to the control plane: ```ts const { access_token } = await ai.auth.token({ user_ref: user.id, capabilities: ["chat", "documents:read"], scope: `${user.orgId}:${user.id}`, allowed_models: ["openai/gpt-4o-mini"], max_effort: "low", expires_in: 900, }); ``` Every field here is intersected or clamped against what the calling credential holds, so a restricted token cannot mint a broader one. That covers what the child may *do*: capabilities, scope, `data_scope`, `allowed_models`, `max_effort`, the tool-iteration and context ceilings, and the subject it may be minted for — a bound the caller holds is applied whether or not you mention the field, because an absent claim reads as *unrestricted*. **`expires_in` is the exception, and it is deliberate.** It is clamped against the project's maximum rather than the caller's remaining lifetime, so a 10-minute token can mint a 24-hour one. The child holds no more *privilege* than its parent — it is the same grant, for longer — and a short-lived credential refreshing itself into a session is a pattern worth keeping. Size a token's lifetime by what it may do, not by the lifetime of whatever minted it. `allowed_models` narrows *within* the project's own registrations; it is not what keeps a token inside them. A request may only name a model this project registered, whatever the token says: ``` model: "gpt-4o" // on a project that registered only "hukuk-db" 400 model 'gpt-4o' is not available to this project. Registered: hukuk-db ``` That boundary is checked before the request goes anywhere, so a name the project never configured cannot reach a shared deployment, cannot spend on anything but the project's own key, and cannot escape its cap. Use `allowed_models` to narrow further — pinning a browser token to your cheapest model — rather than as the thing standing between projects. Project API keys are shown once at creation and can be revoked from the dashboard. Revoking one immediately stops new tokens being minted with it; already-minted tokens expire on their own schedule. :::warning Never put a project key in a browser It can mint tokens with any capability the project allows. If one leaks, revoke it in the dashboard and create a new one. ::: --- ## Project API Everything on a project's dashboard is an HTTP call your server can make with the project key. Nothing here needs a browser, a session or a human, so a project's configuration can live in your repository and be applied by a deploy rather than reconstructed by clicking. ```bash export OBERIK_PROJECT_KEY="pk_…" export OBERIK_PROJECT_ID="7f3c…" # a UUID CP=https://oberik.com ``` ## Which client There are two, and knowing the feature does not tell you which one has it: - **`createProjectClient`** — the **project key**, server-side. What an *operator* configures: providers, models, limits, the capability ceiling, skills, MCP servers, webhooks, retrieval, the system prompt. - **`createClient`** — an **end-user token**, usable in a browser. What a *user* does: chat, their documents, memory, tools, sandboxes, voice — and `audit`, which needs a token minted with `roles: ["admin"]`. Two real confusions, in opposite directions, from people who knew the product well: | Reached for | Actually on | Why it is not obvious | |---|---|---| | `project.audit.list(…)` | the **end-user** client, admin role | reading a trail feels like an operator's job | | `ai.retrieval.set(…)` | the **project** client | choosing the embedding model feels like a user setting because retrieval is what a user experiences | And three names exist on **both**, meaning different things — the case where guessing wrong still compiles: - **`documents`** — the project client sees every document in the project; the end-user client sees what that user's token may see. - **`capabilities`** — the project client sets the project's *ceiling*; the end-user client reads what this token actually carries. - **`tasks`** — the project client manages the project's schedules; the end-user client manages that user's own. `createProjectClient` also takes `projectId` **and** `projectKey`, not an `apiKey`. The SDK has a server-side client for all of it: ```bash npm install @oberik/sdk ``` ```ts import { createProjectClient } from "@oberik/sdk"; const oberik = createProjectClient({ projectId: process.env.OBERIK_PROJECT_ID!, projectKey: process.env.OBERIK_PROJECT_KEY!, }); await oberik.readiness(); // can this project answer yet? await oberik.tokens.mint({ subject: "acme:ana", capabilities: ["chat"] }); await oberik.capabilities.set({ allowTodo: true, allowComputer: true }); await oberik.documents.upload(file, { tags: ["policies"] }); await oberik.systemPrompt.set("You are Acme's support agent."); await oberik.origins.set(["https://app.acme.com"]); await oberik.webhookTools.create({ name: "cancel_order", description: "…", url: "…" }); await oberik.keys.create("ci", { scope: "admin" }); await oberik.usage.observability(); ``` It is a **different client** from the one your app uses, deliberately. That one takes an end-user token and belongs wherever your app runs; this one holds the project key and belongs only on your server — so it throws if it is ever constructed in a browser, rather than shipping and leaking the key to every visitor. The curl equivalents are below, since everything here is one HTTP call. Each carries the same header: ``` X-API-Key: pk_… ``` :::danger Everything on this page needs an `admin` key A project key carries a scope, and a new key defaults to **`mint`** — enough to mint end-user tokens and read the capability ceiling, and nothing else. That is what a backend usually wants, and it is what you should be holding unless a script genuinely administers the project. The calls on this page are the rest of the Project API, so they need an **`admin`** key: it can change the capability ceiling, upload and delete documents, publish skills, **create more keys**, and **delete the project**. Treat one exactly like a root credential — server-side only, in a secret store, one per environment, revoked in the dashboard the moment it might have leaked. A mint key used here is refused with a 403 that names the scope it has. Already-minted end-user tokens are unaffected by a revocation and expire on their own schedule. ::: ## Read the whole project ```bash curl -s "$CP/api/projects/$OBERIK_PROJECT_ID" -H "X-API-Key: $OBERIK_PROJECT_KEY" ``` Returns the project, its `capabilities` ceiling, default model and context settings — the same object the dashboard renders. `GET …/capabilities` returns just the ceiling, with **every** flag present (an untouched one comes back `false`, not missing), plus `supportedModalities`: what the project's registered models can actually read and produce. In the SDK, `oberik.project.get()` and `oberik.project.set({ … })` cover this and the top-level fields (`name`, `description`, `doneWebhookUrl`), and `oberik.project.citations()` sets the marker style. It also carries **`health`** and **`healthDetail`** — the same badge the dashboard renders on the project card, so you can show your own operators what Oberik shows you rather than reconstructing it: ```ts const p = await oberik.project.get(); // p.health "ok" | "degraded" | "down" // p.healthDetail "Every request fails until this is done — your own provider key, and the // models it may use. POST /api/projects/:id/providers · dashboard: LLM & // limits → Add provider" ``` `healthDetail` is a written sentence that leads with what still works and then names what does not; it is not reconstructable from the booleans. Both fields come from `readiness` below — one function computes them, so no surface can report a different badge than another. :::tip Anything without a method: `oberik.raw()` The project client wraps the routes people use most, and this page describes more than that. For any route with no method of its own, `raw` is the escape hatch — same key, same base URL, same error handling, so you never have to hand-roll a control-plane fetch: ```ts await oberik.raw("GET", "/sandbox-host"); // what this deployment's sandboxes provide ``` The example here used to be `raw("PUT", "/subagents", …)`, and then `oberik.subagents.set()` existed; the same has since happened to `/computer`. When a route grows a method, `raw` keeps working — it just stops being the shortest way. Paths are relative to `/api/projects/:projectId`. ::: ## Set the capability ceiling The ceiling is the maximum any token minted for this project may hold. Minting intersects with it, so a token can always be narrower and can never be wider. ```bash curl -sX PATCH "$CP/api/projects/$OBERIK_PROJECT_ID" \ -H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \ -d '{"capabilities":{"allowTodo":true,"allowComputer":true,"allowApprovals":true}}' ``` **Only the flags you send change**; the rest are preserved, so you never have to read the ceiling back and resend it whole. | Field | Type | Grants the capability | |---|---|---| | `allowChat` | boolean | `chat` | | `allowDocuments` | boolean | `documents:read` (+`documents:write` unless read-only) | | `documentsMode` | `"read-write"` \| `"read"` | read-only stops `documents:write` ever being minted | | `allowMemory` | boolean | `memory` | | `sharedWiki` | boolean | one wiki for the project instead of one per end-user | | `allowWebSearch` | boolean | `web_search` | | `allowBrowser` | boolean | `browser` | | `allowBrowserHandoff` | boolean | `browser_handoff` | | `allowTodo` | boolean | `todo` | | `allowComputer` | boolean | `computer` | | `allowSubagents` | boolean | `subagents` | | `allowScheduling` | boolean | `tasks:read`, `tasks:write` | | `allowTriggers` | boolean | `triggers` | | `allowAskUser` | boolean | `ask_user` | | `allowApprovals` | boolean | `approvals` | | `allowUiTools` | boolean | `ui_tools` | | `allowFollowups` | boolean | `followups` | | `allowRecap` | boolean | `recap` | | `allowSteer` | boolean | `steer` | | `allowSystemPrompt` | boolean | `system_prompt` | | `allowWebhookTools` | boolean | `webhook_tools` | | `allowMcp` | boolean | `mcp:manage` | | `allowPluginUploads` | boolean | `plugins:write` | | `allowActionSpace` | boolean | `action_space` | | `inputModalities` | string[] | `input:image` … — bounded by what your models can read | | `outputModalities` | string[] | `output:image` … — `file` is always grantable | | `sessionVisibility` | `"private"` \| `"self"` | who may read a session — see below | | `defaultRoles` / `defaultGroups` | string[] | used when a mint does not name its own. An explicit `roles` / `groups` **replaces** these — including `[]`, which mints with none. `admin`, `owner` and `service` bypass per-user isolation, so do not put your own application's role names here | | `allowedTools` | string[] \| null | a project-wide tool allow-list | The two modality lists take members of a closed set — `image`, `audio`, `video`, `file` — and anything else is **refused**, naming the set, the way `sessionVisibility` and `documentsMode` are. `["image"]` used to be indistinguishable from `["banana"]`: both were filtered out and both answered 200, so `outputModalities: ["image"]` could quietly leave a project able to produce nothing at all. A real modality that **no registered model supports** is a different thing and still **dropped on save** rather than granted — otherwise every token would carry a promise the agent then refuses — but the response says so in `warnings`, naming the modality and what would make it stick. Read the response back either way: what it returns is what was stored. ### `private` vs `self` They are not synonyms, and the difference is about **your** staff rather than your end-users. Both keep one end-user out of another's conversations. | | who can read a conversation | |---|---| | `private` (default) | its owner, a tenant admin, and any token whose `scope` covers the owner — the last one is what makes a support view or a team-lead view possible | | `self` | the exact owner, and nobody else. An admin token is refused, and so is a scoped one | Pick `self` when even your own staff must not read the conversation — a therapy intake, an HR channel, a whistleblowing form. Everything else wants `private`, because with `self` a support engineer cannot see the thread a customer is complaining about, and no role or scope can be granted to change that. A value outside the two is refused (`sessionVisibility 'tenant' is not one of: private, self. Nothing was changed.`) — deliberately not coerced to the safe default, because a caller who typed `"tenant"` meant something, and a 200 that quietly stored `private` would be the same defect one field over. **An empty string is refused too**, and that is the case worth knowing, because nobody types it: it is what an untouched `