/** * Agent Framework — single-file TypeScript client. * * Isomorphic: works server-side (Node 18+) and in the browser. The only runtime * requirements are the standard `fetch`, `Blob`, `TextDecoder`, and web * `ReadableStream` (all present in Node 18+ and modern browsers). No deps. * * Handles the tedious parts for you: * - Auth via JWT (Bearer) or legacy API key, with optional async token refresh. * - Chat streaming over SSE with automatic reconnection — if the connection * drops mid-generation it resumes from the last event id (server keeps going). * - Client-side tools: register a handler and the client auto-executes tool * calls the agent makes and submits the results, looping until the agent ends. * - Resumable uploads: presigned S3 multipart with per-part retry + concurrency. * - Resumable downloads: presigned URL fetched with ranged GETs that retry. * * Quick start: * const af = createClient({ token: jwt }); // hosted API * const af = createClient({ token: jwt, baseUrl: "http://localhost:8000" }); // local * const res = await af.chat.send({ message: "hello" }); * * // Client-side tools — the client runs your handler and resumes automatically: * af.registerTool({ * name: "get_weather", * description: "Current weather for a city", * parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, * handler: async ({ city }) => ({ tempC: 21, city }), * }); * const done = await af.chat.run({ message: "what's the weather in Paris?" }); * // ...or streaming, tokens + auto tool dispatch in one call: * const handle = af.chat.stream({ message: "weather in Paris?" }, { onToken: t => process.stdout.write(t) }); * await handle.done; */ /** * Where the agent API lives. Override for a self-hosted deployment or local * development (`http://localhost:8000`); the hosted API needs no `baseUrl` at all. */ export declare const DEFAULT_BASE_URL = "https://api.oberik.com"; export interface ClientOptions { /** Base URL of the API. Defaults to {@link DEFAULT_BASE_URL}. */ baseUrl?: string; /** * Static JWT (sent as `Authorization: Bearer`). Fine for a script; for anything * long-lived prefer `getToken`, because end-user tokens are deliberately * short-lived and a static one eventually 401s with nothing the SDK can do. */ token?: string; /** * The data plane's own tenant key (`tnt_…`), sent as `X-API-Key`. This is an * **operator credential**, not a customer one: it authenticates as a service principal * with the project's configured service roles, not as an end user. * * If you are using the hosted platform you do not have one and cannot get one — the * control plane holds it server-side and never shows it. Your project key (`pk_…`) * authenticates the **control plane**, not this host, and is refused here. Use `getToken` * (or `token`) with an end-user JWT; that is the way in for every customer. * * It said "legacy API key" before, which reads as *older but supported* and sent people * to a dead end with the only key they had (OBE-217). */ apiKey?: string; /** * How the client obtains a token — **the recommended way to authenticate.** Called * to get one, and called again with `{ expired: true }` when the current token is * finished with, so an expiry is handled where it happens instead of everywhere you * make a call: * * const ai = createClient({ * baseUrl, * getToken: async ({ expired }) => { * // YOUR endpoint, which mints with your project key server-side. * const r = await fetch("/api/ai-token", { cache: expired ? "reload" : "default" }); * return (await r.json()).access_token; * }, * }); * * **Two things trigger it**, and the second is the one that surprises people: * * 1. the server rejected the token (401). The failed request is then replayed once * with the new one, so callers never see the expiry. * 2. the token you returned **expires within the next 60 seconds**. This one fires * BEFORE any request is sent, so you will see `{ expired: true }` with nothing * having failed — a 401 avoided rather than recovered from. * * (2) is why a backend minting 30-second tokens sees one extra call per client. Only * one: if the replacement is also inside the margin — a project whose whole TTL is * shorter than 60s — the pre-emptive check gives up for that client rather than * minting on every request, and expiry is handled by (1) as normal. * * Concurrent requests share one refresh (no stampede), the replay happens at most * once per request (a still-rejected token surfaces as a normal 401 rather than * looping), and an interrupted stream re-attaches to the *same* run with the new * token — the answer in flight is neither lost nor paid for twice. * * `{ expired: false }` calls happen per request, so keep your own token cached and * only go to the network when `expired` is true. */ getToken?: (ctx: { expired: boolean; }) => string | Promise; /** Operator admin key for tenant-management endpoints (sent as X-Admin-Key). */ adminKey?: string; /** Custom fetch (e.g. for tests or non-global fetch). Defaults to global. */ fetch?: typeof fetch; /** How long a single BLOCKING request may take, in milliseconds. Default 600000 * (10 minutes); 0 disables it. * * Streaming is unaffected — it holds the connection open by design and is the right * answer for a long turn. But `limits-and-errors.md` says "if you must block, set a * generous client timeout (120s+)" and there was nothing here to set: on Node a * blocking turn that ran a sandbox job past five minutes died inside undici as * `UND_ERR_HEADERS_TIMEOUT`, with no mention of this SDK in the stack and no knob * short of hand-building a dispatcher. * * **On Node, past 300s, this needs {@link ClientOptions.dispatcher} too.** The value * alone is not enough: it aborts the request, while undici's own `headersTimeout` — * 300s, and not reachable from `AbortSignal` — fires first. So a 600000 budget really * ends at ~302s as `UND_ERR_HEADERS_TIMEOUT`, which is the failure this option exists * to prevent. {@link blockingBudgetIsHonoured} answers whether yours will hold, and * streaming needs none of this. */ timeoutMs?: number; /** * An undici `Dispatcher`, for Node callers who need a blocking budget past 300s. * * npm i undici@^7 // NOT 8 — see below * import { Agent } from "undici"; * const ai = createClient({ * getToken, * timeoutMs: 900_000, * dispatcher: new Agent({ headersTimeout: 900_000, bodyTimeout: 900_000 }), * }); * * **Streaming is the better answer**, and needs none of this: it holds the connection * open by design, has no header ceiling, and can be resumed. Reach for a dispatcher only * when you must block. * * **The undici major has to match the one embedded in Node.** `undici@8` throws * `invalid onRequestStart method` on EVERY request — `tools.list()` included — because * the dispatcher handler protocol changed between majors and Node's built-in fetch is * the older one. `undici@7` works. There is no way to check that from here (see * {@link blockingBudgetIsHonoured}), so a mismatch is caught at the first request and * reported as itself rather than as `TypeError: fetch failed`. * * Yours to construct rather than ours to find. This SDK has no dependencies and has to * load in a browser and under React Native, so it cannot import undici — and the * attempt to do so behind a computed specifier broke Metro outright, which analyses * `import()` statically and refuses a non-literal argument. Ignored where it means * nothing: browsers and React Native drop unknown `RequestInit` keys. */ dispatcher?: unknown; /** Extra headers merged into every request. */ headers?: Record; /** Client-side tools registered up-front; auto-dispatched by chat.run/stream. */ tools?: ClientToolDef[]; /** UI components the agent can draw into your app. Needs the `ui_tools` capability. */ ui?: UiComponent[]; } /** Public tenant view — never includes the API key. */ export interface TenantOut { id: string; slug: string; name: string; created_at: string; } /** Returned ONCE at creation — the only time the API key is exposed. */ export interface TenantCreated extends TenantOut { api_key: string; } /** Everything a minted token can be narrowed by. Each field is intersected (or * clamped) against the minting credential's own grant — never widened. */ export interface TokenRequest { /** Your own label for the end-user (appears on sessions). */ user_ref?: string; expires_in?: number; /** e.g. ["chat", "documents:read"]. Absent = inherit the caller's set. */ capabilities?: string[]; roles?: string[]; groups?: string[]; /** Visibility boundary for this token (must sit within the caller's). */ scope?: string; /** Hierarchical data-visibility prefix, if you separate it from `scope`. */ data_scope?: string; /** Restrict this token to a subset of models. */ allowed_models?: string[]; /** Reasoning-effort ceiling. */ max_effort?: "minimal" | "low" | "medium" | "high"; /** Ceiling on how many tool calls one turn may make. Narrows the project's own * (Dashboard → LLM & limits) — never widens it. */ max_tool_iterations?: number; /** Ceiling on how much conversation history is replayed for this token, in tokens. * Narrows the project's own context budget (Dashboard → Context management) — it * can never widen it. Use it to give a cheap tier a shorter memory. */ max_context_tokens?: number; } export interface TokenResponse { access_token: string; token_type: string; expires_in: number; tenant_id: string; /** Who the token is for. Omitting `user_ref` inherits the caller's subject — it does * not mint a token without one, which used to fail much later at an unrelated call * site with `Token missing subject claim 'sub'`. */ subject: string; /** What was actually granted, after intersecting with the caller's own. Absent when * the request named none (the token is then as broad as the caller). */ capabilities?: string[] | null; scope?: string | null; } export interface ToolInfo { name: string; source: "builtin" | "mcp" | "webhook" | string; description: string; } /** A tool this token holds and this project cannot run, with the reason. * * Reported rather than merely absent so a UI built from `tools.list()` can grey a control * out and say why. The memory family used to be *advertised* on a project with no * embedding model, and the turn then answered `Available: none` — so a "Remember this" * button appeared on a workspace where pressing it could only fail. Absence alone would * have fixed the lie and left the client unable to explain anything. */ export interface WithheldTool { name: string; source: "builtin" | "mcp" | "webhook" | string; /** One sentence: the cause, what it costs here, and the setting that fixes it. */ unavailable: string; } /** What happened when this project's MCP server was asked for its tools. * * Without this, a server that registered cleanly and contributed nothing is * indistinguishable from one still connecting, one whose handshake failed, and one * that genuinely has no tools — all four are an absence of rows in the listing. */ export interface McpServerStatus { name: string; /** Did it answer? */ ok: boolean; /** How many tools it contributed. `ok` with `0` means it answered and offered none. */ tools: number; /** Answered from the short-lived cache rather than dialled again this call. */ cached?: boolean; /** Why not, when `ok` is false — including a URL refused as private or unroutable. */ error?: string; } export type DocumentStatus = "awaiting_upload" | "pending" | "processing" | "ready" | "failed" | string; export interface DocumentOut { id: string; filename: string; content_type: string | null; tags: string[]; /** The subject that uploaded it — owns it for ACL purposes. */ owner_subject?: string | null; visibility: Visibility | string; visibility_scope?: string | null; acl_roles: string[]; acl_groups: string[]; status: DocumentStatus; chunk_count: number; error: string | null; /** Which model embedded it, and with which pipeline — a document embedded by an * older model lives in its own vector collection and needs `reingest` to move. */ embedding_model?: string | null; pipeline_version?: string | null; /** SHA-256 of the bytes as uploaded. Null until ingestion has read them. */ content_hash?: string | null; /** * The id of the OLDEST document in this project with the same bytes, or null. * * Resolved when you read it, against the corpus as it stands — so a whole folder uploaded * at once links as soon as its rows have hashes, and deleting the original stops the * copies naming it. Null on the upload response, where there is no hash yet, and on the * first copy, which has nothing older to point at. * * Worth checking after a bulk import: both copies stay retrievable, so retrieval returns * the same passage twice and that quietly doubles its weight in an answer. * * Declared here late: the docs' own snippet read it off `get`, correctly, and this type * did not have it — so the recipe the page teaches did not compile (OBE-194). */ duplicate_of?: string | null; /** How this document was actually read, in the order the engines ran. * * `["text-layer"]` — every page had a text layer, so it was extracted locally and cost * nothing. `["text-layer", "gpt-4o"]` — some pages were scans and went to the project's * OCR model. `["local-ocr"]` — OCR on our machines, no model call and no cost. * `["document-parser"]` — a non-PDF format parsed natively. * * Anything that is not one of those three is a MODEL NAME: the one you registered, and * the line on your bill. * * Worth checking on a corpus you care about: it is the answer to both "why did this * document come back empty" and "why did ingesting these cost what it did". */ read_with?: string[]; /** What the classifier made of a PDF: `text_based`, `scanned`, `image_based`, `mixed`. * Null for anything that is not a PDF. Informational — routing is per page, not from * this. */ pdf_type?: string | null; /** How many pages OCR was billed for. Zero for a document that had a text layer * throughout, which is the common case and the point of classifying at all. */ ocr_page_count?: number; /** Why it failed, as one word to branch on. Null unless `status` is `"failed"`. * * `error` is the sentence to show a person; this is for the code around it. The two * that are not about the document at all are `usage_cap` and `rate_limit`: the project * hit its own spend cap or its per-minute limit, nothing is wrong with the file, and * the fix is to raise the cap or wait rather than to re-upload. `rate_limited` is a 429 * whose body did not say which. The rest name the stage: `unsupported_type`, * `unreadable`, `embedding`, `vector_store`, `storage`, `unknown`. */ failure?: DocumentFailure | string | null; created_at: string; } /** * One page of documents, and whether there is another. * * The same envelope `sessions.list` returns, one collection over. `documents.list` was a bare * array — completeness asserted by omission — on the collection that only grows, so a client * could not tell a whole corpus from as much of it as it happened to get (OBE-190). */ export interface DocumentPage { items: DocumentOut[]; /** True when the next page has something in it. Never a guess — the server read a spare row. */ has_more: boolean; /** The `offset` that gets the next page, or null on the last one. Given rather than left as * arithmetic: `limit` is capped, so `offset + your limit` can be wrong. */ next_offset: number | null; /** Only when `with_total` was asked for — it costs a second query. */ total?: number | null; /** * How many documents this caller may not list at all, or `null` when it was not asked * for or the caller is not an operator. * * A document uploaded with `visibility: "self"` is readable by the exact uploader and * nobody else — not even an admin. That also makes it unlistable, so a project key's * answer to "how many documents does this project hold" is short by an amount it * otherwise cannot determine (OBE-277). * * A count is not a read: this names no filename, no owner and no text. It is answered * for an admin or a project key and is `null` for an end-user token, because to a * reader the same number would say how many private documents other people have. */ hidden?: number | null; } /** Why an ingest failed — see `DocumentOut.failure`. */ export type DocumentFailure = /** The project's own spend cap. Raise it, or wait for the window to reset. */ "usage_cap" /** The per-minute rate. Retry in a moment. */ | "rate_limit" /** A 429 whose body said neither — one of the two above, and the fixes are opposite, so * it names both rather than guessing. */ | "rate_limited" | "unsupported_type" | "unreadable" /** The embedder has no working credential — for a project with no embedding model of its * own, the platform's fallback is registered without one. */ | "embedding_model_missing" /** The model answered and the vector store refused its answer: the collection is built * for one width and the model returns another. NOT the model being unreachable — it * was reached and was right. Setting the embedding model again measures the width and * fixes it; documents that failed meanwhile need uploading again. */ | "embedding_dimension" | "embedding" | "vector_store" | "storage" | "unknown"; /** The HTTP status an ingest failure is reported as. * * Ingestion is asynchronous, so its failure has no status of its own and this library has * to choose one. It chose 422 for everything — the code `limits-and-errors.md` defines as * "invalid request body" — so a project that had merely run out of budget was told its * upload was malformed, and no client could separate "raise the cap and retry later" from * "this file will never work". A limit is a 429 wherever else it is met, including on * `/chat`, so it is a 429 here too. */ export declare function ingestFailureStatus(failure?: string | null): number; export interface RetrievedChunk { text: string; score: number; document_id: string; chunk_index: number; metadata: Record; } export interface RetrieveRequest { query: string; document_ids?: string[] | null; tags?: string[] | null; top_k?: number | null; top_n?: number | null; } /** OpenAI-format tool schema for client-executed (non-MCP) tools. */ export interface ClientTool { type: "function"; function: { name: string; description?: string; parameters: Record; }; /** This call is never handed over until a person has approved it. See * `ClientToolDef.requiresApproval` — this is the wire form. */ requires_approval?: boolean; } export interface ClientToolResult { tool_call_id: string; content: string; } /** A client-side tool: its JSON-schema + a handler the client runs when the agent * calls it. Register via `createClient({ tools })` / `client.registerTool(...)`, * or pass per-call. `chat.run` / `chat.stream` then dispatch and resume for you. */ export interface ClientToolDef { name: string; description?: string; /** JSON Schema for the arguments (the `parameters` of the OpenAI tool schema). */ parameters?: Record; /** Executed with the model-provided args. Return a string, or any JSON value * (it's JSON-stringified). Throwing is caught and returned as an error result. */ handler: (args: Record, call: PendingToolCall) => unknown | Promise; /** Never run this without asking a person first. * * A call to it comes back as an **approval**, not as a tool call, carrying `tool` and * `arguments` — so `onApproval` fires and auto-dispatch cannot reach the handler. Say * yes and the identical call arrives in `tool_calls` on the next round and runs * normally; say no and the agent is told it was refused and why refusing is the point. * * Distinct from the agent's own `request_approval`, which is the MODEL deciding to ask. * That is exactly as reliable as the model: a tool described in as many words as * "Cancel a booking. Irreversible." was measured being called straight through, twice, * with an `onApproval` handler that refused everything and was never consulted. This * gate fires because the tool was called, so there is nothing for the model to skip. * * Put it on the calls you would not want to explain: money moving, a message sent as * the user, anything deleted. Not on reads — a person asked to approve everything * approves everything. */ requiresApproval?: boolean; } /** One step on the agent's plan for a conversation. `number` is stable within the * session and never reused, so it keeps meaning the same step as items come and go. */ export interface TodoItem { id: string; number: number; content: string; status: "pending" | "in_progress" | "completed" | string; } /** One piece of work the agent handed to another agent running beside it. * * Small on purpose: a UI shows a line per subagent — what it is doing and how far * along — not a transcript. The subagent's working conversation is its own, and the * only part of it anyone else needs is `result` at the end. */ export interface SubagentState { id: string; /** The conversation this delegate is working in — read it with * `chat.sessions.messages(session_id)`. * * Note that `id` is NOT that: it identifies the delegate record and 404s on * `chat.sessions.*`, which is the first thing anyone debugging a delegation tries. * Before this field existed the only route was `chat.sessions.list({ includeSubagents: * true })` filtered on `parent_session_id` — which needs you to know the flag exists * and that the id in your hand is the wrong one. Null for the second or so before the * delegate's first turn has created its session. */ session_id?: string | null; /** The short handle the agent addresses it by, e.g. "a1". Stable per conversation. */ ref: string; /** What it was asked to do, in the main agent's words. */ task: string; model?: string | null; /** `delivered` means the main agent has been told; the work is over either way. */ status: "running" | "finished" | "failed" | "stopped" | "delivered" | string; /** Four or five words on what it is doing right now, written from its own actions * rather than asked of the model — so it stays live under load. */ progress: string; /** Agent↔tool steps taken. Use it for movement when `progress` does not change. */ steps: number; /** Set while it is blocked waiting for the main agent to decide something. The main * agent answers it; there is nothing for a client to do but show it. */ question?: string | null; /** Its report to the main agent. Empty until it has finished. */ result: string; error?: string | null; } /** What context management did to fit this turn's history into the model's window. * * Present only when the conversation did NOT fit as-is — which is exactly when it * matters, because it means the model was not shown the whole transcript. The * strategy is a project setting (Dashboard → Context management), not a per-call one. */ export interface ContextReport { /** What the PROJECT asked for (Dashboard → Context management). */ strategy: "trim" | "summarize" | "none" | string; /** What actually fitted the window. * * Usually the same as `strategy`, and the interesting case is when it is not: a * `summarize` policy resolved by a `trim` is a policy that could not run — see * `fallback`. These were one field, which made a summarizer that had just timed out * indistinguishable from a project that had chosen trimming. */ resolved_by: "trim" | "summarize" | "none" | string; /** Why the policy did not run, when it did not. `null` when nothing went wrong — * including on a `"step"` report, which never summarizes by design rather than by * failure. * * - `"summarizer_timed_out"` — the summarizer did not answer in time. An * infrastructure answer: the model behind it is too slow for the size of the * transcript, and an operator can raise the ceiling. * - `"summarizer_returned_nothing"` — it answered, with nothing usable in it. * - `"nothing_to_summarize"` / `"nothing_evictable"` — there was no prefix to compact, * so trimming is all there was to do. */ fallback: string | null; /** Which moment this describes. Absent on `ChatResponse.context`. * * `"done"` — the history being replayed was fitted before the turn ran. `"step"` — a * single model call inside the turn had to be fitted, which in practice means the turn * produced something too big for the window itself: a sandbox command's output, an MCP * tool's response. `"step"` never summarizes whatever the project's strategy says — * a model round trip per step of a tool loop is not a trade worth making — so it * reports `resolved_by: "trim"` and `summarized: false`. * * A `"step"` report is worth surfacing for the same reason a `"done"` one is: the * answer that follows was built from a cut view, and the agent has been told so. */ phase?: "done" | "step"; tokens_before: number; tokens_after: number; budget: number; /** Whose number `budget` is — `"token"`, `"project"` or `"model"`. * * `"token"` is the one worth branching on: a per-end-user ceiling * (`maxContextTokens` at mint) is bought deliberately, and until this existed there was * no way to confirm it had applied. A report now arrives for a token ceiling even when * nothing had to be cut, precisely so you can verify the tier you are selling before it * bites — the docs' own use case is "a cheap tier can be given a shorter memory without * its own project", and a probe that clamps a token, plants a marker and reads it back * sees the marker survive, because the first user message is protected by design. */ budget_from: "token" | "project" | "model" | string; /** How long the summarizer was allowed, how long it took, and which model was asked. * `null` unless a `summarize` policy actually ran. * * `fallback: "summarizer_timed_out"` names the failure and not the cause, and from * outside three causes look identical: a ceiling too tight for any model, a model too * slow for this transcript, or a summarizer that never finishes at any budget. These * tell them apart — and on a SUCCESSFUL compaction, `summarizer_ms` close to * `summarizer_timeout_s` is the warning that the next slightly longer conversation will * fall back to a trim. * * `summary_model` is the project's `summaryModel` when it set one, else the turn's own * model — worth knowing, because "your chat model is the wrong tool for summarizing * 96k tokens" and "raise the ceiling" are different fixes. */ summarizer_timeout_s: number | null; summarizer_ms: number | null; summary_model: string | null; /** Messages that left the window and whose content is IN the summary that replaced * them. Counted apart from `dropped` because they are the opposite outcome: "we * summarised the earlier part of this conversation" is not "we dropped 3 messages". */ compacted: number; /** Messages removed from the window with their content gone. */ dropped: number; /** Messages (usually tool outputs) kept but shortened. */ truncated: number; /** True when the evicted prefix was compacted into a summary. */ summarized: boolean; } /** Emitted when compaction starts, before the summarizer has produced anything. * `tokens_after` is not known yet — that arrives on the `done` frame. */ export interface ContextProgress { phase: "compacting"; strategy: "summarize" | string; tokens_before: number; budget: number; /** Images/audio/video being sent to the summarizer to be described before they go. */ media: number; } /** A picture of a page the agent has handed to the user, and what a click means. */ export interface BrowserFrame { url: string; title: string; /** data: URI, ready for an . */ image: string; width: number; height: number; /** Where the crop was taken from. Echo this back with a click. */ origin: { x: number; y: number; }; /** False when the whole viewport is shown. */ clipped: boolean; /** Whether the agent's OWN selector produced this crop. */ matched: boolean; /** `selector` — the agent's; `detected` — it matched nothing and the widget was found * anyway; `viewport` — neither, so this is the whole page. */ region: "selector" | "detected" | "viewport"; } /** One frame of the live view, as Chrome painted it. */ export interface BrowserStreamFrame { image: string; device_width?: number; device_height?: number; scroll_x?: number; scroll_y?: number; } /** Where to crop the live view, and why. Sent when it MOVES, not per frame — an * overlay appearing mid-gesture changes it, a repaint does not. */ export interface BrowserStreamClip { origin: { x: number; y: number; }; width: number; height: number; clipped: boolean; matched: boolean; region: "selector" | "detected" | "viewport"; url: string; } /** Whether the person seems to have finished with the page. * * `state` is one of four fixed words. That is a security property, not brevity: the * checker is looking at a CAPTCHA, and a closed vocabulary is what stops it being a * way to read one out. `checked: false` means no call was made (rate-limited, off, or * nothing handed over) — ask again later; it does not mean "not finished". */ export interface HandoffCheck { /** `gone` (the widget is no longer on the page) and `loading` (mid-navigation) are * decided from the page itself, without paying for a model call. */ state: "solved" | "gone" | "unsolved" | "loading" | "unclear"; done: boolean; /** False when no check was made at all. On its own this cannot tell you whether to * wait or to give up — see `will_check_again`, which is the field to branch on. */ checked: boolean; /** Whether polling can ever produce a check for this hand-off. * * `false` means stop and show a Done button: the deployment has auto-detection off, * the hand-off is not the kind that ends itself, or its check budget is spent. * `checked: false` used to be the only signal, so "ask again in a moment" and "this * will never be answered" were the same body — and a client doing what the docs said * polled a blocking hand-off for ever. */ will_check_again: boolean; /** Which way it was, when nothing was checked: `autodetect_disabled`, * `nothing_to_check`, `too_soon`, `checked_enough`. Absent when a check ran. */ reason?: string | null; } export interface BrowserStreamHandlers { onFrame: (frame: BrowserStreamFrame) => void; onClip?: (clip: BrowserStreamClip) => void; onError?: (message: string) => void; } /** A live view being watched. Call `close()` when the viewer goes away — the page * keeps painting for as long as anyone is listening. */ export interface BrowserStream { close: () => void; } /** The bit of `getBoundingClientRect()` the geometry needs. */ export interface HandoffRect { left: number; top: number; width: number; height: number; } /** What `attach()` needs of an element — satisfied by a real ``, and by anything * else shaped like one. Deliberately not `HTMLImageElement`: the published types would * then require a DOM lib in every consumer's tsconfig, including the server-side ones * that never open a viewer. */ export interface HandoffElement { getBoundingClientRect(): HandoffRect; parentElement?: { getBoundingClientRect(): HandoffRect; style?: any; } | null; naturalWidth?: number; naturalHeight?: number; src?: string; style?: any; addEventListener(type: string, handler: (event: any) => void, options?: any): void; removeEventListener(type: string, handler: (event: any) => void, options?: any): void; setPointerCapture?(pointerId: number): void; releasePointerCapture?(pointerId: number): void; /** Keyboard relay. An element only receives `keydown` if it can hold focus, so * `attach()` makes it focusable and focuses it when the user points at it. Optional * so a custom viewer that relays keys its own way is unaffected. */ focus?(): void; tabIndex?: number; } /** Everything needed to render the handed-over page, recomputed as frames arrive. * * Read it in `onView` and paint however you like — or hand an element to `attach()` * and never look at this at all. */ export interface HandoffView { /** False once the hand-off is over; the viewer should come off the screen. */ active: boolean; /** The page's address, for the caption. */ url: string; /** The picture to show, as a data URI. Empty until the first frame lands. */ image: string; /** True when frames are being pushed; false while showing the polled fallback. */ live: boolean; /** The region being shown, in page pixels, and where it was taken from. `origin` is * echoed back with every pointer event — never added to a coordinate here. */ origin: { x: number; y: number; }; width: number; height: number; /** False when the whole viewport is being shown rather than a region. */ clipped: boolean; /** True when the frame must be blown up and offset to show only the region — which * is what `style` does. */ cropping: boolean; /** CSS for the image and the window it shows through. Applied for you by `attach()`; * spread onto your own elements if you render the picture yourself. */ style: { image: Record; window: Record; }; /** False = they may look and not touch. Enforced server-side as well. */ interactive: boolean; /** A "did that finish it?" check is in flight — say so, rather than letting the * viewer vanish under them a moment later with no explanation. */ checking: boolean; /** The viewer holds keyboard focus, so typing reaches the page. * * Worth rendering. A picture of a login form gives a person no way to tell whether * their keystrokes are going to it or to the app around it, and the failure is * silent in the worst way: they type a password into nothing. Show a focus ring, or * say "click the page to type". */ focused: boolean; /** True while this hand-off can still end itself. Goes false when the server says it * will never check again — the moment to make the Done button the obvious thing on * screen rather than leaving a spinner that will not resolve. */ autoDone: boolean; /** The hand-off as the agent announced it (reason, blocking, auto_done…). */ handoff: Handoff; } export interface HandoffOptions { /** Called whenever anything about the view changes — a frame, a clip, a check. */ onView?: (view: HandoffView) => void; /** The hand-off is over. `resume` is true when the turn stopped for it and is waiting * to be continued: send `handoff_done: true` (via `chat.handoffDone`, or your own * streamed turn if you render one). Deliberately yours to do — the SDK owns the * viewer, not the conversation. */ onEnded?: (info: { resume: boolean; }) => void; /** Frame/relay/check failures. Never thrown: a viewer that dies on a dropped frame is * worse than one that misses it. */ onError?: (message: string) => void; /** How long after a gesture ends before asking whether it finished the job. A check * fired the instant a drag ends asks about a widget still animating. Default 700ms. */ settleMs?: number; /** Minimum gap between relayed pointer moves. A pointermove handler fires far faster * than any network will carry. Default 16ms (~60/s). */ relayMs?: number; /** How long to wait for the stream to paint before fetching one frame the slow way, * so a viewer shows something even if the stream cannot be opened. Default 1500ms. */ fallbackMs?: number; } /** A live hand-off: the stream, the geometry, the gestures and the finished-check. */ export interface HandoffController { /** The current view. Also delivered to `onView` as it changes. */ readonly view: HandoffView; /** Paint into this element and relay what the user does to it. Returns a detach * function; call it when the element goes away (a re-render, an unmount). */ attach: (element: HandoffElement) => () => void; /** The agent re-announced the hand-off. Keeps the stream when it is still the same * view — each announcement is a new object, and re-opening on every one blanks the * picture mid-gesture. */ update: (handoff: Handoff) => void; /** Relay one thing the user did. `selector`, `origin` and `want_frame` are filled in; * for a custom viewer, send `x`/`y` in the image space `view` describes. */ send: (input: BrowserInputEvent) => Promise; /** Type into the page, then (optionally) press Enter — what a text box in your UI * should call. */ type: (text: string, opts?: { submit?: boolean; }) => Promise; key: (key: string, modifiers?: string[]) => Promise; scroll: (pixels: number) => Promise; /** A gesture ended: schedule the "did that finish it?" check. `attach()` calls this * on release; call it yourself if you relay gestures your own way. */ settled: () => void; /** Finish the hand-off — the "I'm done" button. Fires `onEnded`. */ done: () => Promise; /** Stop watching without ending anything: the viewer went away, the turn did not. */ close: () => void; } /** A page the agent has put in front of the user. */ export interface Handoff { active: boolean; /** The turn STOPPED and resumes when they say they're done — same shape as a pending * client tool call, because it is the same situation. Render a "Done" button and * send `handoff_done: true` when it's pressed. */ blocking: boolean; /** False = show it, don't let them touch it. Enforced server-side too. */ interactive: boolean; /** Poll `handoffCheck` after each release and press Done when it reports finished — * someone who has just passed a bot check should not then have to report that they * passed it. False when the agent gave nothing to judge against, when it wants * telling, **or when this deployment has auto-detection switched off** — in which case * nothing would ever answer and polling is the wrong loop to start. * * Still stop polling when a check comes back with `will_check_again: false`: the * budget for a single hand-off is finite, so a long session ends up there even when * this was true to begin with. */ auto_done: boolean; reason: string; url: string; selector: string; clipped: boolean; } /** One thing the user did, relayed into the page. * * The pointer kinds are what make a hand-off able to do more than tap: press, move, * release IS a drag, which is what a slider puzzle asks for. Stream `pointer_move` * from a real pointermove handler and the page sees the gesture the person actually * made, path and all. */ export interface BrowserInputEvent { type: "click" | "pointer_down" | "pointer_move" | "pointer_up" | "drag" | "wheel" | "type" | "key" | "scroll"; /** Keep the same crop the agent handed over, so the view doesn't jump. */ selector?: string; x?: number; y?: number; origin?: { x: number; y: number; }; text?: string; key?: string; pixels?: number; /** Held for this input: "Shift" | "Control" | "Alt" | "Meta". */ modifiers?: string[]; button?: "left" | "right" | "middle" | "back" | "forward"; /** 2 is a double-click, 3 a triple — a selection gesture, not three clicks. */ clicks?: number; /** `drag`: where it ends, same image space as x/y. */ to?: { x: number; y: number; }; /** `drag`: how long the path takes. A drag with no duration is a teleport. */ hold_ms?: number; steps?: number; /** `wheel`: for panes that scroll under the pointer rather than the window. */ delta_x?: number; delta_y?: number; /** Set FALSE when watching `browserStream` — otherwise every pointer move renders a * full JPEG server-side that arrives after the stream has already shown it. */ want_frame?: boolean; } export interface QuestionOption { label: string; description?: string | null; } /** One decision the agent put to the user. Render `options` as a picker — * checkboxes when `multi_select`, radio otherwise — and always offer two escapes * the agent cannot take away: a free-text answer, and declining to answer at all * (`chat_instead`), which tells the agent to drop the question and keep talking. * * `options` is empty when the answer is genuinely open-ended: render a text input. */ export interface AgentQuestion { id: string; /** Very short label — the tab/chip for this question in a multi-question batch. */ header: string; question: string; options: QuestionOption[]; multi_select: boolean; /** Always true today: "type something else" is never withheld. */ allow_other: boolean; } /** A paused `ask_user` call: what was asked, and the id that answers it. */ export interface PendingQuestions { tool_call_id: string; questions: AgentQuestion[]; } export interface QuestionAnswerItem { /** The question's `id` (or `header`). Omit for a single question, or when the * answers are in the order they were asked. */ question_id?: string; /** What the user picked: an option's `label`, or the `QuestionOption` itself. * * Both, because the options you are handed are objects and `selected: [q.options[0]]` * is the natural thing to write — and it was a 422 (`Input should be a valid string`) * telling you nothing about `.label`. The server unwraps an object to its label, so * an `onQuestion` handler that returns the option it was given now works, which is * what "pass an `onQuestion` handler and the pause is answered for you" claimed. * * Anything not among the offered options is reported to the agent as text the user * typed, not as a selection. */ selected?: (string | QuestionOption)[]; /** The "type something else" answer. May accompany selections. */ text?: string | null; } /** Resumes a turn paused on a question. Set `chat_instead` when the user declined * the picker — the agent is told to drop the question rather than re-ask it. */ export interface QuestionAnswer { /** The paused call. Optional when exactly one question is outstanding. */ tool_call_id?: string; answers?: QuestionAnswerItem[]; chat_instead?: boolean; /** What the user said instead, if anything. */ message?: string | null; } /** Answer a batch of questions. Return one entry per question (or a single * `{ chat_instead: true }` to decline the whole batch and keep chatting). */ /** An Agent Plugin visible to this token: what the project published, plus anything * this end-user uploaded. */ export interface PublishedPlugin { id: string; name: string; version?: string | null; description: string; /** `tenant` = published by the project to everyone; `private` = this user's own. */ visibility: string; owner_subject?: string | null; enabled: boolean; size_bytes: number; /** Each skill's name and description are all the agent sees until it loads one, so * the description is the line that decides whether a procedure ever gets used. */ skills: { name: string; description: string; files: string[]; }[]; mcp_servers: string[]; /** Wrong but not fatal — a skill that would not parse, a server that was refused. * Worth surfacing: a plugin whose skill silently did not load is the failure people * spend an afternoon on. */ warnings: string[]; } /** A component your app can render, declared for the agent to call. * * The difference from a client tool is that there is no result. A client tool is work * handed back — the turn stops, you run it, the answer returns. This is a one-way * instruction: the agent calls it, your `render` runs, and the turn carries straight * on. Use it wherever showing beats describing. */ export interface UiComponent> { name: string; /** What it shows, and when the agent should reach for it. The agent picks from this, * so "Draw a line chart of a numeric series over time" beats "chart". */ description: string; /** JSON Schema for the arguments, exactly as a client tool declares them. */ parameters?: Record; /** Called when the agent draws it. Return nothing — anything you return is dropped, * because the agent is not waiting. */ render: (args: A) => void; } /** One thing the agent drew, as it comes back on the turn. */ export interface UiRender { name: string; args: Record; } /** Something the agent stopped to ask permission for. * * Arrives with `requires_action` and an EMPTY `tool_calls`: an approval is not the * client's work to execute, so tool auto-dispatch can never grant one on the user's * behalf. It has to reach a person. */ export interface PendingApproval { tool_call_id: string; /** One line saying exactly what the agent will do, with the specifics in it. The * model's own words when it asked; the tool's description when the tool demanded it. */ action: string; /** What the person needs in order to decide — the message, the command, the rows. For a * gated call, its arguments: "cancel NW-3009" is the decision, "cancel a booking" is * the category. */ detail?: string | null; /** What cannot easily be undone. Usually the only part that decides the answer. */ consequence?: string | null; /** The call being held back, when this pause came from a tool marked * `requiresApproval`. Null when the agent asked of its own accord via * `request_approval` — it is describing work it has not started, so there is no call. * * Approve it and the identical call arrives in `tool_calls` on the next round — for a * tool of YOURS, which you run. A **webhook tool** the project gated is called by * Oberik instead, so approving one produces no `tool_calls` and the next round simply * carries on with its result: the pause looks the same to you, and there is nothing * for you to execute. */ tool?: string | null; /** That call's arguments, coerced to the types you declared — enough to render your own * confirmation instead of the generic one. Null alongside a null `tool`. */ arguments?: Record | null; } /** A person's answer to one approval pause. */ export interface ApprovalDecision { tool_call_id?: string; approved: boolean; /** The difference between "no" and "no, use the other address". */ note?: string | null; } /** Put the request to a person and return their decision. * * Throwing, or returning `false`, is a refusal — and a refusal stops the work: the * agent is told not to try another way and to ask what you want instead. */ export type ApprovalHandler = (request: PendingApproval) => ApprovalDecision | boolean | Promise; /** Answer the agent's question. A bare string is the whole answer — the shorthand, like * `true` for an approval; the fuller shapes are for several answers, or for handing the * question back to the user with `{ chat_instead: true }`. * * Returning something else THROWS rather than being ignored: a handler whose return value * went nowhere left the agent asking twice and then giving up, with nothing saying why. */ export type QuestionHandler = (pending: PendingQuestions) => string | QuestionAnswer | QuestionAnswerItem[] | Promise; export interface RunOptions extends Omit { /** Extra client tools for this call (merged over any registered on the client). */ tools?: ClientToolDef[]; /** UI components for this call (merged over any registered on the client). Needs the * `ui_tools` capability. */ ui?: UiComponent[]; /** Max auto tool-dispatch rounds before giving up (default 10). */ maxToolRounds?: number; /** Fires before each batch of client tools is executed. */ onToolCalls?: (calls: PendingToolCall[]) => void; /** Answer the agent's questions and continue the turn automatically. Without it, * `run` returns as soon as the agent asks, with `questions` set. */ onQuestion?: QuestionHandler; /** Decide the agent's approval requests and continue the turn. Without it, `run` * returns as soon as the agent asks, with `approvals` set — which is the right * default: nothing should be able to approve an irreversible action by accident. */ onApproval?: ApprovalHandler; signal?: AbortSignal; } /** The handler half of `RunOptions`, so `chat.run(req, handlers)` — the shape * `chat.stream` takes — works instead of being silently ignored. */ export type RunHandlers = Pick; /** A non-text input part. `url` is a data: URI or an https URL. * * Two capabilities decide what happens to it, and they are separate on purpose: * * - `input:file` accepts ANY attachment — a PNG and a WAV included. What the model * cannot perceive is read as text instead (OCR for a scan, a transcript, an * extraction), so granting it costs fidelity, never access. * - `input:image` / `input:audio` / `input:video` additionally let the model look at * that kind with its own senses, raw. * * So a project that just wants users to attach whatever they have grants `input:file`; * one that wants the model to actually SEE the picture grants `input:image` too. */ export interface Attachment { /** Stable identity, set on anything the agent produced. Match on this, not on `url`: * a URL is signed with an expiry, so it is a handle with a lifetime rather than an * identity — do not persist one and do not compare two. It is also the handle to pass * to a tool that takes a file. * * Stated precisely, because the reason used to be stated wrongly. Every response * re-signs, but an S3 signature is deterministic given the same key, expiry and * `X-Amz-Date` — and that timestamp is granular to the second. So the mid-turn * `attachments` event and the response that follows usually carry a byte-identical * URL, and carry different ones when they land either side of a second tick. Keying * on the URL therefore works nearly every time and duplicates the file occasionally, * which is the hardest version of this bug to ever see reported. */ id?: string; kind: "image" | "audio" | "video" | "file" | string; url: string; mime_type?: string | null; name?: string | null; format?: string | null; /** Set on outputs (e.g. a file the agent exported from its sandbox). */ size?: number | null; /** Generated/exported media is stored server-side and `url` is signed fresh on each * response — download it or show it, but don't persist the URL, it expires. */ /** Input only: hand the ORIGINAL bytes to the sandbox instead of showing the file * to the model. Needs the `computer` capability; the model is told the path it * landed at. Use for data files the agent should process with commands. */ to_sandbox?: boolean; } /** What this deployment's sandboxes are and what they can do. The sandbox host is the * deployment's, not the project's: there is no backend to configure. */ export interface ComputerHost { provider: "firecracker" | "cloudflare" | "e2b" | string; /** pause / snapshot / expose_port / network_policy. */ capabilities: Record; workdir: string; /** Applied when a command names no timeout of its own. */ exec_default_timeout_s: number; /** Hard ceiling: past this a command is killed. */ exec_max_timeout_s: number; max_sessions_per_tenant: number; } export type ComputerSessionStatus = "creating" | "running" | "paused" | "stopped" | "failed" | string; /** A sandbox. `id` is stable across pause/resume — pass it as `computer_session_id` * on a later turn to reattach the agent to this workspace. */ export interface ComputerSession { id: string; name?: string | null; provider: string; /** The backend's own sandbox id. */ external_id: string; status: ComputerSessionStatus; workdir: string; chat_session_id?: string | null; error?: string | null; last_used_at?: string | null; expires_at?: string | null; created_at: string; } export interface ComputerExecResult { stdout: string; stderr: string; exit_code: number; truncated: boolean; timed_out: boolean; duration_ms?: number | null; } export interface ChatRequest { session_id?: string | null; message?: string | null; /** Additional user messages for this turn, in order — someone who kept typing while * the agent was busy. Each is stored as its own message and the agent replies once * to all of them, so the transcript shows what was actually said rather than one * merged prompt. */ messages?: string[] | null; /** Multimodal inputs (images/audio/video/files) for this turn. */ attachments?: Attachment[] | null; /** Modalities the MODEL may generate this turn, e.g. ["text","image"]. Bounded by the * token's `output:` capabilities; ignored by text-only models. * * Nothing to do with what the agent may hand over as a file: a screenshot it took or * a chart it rendered in a sandbox is a file it produced, not a modality it emitted, * and `output:file` is what permits sending those — any of them, pictures included. */ output_modalities?: string[] | null; tool_results?: ClientToolResult[] | null; /** Resume a turn paused on `ask_user`. The wording the agent reads is composed * server-side from the question it actually asked, so the transcript can't drift * from what was on screen. */ question_answers?: QuestionAnswer[] | null; /** UI components the agent may draw this turn. `chat.run`/`chat.stream` fill this in * from the components you registered — set it directly only on a raw `/chat` call. */ ui_tools?: Record[] | null; /** Answers to approval pauses, resuming the turn. */ approval_decisions?: ApprovalDecision[] | null; /** Let the agent stop and ask permission before an action with consequences. Needs * the `approvals` capability. Set false for any caller that cannot answer — a batch * job, a webhook — so the agent never stalls waiting on a decision. */ enable_approvals?: boolean; /** What anything this turn creates may do when it wakes the agent later with nobody * connected — a reminder it schedules, a background command it starts, a delegate it * hands work to. Capability strings, never wider than this token's (a wider one is a * 400); omitted means everything the token holds. Leave `approvals` out and a woken run * cannot pause for a permission nobody is there to give: tools that need approval are not * offered to it, and it is told so. */ unattended_capabilities?: string[]; /** Name this conversation. Needs the `auto_title` capability; ignored once it has a * name. */ enable_auto_title?: boolean; /** WHEN the name is written, when one is being generated at all. * * `"prompt"` (the default) names it from the user's opening message, alongside the * turn — the `title` event arrives while the answer is still streaming, so a sidebar * can show a real name straight away, and the name lands even if the turn then fails * or is cancelled. * * `"response"` waits for the first exchange instead. It names a little better, having * seen what the agent made of the question, and it arrives only with `done` — never * for a turn that errored, which is the case a chat list notices. */ title_from?: "prompt" | "response"; /** A name for the conversation, on the request that CREATES it — which skips * generation entirely. Ignored when continuing an existing session; rename one with * `chat.sessions.rename`. */ title?: string | null; /** Let the agent reach for the skills published to this project. Needs the `plugins` * capability. */ /** Let the agent call the tools this project publishes as URLs — Oberik POSTs to * the customer's endpoint with the end-user's identity signed into the body. Needs * the `webhook_tools` capability. Turn it off for a turn that should not touch your * systems. */ enable_webhook_tools?: boolean; /** Let the agent use its own conversation controls on a voice call — put the caller on * hold while something slow runs, then come back. Needs the `voice` capability. * * Nothing to do with whether a call can be OPENED: that is `voice.open()`, and it needs * the same capability. This is the per-turn switch, so a text turn in the same project * can decline the tools. They stay bound either way and refuse with a sentence saying * there is no call — a model that can see a tool it cannot use reasons better than one * whose toolset silently changed shape. */ enable_voice_control?: boolean; enable_plugins?: boolean; /** Which plugins this turn may use, by name. Omit for everything the token can see. * Narrowing only — a name that is not visible is absent rather than an error, so a * pinned list does not start failing turns the day someone deletes one. */ plugins?: string[] | null; /** The user pressed Done on a page the agent handed them. Resumes a turn that * stopped on a blocking hand-off — the button IS the answer, so nothing else is * needed with it. */ handoff_done?: boolean; client_tools?: ClientTool[] | null; /** End-user-supplied MCP servers for this turn, merged with the tenant's own * project-level servers. Needs the `mcp:manage` capability (the project's `allowMcp` * ceiling). * * Without it the turn is **refused with a 403** naming the capability — not silently * ignored, which is what this said for a while. The difference matters to whoever * writes the client: "ignored" tells you to expect a degraded-but-successful turn and * to write defensive code for a case that cannot happen. The refusal also says that the * project's own servers still load, so this is not a total loss of MCP for the turn. */ mcp_servers?: Array<{ name: string; url: string; transport?: "sse" | "streamable_http"; headers?: Record; }> | null; allowed_tools?: string[] | null; document_ids?: string[] | null; tags?: string[] | null; enable_rag?: boolean; enable_scheduling?: boolean; enable_memory?: boolean; enable_web_search?: boolean; /** Opt-in: bind only the most relevant tools when the action space is large. */ enable_action_space?: boolean; /** Let the agent run commands / edit files in an isolated sandbox. Needs the * `computer` capability and a configured backend; the sandbox is only provisioned * if the agent actually uses it. */ enable_computer?: boolean; /** Attach a specific existing sandbox to this turn (reconnect to a prior session). * Omitted = the sandbox bound to this chat session, else a fresh one. */ computer_session_id?: string | null; /** Let the agent drive a real browser — click, type, scroll, wait, capture — rather * than fetching one page at a time. Needs the `browser` capability and the browser * service; `web_search`/`browse_url` work without it. */ enable_browser?: boolean; /** Let the agent keep a todo list for this conversation. Needs the `todo` capability. */ enable_todo?: boolean; /** Let the agent hand a self-contained piece of work to another agent that runs * alongside it. Needs the `subagents` capability AND models chosen for it in the * dashboard (Project → LLM & limits → Subagent models). * * Turning it off stops new delegation; it does not abandon work already running — * a subagent started earlier still reports back into this conversation. */ enable_subagents?: boolean; /** Let the agent pause and ask the user a structured multiple-choice question. * Needs the `ask_user` capability. Set false for any caller that cannot answer — * a batch job, a webhook, a scheduled task — so the agent never stalls waiting. */ enable_ask_user?: boolean; model?: string | null; /** Reasoning effort (minimal|low|medium|high), bounded by the token's max_effort. */ reasoning_effort?: "minimal" | "low" | "medium" | "high" | null; system_prompt?: string | null; temperature?: number; user_ref?: string | null; } export interface PendingToolCall { id: string; name: string; args: Record; } /** One sentence, and the passages it came from. * * `citations` is still the whole retrieval set — a client may want to show what was * searched — but this is what answers "where did THAT come from". Before it existed the * docs promised a citation "for every claim" and delivered the retrieval set in score * order, so a question whose answer lived in one chunk came back with three "sources" and * a UI built as instructed showed the reader citations that did not support the sentence. */ export interface Claim { text: string; /** Offsets into `content`. Null when the claim could not be located after markers were * stripped — better absent than pointing at the wrong span. */ start: number | null; end: number | null; /** `Citation.marker` values, in the order the model wrote them. */ citations: number[]; } /** One piece of an answer, ready to render. See `renderCited`. */ export interface CitedSegment { /** The prose. Render it as-is. */ text: string; /** The pills that belong at the END of this segment, in the order the model wrote them. * Empty for a segment the model did not attribute — most of a normal answer. */ citations: Citation[]; } /** * An answer split into segments, each carrying the pills that belong after it. * * The offsets to do this yourself are all in `claims` and `citations`, and every client * was writing the same loop over them — sort the claims, walk the string, resolve marker * numbers, keep the gaps, and get `start`/`end` right after markers were stripped. That is * four ways to be subtly wrong about where a footnote goes, and pointing one at the wrong * sentence is the failure this whole area exists about. * * ```tsx * {renderCited(done).map((seg, i) => ( * * {seg.text} * {seg.citations.map((c) => )} * * ))} * ``` * * Every segment of the content is returned, attributed or not, in order — concatenating * `text` reproduces `content` exactly. A turn with no attribution comes back as one * segment with no citations, so a renderer needs no special case for it. * * A marker the model wrote that no citation carries is dropped rather than rendered as a * dead pill: the server already refuses to accept an invented number, and a pill that * resolves to nothing is worse than no pill. */ export declare function renderCited(turn: { content?: string; claims?: Claim[]; citations?: Citation[]; }): CitedSegment[]; /** Where a citation came from. Provenance is a field, not a different array. */ /** What a citation points at. `kind` describes the EVIDENCE, not the tool that produced * it — a page fetched with `browse_url` and one driven to with the browser are both `web`. * * `file` is a path in the agent's sandbox, and it is citable for the same reason a * document passage is: the reader can ask for that file, and the agent can hand it over. * Command output is deliberately NOT citable — it is gone once it scrolls past, so nobody * can go and check it, and a long loop would bury the register. */ export type CitationKind = "document" | "web" | "memory" | "wiki" | "tool" | "preview" | "file"; /** * One thing the turn was shown that a sentence can be attributed to. * * **One list, one numbering, whatever it came from.** There were five mechanisms and only * the first could be footnoted: a document passage got a number; a web page and an MCP * result went to a separate `sources` array no `Claim` could point at; a memory was * deliberately not evidence; a sandbox computation was grounding for the guardrail and * invisible to the reader. So the only answer you could render footnotes for was one that * came out of the corpus — on a product whose pitch is an agent that also searches the web, * remembers, reaches your own systems and computes. * * Render `title`, `quote` and `url`. Branch on `kind` only for the extras you want: a page * number for a document, the tool's name for a result, "you told me this" for a memory. * `renderCited` turns content + claims + these into the segments a pill sits at the end of. */ export interface Citation { /** The number the model was shown, and what a `Claim` refers to. 1-based. */ marker?: number | null; kind?: CitationKind | string; /** What to put on the pill. */ title?: string | null; /** Where it can be opened, when that means anything — a page, an exposed port. */ url?: string | null; /** What was shown to the model, truncated for display. Not what a grounding check * reads: that is the whole passage, and it stays server-side. */ quote?: string | null; /** Whether a sentence was actually attributed to it. The whole register comes back — * you may want to show everything that was looked at — and this says which of it * did any work. */ used?: boolean; /** `kind: "document"`. */ document_id?: string | null; chunk_index?: number | null; filename?: string | null; page?: number | null; score?: number | null; /** `kind: "tool"` — which tool produced it. */ tool?: string | null; /** `kind: "wiki"` — out of date relative to a document it was written from. */ stale?: boolean | null; /** `kind: "web"` — screenshot (data URL) of a page whose text could not be extracted. */ screenshot?: string | null; } export interface ChatResponse { session_id: string; content: string; requires_action: boolean; tool_calls: PendingToolCall[]; /** The agent paused to ask the user something. Arrives with `requires_action` and * an EMPTY `tool_calls`: a question is not the client's work to execute, so tool * auto-dispatch can never answer it on the user's behalf. Resume by sending * `question_answers` (or let `chat.run`/`chat.stream` do it via `onQuestion`). */ questions: PendingQuestions[]; /** The agent stopped before doing something and is waiting to be allowed. Same shape * of pause as `questions`, and likewise never in `tool_calls`. Resume by sending * `approval_decisions` (or let `chat.run`/`chat.stream` do it via `onApproval`). */ approvals: PendingApproval[]; /** The agent's plan for this session, when the todo family ran this turn. */ /** The plan as it stands NOW, not a record of the turn. * * An agent that tidied up with `todo_clear` at the end of its work returns `[]` here, * even though it streamed six items ticking off — so a client that re-renders from * `done` blanks the plan it just showed. That is the truth (the list really is gone), * but if you want the history, keep what the `todo` stream events gave you rather * than replacing it from `done`. */ todos: TodoItem[]; /** Every subagent of this conversation, including ones still working — a turn ending * is not a reason to stop showing work that has not. Empty unless it has delegated. */ subagents: SubagentState[]; /** Set only when history had to be trimmed or summarized to fit the window. */ context?: ContextReport | null; citations: Citation[]; /** Sentences attributed to a passage, in order. Empty when the model emitted no markers, * which `attribution` reports rather than hiding. */ claims: Claim[]; /** `per-claim` · `retrieval-only` · `none`. Draw footnotes from `claims` on the first; * on the second you have the retrieval set and no attribution, which is a different * thing to show. */ attribution: "per-claim" | "retrieval-only" | "none"; /** Set when the agent put a page in front of the user. Declared here as well as on * `ChatDone`: a `chat.send()` caller could not learn a hand-off had happened at all, * while the docs say the blocking path carries `handoff.blocking`. */ handoff?: Handoff | null; /** Non-text outputs of the turn: generated media, and files the agent exported * from its sandbox. Each `url` is signed with an expiry and re-signed per response — * don't persist one, and don't compare two (see `Attachment.id`). */ attachments: Attachment[]; /** Components the agent drew this turn, in call order. `chat.run`/`chat.stream` have * already called each component's `render` by the time you see this; it is here so a * client that re-mounts can redraw from the list. */ ui: UiRender[]; /** Guardrail actions taken this turn, e.g. "pii:EMAIL", "ungrounded", "injection". */ guard_flags: string[]; /** Per-turn switches this project could not honour, one sentence each. Empty on an * ordinary turn. * * `capabilities()` reports a blocked capability and is the right place to ASK — but you * set `enable_action_space: true` once at integration time and never ask again, and that * flag degrades to "every tool bound" rather than to "no tools", so there is no absence * to notice. Worth logging: each entry names the cause, what it costs on this turn, and * the setting that fixes it. */ warnings?: string[]; /** Which model answered, in the friendly form — and the tier that chose it, when the * project's per-request model routing did. * * `model_tier` is null when you named a model or the project routes everything to one * default, so it says "this was chosen for you" rather than "this is how hard we think * your question was". Read it while tuning: a routing mistake and a model being bad at * something are indistinguishable without it, and an operator who has just switched * routing on has no other way to find out that every request is being called * `complex`. */ model?: string | null; model_tier?: "simple" | "normal" | "complex" | null; /** The stored message holding this answer — what you pass to `chat.sessions.rate` to * record a thumbs up or down on it. `null` when the turn answered nothing. */ message_id?: string | null; /** A reasoning model's thinking for this turn, else "". Live only — it is not stored * and is never sent back to the model, so it won't appear in session history. */ reasoning?: string; /** * Non-normal stop reason, else null. Language-neutral — localize it yourself. * * `max_tool_iterations` a token's tool-loop ceiling was hit; `content` is the answer * produced so far * `guardrail` an input guardrail refused the message * `no_model` the PROJECT has no model configured, so nothing could run. * Not the end-user's problem and not something they can fix — * route it to whoever set the project up. `GET /readiness` on * the control plane lists what is missing. * `subagents_incomplete` the turn ended while work it had DELEGATED was still * running, so part of the answer may be missing. The turn is * over and nobody is waiting — but do not render it as a * finished result. `subagents[]` says which are still going. * `no_answer` the turn is over and produced nothing: no prose, no file, * nothing to pause on. Rare, and not the user's fault — a * provider dropping a message on a filter of its own is the * usual cause. Offer a retry rather than rendering an empty * reply as the answer. * * `max_tool_rounds` set by THIS CLIENT, not the server: `chat.run`/`chat.stream` * answered `maxToolRounds` pauses and stopped. Handle it like * `max_tool_iterations` — the same loop, the other ceiling. * It used to throw, which discarded the content and the * `session_id` with it; now the turn comes back and * `session_id` is what you resume from. */ finish_reason?: string | null; /** Mid-turn corrections the agent actually read, in the order they arrived. Empty on * every ordinary turn — see `ChatDone.steered`. */ steered?: string[]; /** The conversation's name, or null. * * Null is the ordinary state: a project that has not switched titles on, and a caller * that set none, gets no name rather than one derived from whatever the first message * happened to be. Comes back on the turn that produced it, so a sidebar can label the * row it just created without a second request. */ title?: string | null; } /** How a conversation's last turn ended — the word `SessionOut.status` carries and the * `status` filter takes: * * - `error` — it failed; `last_error` says why. * - `stopped` — it ended short of an ordinary answer: a tool ceiling, a guardrail, delegates * still running, a pause waiting on a person. `last_finish_reason` says which. * - `empty` — no turn has reached a model yet. * - `ok` — an ordinary ending. * * Any other value is refused with a 422 rather than ignored. */ export type SessionOutcome = "error" | "stopped" | "empty" | "ok"; export interface ProjectSessionQuery { /** Substring of an id, a title, a user or a model. Applied server-side. */ q?: string; /** Exact match on what the last turn ran on. */ model?: string; /** The last turn's outcome. */ status?: SessionOutcome; /** Narrow to one end-user. */ userRef?: string; /** ISO timestamps, over `updated_at`. */ since?: string; until?: string; /** Include subagents' own working conversations. Off by default; their rows carry * `parent_session_id`. */ includeSubagents?: boolean; /** Defaults to 100, capped at 500 server-side. */ limit?: number; /** Feed `next_offset` back in to page. */ offset?: number; } export interface SessionPage { items: SessionOut[]; /** True when the next page has something in it. Never a guess — the server reads one * row more than it returns. */ has_more: boolean; /** Pass as `offset` for the next page; `null` when this is the last one. */ next_offset: number | null; } export interface SessionOut { id: string; title: string | null; user_ref: string | null; created_at: string; updated_at: string; /** What the most recent turn ran on. `null` for a conversation with no turns yet. */ last_model: string | null; /** Why the most recent turn failed, scrubbed — `null` when it didn't. This describes * the LAST turn, not the history: a conversation that errored and then recovered is * not one anybody is looking for. */ last_error: string | null; /** Why the last turn stopped, when it stopped for a reason worth naming — a tool * ceiling, a guardrail, delegates still running. `null` is the ordinary ending. */ last_finish_reason: string | null; /** Turns that reached a model. Zero separates a real conversation from the empty * session a page-load creates. */ turn_count: number; /** How the last turn ended, as the word a badge and the `status` filter both use — * derived by the server, so a client never re-derives it from the nullable fields. */ status: SessionOutcome; /** The conversation that DELEGATED this one, when it is a [subagent's](./subagents.md) * working transcript rather than somebody's chat. `null` for an ordinary conversation, * which is almost all of them. * * `list()` leaves these out unless you ask (`includeSubagents`): a delegate's * conversation is one nobody had, and a sidebar built from this route grew a row per * delegate — three per turn on a project allowing three at once. This field is here so * a client that DOES ask can tell them apart, and so you can get from a delegate back * to the turn that started it. */ parent_session_id?: string | null; } export interface MessageOut { id: string; role: "user" | "assistant" | "tool" | "system" | string; /** Can legitimately be `""`. * * A turn is stored as several assistant rows, and one that only made tool calls has no * prose — the tool calls are in `extra.tool_calls`. A UI that renders this list * straight through draws a blank bubble for every tool-calling step. Skip a row whose * content is empty and whose `extra.tool_calls` is not, or render it as an activity * line. */ content: string; /** `tool_calls` on an assistant row, `attachments` on one that produced files, and * `model` — which model wrote it — on an assistant row. */ extra: Record; created_at: string; /** Your own vote on this message, so a reloaded conversation shows the thumbs you * already pressed. Only ever yours; per-model totals are the project's `feedback`. */ feedback?: FeedbackRating | null; } /** A person's verdict on one answer. */ export type FeedbackRating = "up" | "down"; export interface FeedbackOut { message_id: string; rating: FeedbackRating; /** The model that wrote the answer. `null` for an answer from before models were * recorded on messages — never guessed from what the conversation runs now. */ model: string | null; comment: string | null; updated_at: string; } export interface ModelVotes { model: string | null; up: number; down: number; /** `up / (up + down)`; `null` with no votes, because zero votes is not 0% approval. */ approval: number | null; } export interface FeedbackSummary { since: string | null; until: string | null; /** Most-voted first. */ models: ModelVotes[]; } export type TaskKind = "once" | "recurring"; export interface TaskAction { type?: "agent" | "webhook"; session_id?: string | null; prompt?: string | null; system_prompt?: string | null; model?: string | null; callback_url?: string | null; url?: string | null; method?: string; headers?: Record; payload?: Record; } export interface TaskCreate { name: string; kind: TaskKind; run_at?: string | null; cron?: string | null; interval_seconds?: number | null; timezone?: string; action: TaskAction; } export interface TaskOut { id: string; name: string; kind: TaskKind; /** `scheduled` · `completed` · `incomplete` · `failed` · `cancelled`. * * `incomplete` is a one-shot whose run happened, did not error, and did not do the work * — see `last_finish_reason` for which way. It used to be reported as `completed`. */ status: string; created_by: string; run_at: string | null; cron: string | null; interval_seconds: number | null; timezone: string; action: Record; run_count: number; last_run_at: string | null; next_run_at: string | null; last_error: string | null; /** The conversation the last run wrote its answer into, for an `agent` action. * * This is what makes polling work. `action.session_id` is the session you ASKED for, so * a task that named none has null there forever — and a completed run used to report * `run_count: 1` and no way at all to reach what it produced. Read the messages with * `chat.sessions.messages(last_session_id)`. Null before the first run, for a `webhook` * action, and after a run that failed. */ last_session_id: string | null; /** How the last run's turn ended, in the same words `turn.stopped` uses — `needs_input`, * `max_tool_iterations`, `no_answer`, `guardrail`, `subagents_incomplete` — and null when * it simply finished. On a one-shot, anything non-null means `status` is `"incomplete"` * rather than `"completed"`. * * Read it before you treat a run as done. A scheduled run that called an approval tool * with nobody there to answer used to report `status: "completed"`, `run_count: 1`, * `last_error: null` — indistinguishable from a run that did the work, when the work is * the whole point of the run. `last_error` still means what it always did: something * broke. This means the run happened and the work did not. */ last_finish_reason: string | null; created_at: string; } /** A project's guardrail policy, complete — an untouched field reads as its default * rather than as missing. */ export interface GuardrailPolicy { enabled: boolean; input: { injection: boolean; blockedTopics: string[]; pii: PiiMode; }; output: { groundedness: boolean; moderation: boolean; pii: PiiMode; }; /** Which model judges. Null = the turn's own model. */ guardModel: string | null; /** `block` refuses the turn; `flag` allows it and annotates `guard_flags`. */ onViolation: "block" | "flag"; /** Whether a refused end-user is told WHY, or only that they were refused. */ explainRefusals: boolean; } /** A webhook tool as the API returns it — never with its secret, which is shown once. */ export interface WebhookToolOut { id: string; name: string; description: string; url: string; parameters: Record; headers?: Record; } /** Off is the default and deliberately so: on a product where an end-user shares their own * information on purpose, the model has to see it. `detect` records that it was there; * `redact` keeps it from the provider and costs the agent the ability to use it. */ export type PiiMode = "off" | "detect" | "redact"; /** Flat, because that is what a form sends. Anything omitted is left unchanged. * * NOT the shape `guardrails.get()` returns — that one is nested. Passing it back is a * 400 naming the flat fields to send instead (it used to be a silent no-op that * answered 200 with the unchanged policy). Note `input.pii` reads back as `inputPii`. * * `guardModel: null` clears it; omitting it leaves it alone. */ export interface GuardrailUpdate { enabled?: boolean; injection?: boolean; blockedTopics?: string[]; inputPii?: PiiMode; groundedness?: boolean; moderation?: boolean; outputPii?: PiiMode; guardModel?: string | null; onViolation?: "block" | "flag"; /** Append the reason to the refusal the END-USER sees. Off by default: a bare refusal * is a dead end on a false positive, but an explained one helps somebody probing the * filter. Your own logs get the reason on `guard_flags` either way. */ explainRefusals?: boolean; } /** What still has to happen before a project can answer a question. */ export interface ProjectReadiness { /** Can this project answer a question at all? **This is the deploy gate.** * * Separate from `ready` because they are different questions and used to share one * boolean: turning on an optional capability (subagents, say) added a step, and a * project that answered questions perfectly reported `ready: false` until that * capability was also configured — failing the deploy check the SDK recommends. */ canAnswer: boolean; /** Is EVERYTHING the project asked for finished? This is the setup checklist, and it * includes steps that only hold back one capability. */ ready: boolean; /** The first step to do — what blocks answering, before what merely blocks a * capability. One line of UI has something to render. */ next: ReadinessStep | null; steps: ReadinessStep[]; /** The badge Oberik itself renders for this project — the same value, from the same * function, as the dashboard card and the SSH `project` command. * * It was attached by three callers and by no route a project key could reach, so a * customer's own backend could not show its operators the status Oberik shows them, and * the only alternative was reimplementing it from `canAnswer`/`ready` — which drifts the * first time a step is added (OBE-197). */ health: ProjectHealthState; /** The sentence behind the badge: it leads with what still works and then names what does * not, conditional on the capabilities actually granted. Not reconstructable from the * booleans, which is why it is returned rather than left to be derived. */ healthDetail: string; } /** `down` — an essential step is outstanding, every request fails. `degraded` — it answers, * and something it asked for does not work. `ok` — every step is done. */ export type ProjectHealthState = "ok" | "degraded" | "down"; export interface ReadinessStep { id: string; done: boolean; /** True when this step stops the project answering AT ALL. `blocks` in a boolean: * gate a deploy on the essential ones, show the rest as a checklist. */ essential: boolean; /** What stops working while this is undone — "every request", "every document upload". */ blocks: string; what: string; /** The one call, or the one screen, that fixes it. */ how: string; } export interface ConnectInfo { projectId: string; tenantId: string | null; controlPlaneUrl: string; dataPlaneUrl: string; /** Copy-pasteable: the server half and the app half, using both clients correctly. */ snippet: string; } /** What a project key may do. An admin key satisfies a mint requirement, never the * reverse. */ export type ProjectKeyScope = "admin" | "mint"; export interface ProjectKeyInfo { id: string; name: string; scope: ProjectKeyScope; /** The visible stub, e.g. "pk_ab12…". The key itself is shown once, at creation. */ prefix: string; lastUsedAt: string | null; createdAt: string; } /** What a token holds, and which per-turn switches are worth setting on it. */ export interface TokenCapabilities { subject: string; scope: string; /** `null` = unrestricted (a service key), which is NOT the same as an empty list. */ capabilities: string[] | null; flags: { flag: string; /** Any one of these is enough to grant it. Empty = ungated. */ capability: string[]; granted: boolean; platform_enabled: boolean; /** Whether setting this flag on a request would change anything. */ effective: boolean; /** * Why it is granted and still worth nothing, in a sentence, or `null`. * * The fourth gate: infrastructure the capability needs and this project does not * have. Today that means an embedding model — `documents`, `memory` and * `action_space` all embed — and it is the only one of the four a customer can fix * themselves, which is why it is a sentence rather than a boolean. `action_space` * granted on a project with no embedding model reported `effective: true` and bound * every tool in the catalog on every turn. */ blocked: string | null; label: string; description: string; group: string | null; }[]; modalities: { /** What may be attached. */ input: string[]; /** What the model perceives directly; the rest arrives as extracted text. */ native: string[]; /** What the agent may hand back. */ output: string[]; }; limits: { max_effort: string | null; max_tool_iterations: number | null; max_context_tokens: number | null; }; /** The project's own tool allow-list, or `null` for "everything your capabilities * reach". The one gate on a turn that is not in your claims: it lives on the project, * applies before any request-level `allowed_tools`, and without it a client could not * tell a tool it may not call from a tool that does not exist. `tools()` already * answers the resulting question; this says why the answer is what it is. */ allowed_tools: string[] | null; } /** A URL that starts a turn when something happens in another system. */ export interface Trigger { id: string; name: string; prompt: string; enabled: boolean; /** Events that started a run. A sender's retry of the same event is not counted twice. */ fired_count: number; last_fired_at: string | null; last_error: string | null; /** Absolute, and give it to the other system. Not shown-once: their configuration holds * it, so we cannot forget it on their behalf. */ url: string; /** Only on create and rotate. Sign the body with it and the URL stops being a bearer * token: `X-Signature: sha256=`. */ secret?: string | null; } /** One memory or wiki page. */ export interface KnowledgeItem { id: string; kind: "memory" | "wiki" | string; title?: string | null; /** The body. Called `text` here and `filename`/`content` nowhere — a document has a * `filename`, a memory and a wiki page have `text`. Reading `.content` (the obvious * guess, and what the dashboard labels it) yields `undefined` rather than erroring. */ text: string; owner_subject?: string | null; visibility: string; /** Documents a wiki page was written from. */ source_document_ids: string[]; written_at?: string | null; /** True once a source document changed or went away — the page is unverified. */ stale?: boolean; } export interface AuditEntry { id: string; subject: string | null; action: string; resource_type: string | null; resource_id: string | null; metadata: Record; created_at: string; } /** One page of the audit trail, and whether there is another. * * The same envelope `documents.list` answers with, for the reason OBE-190 gives: a bare * array asserts completeness by omission, and nothing in it distinguishes "here is the * set" from "here is as much of it as you got". On an audit trail that is the difference * between an answer and a wrong answer (OBE-273). */ export interface AuditPage { items: AuditEntry[]; /** True when the next page has something in it. Never a guess — a spare row was read. */ has_more: boolean; /** The `offset` that gets the next page, or null on the last one. */ next_offset: number | null; } export interface ForgetResult { subject: string; documents_deleted: number; sessions_deleted: number; /** Sandboxes destroyed — they hold the subject's files too. */ sandboxes_destroyed: number; /** Scheduled tasks stopped at Temporal, not merely deleted from the database. A row * removed while its schedule lives on is a task that keeps firing turns — and * billing them — for a subject that has been erased. */ tasks_cancelled: number; /** How many vectors went with them, or `null` when the vector store could not be * reached. A count rather than a `true`, and the same shape the project deletion * answers: `0` means the corpus was empty, `null` means nobody knows. */ vectors_purged: number | null; /** Every table touched, and how many rows went from each. Reported because "we * deleted your data" is a claim somebody has to be able to check. */ rows_deleted: Record; /** Stored FILES removed — the subject's documents, their uploads, and everything the * agent produced for them. This used to be zero in every sense: the rows went and the * bytes stayed, so a receipt could say `documents_deleted: 4` while the four documents * were still in object storage. */ objects_deleted: number; } export type StreamEvent = { event: "run"; data: { run_id: string; }; } | { event: "start"; data: { session_id: string; }; } | { event: "token"; data: { delta: string; }; } | { event: "tool_start"; data: { name: string; input: unknown; }; } | { event: "tool_end"; data: { name: string; output: string; }; } /** A sandbox command's output WHILE it runs, a chunk at a time. Arrives between a * `tool_start` for `computer_bash` and its `tool_end`, so a long build or test run * can be watched instead of appearing to hang. `command_id` groups the chunks of one * command; the same text also arrives whole in `tool_end`, so ignoring these events * costs nothing but the liveness. */ | { event: "command_output"; data: { command_id: string; command: string; stream: "stdout" | "stderr"; delta: string; }; } /** A command the agent started in the background has finished and been reported to it. * Arrives at a step boundary — the agent is told between its own moves, never * mid-tool. If the turn had already ended when the command finished, the report * arrives as a NEW turn in the same session instead (like a scheduled run), which your * `onTurn`/webhook path already handles. */ | { event: "command_finished"; data: { job_id: string; command: string; exit_code: number | null; status: string; }; } | { event: "citations"; data: { citations: Citation[]; }; } | { event: "guardrail"; data: { stage: "input" | "output"; flags: string[]; content?: string; }; } /** Files for the user. Arrives DURING the turn, as each is produced, and once more * before `done`; every frame carries the whole list so far. */ | { event: "attachments"; data: { attachments: Attachment[]; }; } | { event: "todos"; data: { todos: TodoItem[]; }; } /** The conversation has just been given a name. Fires ONCE, on the turn that named it — * mid-turn with the default `title_from: "prompt"`, just before `done` with * `"response"`. A conversation you named yourself, or one that already had a name, * never produces this. `done.title` carries the same name for anyone not listening. */ | { event: "title"; data: { title: string; }; } /** One subagent moved: started, changed what it is doing, asked the main agent * something, or finished. Carries that ONE subagent — merge it into what you are * showing by `ref`. */ | { event: "subagent"; data: { subagent: SubagentState; }; } /** Every subagent of the conversation, sent once before `done`. Render from this * rather than from accumulated `subagent` frames where you can: a reconnect or a * missed frame cannot leave it wrong. */ | { event: "subagents"; data: { subagents: SubagentState[]; }; } | { event: "questions"; data: { questions: PendingQuestions[]; }; } /** The agent is waiting to be allowed to do something. Render it and answer — nothing * proceeds until you do. */ | { event: "approvals"; data: { approvals: PendingApproval[]; }; } /** The agent drew something. Arrives the instant it is called, mid-turn. */ | { event: "ui"; data: UiRender; } /** The agent has handed a page to the user — open a viewer, poll `browserFrame` * and relay clicks with `browserInput`. It then asks a question and waits. */ | { event: "browser_handoff"; data: Handoff; } /** History management. `compacting` arrives BEFORE the turn produces anything — * summarizing a long transcript is a full model round-trip, so show a * "compacting conversation…" state rather than a blank screen — and is followed by * a `done` frame with the outcome. `compacting` never fires for a plain trim, * which is instant. */ | { event: "context"; data: ContextProgress | ContextReport; } /** A reasoning model's thinking, streamed as it happens — arrives BEFORE (and * between) `token` frames. Render it, or just use the first one to show that the * model is working. */ | { event: "reasoning"; data: { delta: string; }; } | { event: "done"; data: { session_id: string; content: string; requires_action: boolean; tool_calls: PendingToolCall[]; questions: PendingQuestions[]; approvals: PendingApproval[]; ui: UiRender[]; todos: TodoItem[]; subagents: SubagentState[]; handoff?: Handoff | null; guard_flags: string[]; /** Per-turn switches this project could not honour — see `ChatDone.warnings`. */ warnings?: string[]; context?: ContextReport | null; citations: Citation[]; claims: Claim[]; attribution: "per-claim" | "retrieval-only" | "none"; attachments: Attachment[]; reasoning?: string; finish_reason?: string | null; /** Corrections this turn picked up — see `ChatDone.steered`. */ steered?: string[]; }; } | { event: "cancelled"; data: { run_id: string; }; } /** The turn failed, or its stream cannot be continued. * * `detail` is the sentence; `code` is the branch. A reconnect to a run the server no * longer has the frames for arrives here rather than as a 404, so `session_id` is * present even when your client never got as far as a `done` — which for a first turn * is the only place the conversation's id ever appears. See `AgentStreamError`. */ | { event: "error"; data: { detail: string; code?: StreamErrorCode; /** The HTTP status the same failure carries on `POST /chat`, when there is one. */ status?: number; run_id?: string; session_id?: string | null; }; }; /** Why a stream ended without a `done`. * * The `stream_*` codes are about the STREAM; the other two are about the turn being * refused before it could run, and carry `status: 429` for the same reason `POST /chat` * answers 429 — so retry logic keyed on the number behaves the same whichever way you * drove the turn. * * - `stream_expired` — the turn finished; its frames are past the server's replay * window. The answer is in the conversation's history. * - `stream_lost` — the process generating the turn restarted (a deploy, a crash). It * did not finish, and nothing was persisted: send the turn again. * - `stream_cancelled` / `stream_failed` — it was cancelled, or it failed; `detail` * carries the server's own words for the failure. * - `stream_elsewhere` — recorded as running, but not on the API worker that answered. * Retry; do not resend. * - `usage_cap` — the project has spent its own cap. Do NOT retry: raise it under * LLM & limits, or wait for the budget period to reset. This used to arrive as the * provider's raw "Budget has been exceeded! Key=… Max budget: 0.0" with no code and no * status, so a streaming caller could only detect it by matching English. * - `rate_limited` — a per-minute limit. Transient: retry shortly. The opposite fix from * `usage_cap`, which is why they are two codes and not one. * - `bad_attachment` — an attachment could not be read: a URL whose host does not resolve, * a `data:` URI cut short, or a URL that resolves to something that is not an image. * Carries `status: 400`. Do NOT retry unchanged — these used to arrive as 502, whose * documented advice is "retry with backoff", for a request that can never succeed as * sent. * - `no_provider` — this project's LLM access is not usable: the provider it was * configured with was removed, or its key was rotated out from under the conversation. * Carries `status: 409`. Do NOT retry — nothing can answer until a provider is * registered again, and the conversation is fine: a turn on the same session works the * moment one is. It used to arrive as the proxy's own `"User not found."`, which a * customer reads as *their end-user* and goes off to check the subject, the token and * the mint call — all of them correct. * - absent — the turn itself errored mid-generation, which is the ordinary `error` * frame and has been there all along. */ export type StreamErrorCode = "stream_expired" | "stream_lost" | "stream_cancelled" | "stream_failed" | "stream_elsewhere" | "usage_cap" | "rate_limited" | "bad_attachment" | "no_provider"; export interface StreamHandlers { onToken?: (delta: string, full: string) => void; onEvent?: (event: StreamEvent) => void; onToolStart?: (name: string, input: unknown) => void; onToolEnd?: (name: string, output: string) => void; onCitations?: (citations: Citation[]) => void; /** A guardrail acted on the turn (input block/redaction or output redaction/refusal). * * THREE POSITIONAL ARGUMENTS, not the event object. `chat.md`'s event table shows the * frame as `{stage, flags, content?}`, which reads as the handler's parameter — write * it that way and you silently get the string `"input"` in a variable named `flags`. */ onGuardrail?: (stage: "input" | "output", flags: string[], content?: string) => void; /** Non-text outputs: a file the agent handed over, a screenshot it took, media it * generated. Fires as soon as one is produced — mid-turn, right after the tool call * that made it — and again before `done` with the complete list. * * Each call carries EVERY file of the turn so far, not just the new one, so render * from the list rather than appending: that way a reconnect or a missed frame cannot * leave a gap, and re-rendering is idempotent. Files the agent kept for itself * (a screenshot taken only to be cropped) are never in it. */ onAttachments?: (attachments: Attachment[]) => void; /** A reasoning model is thinking. `delta` is the newest thinking text and `full` the * accumulated trace. With a reasoning model this fires well before the first token, * which is what a "Thinking…" indicator should key off — otherwise the UI looks * frozen for as long as the model reasons. */ onReasoning?: (delta: string, full: string) => void; /** The agent's plan changed. Fires whenever the turn touched its todo list — * render it as a live checklist so the user can see where the agent is. */ onTodos?: (todos: TodoItem[]) => void; /** The conversation was just named (see `enable_auto_title` and `title_from`). Fires * once, on the turn that named it, and with the default order that is mid-turn — put * it straight into your sidebar rather than waiting for `done`. */ onTitle?: (title: string) => void; /** A subagent started, moved, asked the main agent something, or finished. * * Called with EVERY subagent of the conversation, not just the one that moved, for * the same reason `onAttachments` is: render from the list and a dropped frame * cannot leave the panel wrong. One that is `running` after the turn ends is still * running — keep showing it, and it will report into the next turn. */ onSubagents?: (subagents: SubagentState[]) => void; /** UI components the agent may draw, for this stream. Their `render` is called for * you as each arrives; this fires alongside if you also want to observe them. */ ui?: UiComponent[]; onUi?: (render: UiRender) => void; /** A sandbox command's output as it is produced. Append it to a live pane keyed by * `command_id` — a long build should look like it is working, and a person watching * needs to be able to tell a slow command from a stuck one. */ onCommandOutput?: (chunk: { command_id: string; command: string; stream: "stdout" | "stderr"; delta: string; }) => void; /** A background command finished and the agent has just been told. Show it: the user * watched the command start and has been waiting longer than the agent has. */ onCommandFinished?: (job: { job_id: string; command: string; exit_code: number | null; status: string; }) => void; /** The agent paused on a question. Without `onQuestion` the stream simply ends * with these on `done`, and you resume it yourself with `question_answers`. */ onQuestions?: (pending: PendingQuestions[]) => void; /** The agent needs the user to act on a page themselves. */ onBrowserHandoff?: (h: Handoff) => void; /** History had to be trimmed or summarized to fit the model's window. Fires only * when it actually happened — useful for spotting a budget set too low. */ onContext?: (event: ContextProgress | ContextReport) => void; /** Answer the agent's questions and continue the stream automatically. Show the * picker, resolve with what the user chose (or `{ chat_instead: true }` if they * would rather keep talking), and `done` resolves only once the agent finishes. */ onQuestion?: QuestionHandler; /** Decide the agent's approval requests and continue the stream. Without it the * stream ends with `approvals` on `done` and you resume it yourself. */ onApproval?: ApprovalHandler; /** Called each time the client (re)connects, with the resume attempt count. */ onReconnect?: (attempt: number) => void; signal?: AbortSignal; /** Max reconnection attempts after a drop (default 10). */ maxRetries?: number; /** Extra client tools for this stream (merged over any registered on the client). * When any tools are available, the stream auto-executes tool calls and resumes, * so `done` only resolves once the agent finishes (no `requires_action`). */ tools?: ClientToolDef[]; /** Fires before each batch of client tools runs. */ onToolCalls?: (calls: PendingToolCall[]) => void; /** Max auto tool-dispatch rounds before giving up (default 10). */ maxToolRounds?: number; } /** Handlers for `chat.sessions.watch`. */ export interface WatchHandlers { /** A message that appeared in the session which this client did not stream. * A fired reminder arrives as its prompt (`role: "user"`) followed by the * agent's answer (`role: "assistant"`) — filter to what your UI should show. */ onMessage: (message: MessageOut) => void; onError?: (error: Error) => void; /** Poll interval in ms for the fallback path (default 4000). */ intervalMs?: number; /** Force polling instead of the live stream (mostly for testing). */ poll?: boolean; /** Fires when the transport is decided, so a UI can say "live" vs "polling". */ onTransport?: (transport: "stream" | "poll") => void; signal?: AbortSignal; } export interface SessionWatch { /** Stop watching. Safe to call more than once. */ stop: () => void; /** Treat everything currently in the session as already delivered. Called for * you whenever this client streams a turn on the watched session. */ resync: () => Promise; } export interface ChatDone { session_id: string; content: string; requires_action: boolean; tool_calls: PendingToolCall[]; /** Questions the agent paused on. Empty once `onQuestion` has answered them. */ questions: PendingQuestions[]; /** Approvals the agent is waiting on. Empty once `onApproval` has decided them. */ approvals: PendingApproval[]; /** Components the agent drew this turn, in call order. Already rendered by then. */ ui: UiRender[]; /** The agent's plan for this session, when the todo family ran this turn. */ todos: TodoItem[]; /** Every subagent of the conversation. Some may still be `running` — the turn * finishing does not finish them, and they report into the next one. */ subagents: SubagentState[]; /** Set when the agent put a page in front of the user. */ handoff?: Handoff | null; /** Guardrails that acted on this turn (also announced live as a `guardrail` event). */ guard_flags: string[]; /** Per-turn switches this project could not honour, one sentence each. Empty on an * ordinary turn. * * `capabilities()` reports a blocked capability and is the right place to ASK — but you * set `enable_action_space: true` once at integration time and never ask again, and that * flag degrades to "every tool bound" rather than to "no tools", so there is no absence * to notice. Worth logging: each entry names the cause, what it costs on this turn, and * the setting that fixes it. */ warnings?: string[]; /** Which model answered, in the friendly form — and the tier that chose it, when the * project's per-request model routing did. * * `model_tier` is null when you named a model or the project routes everything to one * default, so it says "this was chosen for you" rather than "this is how hard we think * your question was". Read it while tuning: a routing mistake and a model being bad at * something are indistinguishable without it, and an operator who has just switched * routing on has no other way to find out that every request is being called * `complex`. */ model?: string | null; model_tier?: "simple" | "normal" | "complex" | null; /** The stored message holding this answer — what you pass to `chat.sessions.rate`. * `null` when the turn answered nothing. */ message_id?: string | null; /** Set only when history had to be trimmed or summarized to fit the window. */ context?: ContextReport | null; citations: Citation[]; /** Sentences attributed to a passage, in order. Empty when the model emitted no markers, * which `attribution` reports rather than hiding. */ claims: Claim[]; /** `per-claim` · `retrieval-only` · `none`. Draw footnotes from `claims` on the first; * on the second you have the retrieval set and no attribution, which is a different * thing to show. */ attribution: "per-claim" | "retrieval-only" | "none"; attachments: Attachment[]; /** The full thinking trace of a reasoning model, else "" (streamed as `reasoning`). */ reasoning?: string; /** Non-normal stop reason, else null (e.g. "max_tool_iterations"). */ finish_reason?: string | null; /** Mid-turn corrections the agent actually read, in the order they arrived. Empty on * every ordinary turn. * * `steer()` resolving `true` means the message was queued — a turn can end before its * next step boundary, so it was never an answer to "did my correction land?". This is. * * It also explains `content`. A steered turn speaks twice — once answering the * instruction that was superseded, once answering the correction — and `content` * carries both, because it is the same text `onToken` emitted. Render * `chat.sessions.messages` if you want the two separated by the correction between * them; and if you assert "the reply is exactly X", know that a corrected turn is not * one reply. */ steered?: string[]; /** The conversation's name, or null — see `ChatResponse.title`. */ title?: string | null; } export interface StreamHandle extends Promise { /** Resolves with the terminal `done` payload; rejects on server error/cancel/abort. * The handle is itself awaitable — `await af.chat.stream(...)` returns this. */ done: Promise; /** Stop listening locally. Server-side generation KEEPS running. * * Re-attach later with `chat.attach(runId)` — from this client or a fresh one, which * is what makes a page reload survivable. `chat.sessions.activeRun(sessionId)` finds * the id if you did not keep it. * * A run id is not a capability: attaching checks the same per-subject ACL as * `sessions.messages`, on the same conversation, so a token that cannot read the * transcript cannot read the stream either — it gets a 404, the same one an unknown id * gets. Steering and cancelling are checked as writes. */ disconnect: () => void; /** Terminate server-side generation (stops model spend), then stop listening. * Best-effort: fires POST /chat/stream/{run_id}/cancel once the run id is known. * * What it stops stays stopped. The cancelled message remains in the conversation, but * it is marked unanswered and is not replayed to the model, so the next turn does not * inherit the instruction the user just stopped — which it used to, spending on the * work cancel exists to prevent. */ cancel: () => Promise; /** The run id, or `undefined` until the first response headers arrive. * * Unlike `cancel()` and `disconnect()`, which are usable immediately, this is not * populated synchronously by `chat.stream()` — there is no run until the server has * accepted one. Read it inside `onToken`/`onEvent`, or after awaiting the handle. */ runId: () => string | undefined; /** The conversation this turn is in, once the server has named it. * * Set from the `start` frame, which arrives before the first token — so it is known * long before `done`, and it is known for a turn that never reaches one. That is the * point: on a NEW conversation the id is created inside the turn, so a stream that * broke was a conversation the client could not find again. */ sessionId: () => string | undefined; /** Send a message INTO this running turn. Needs the `steer` capability. * * The agent picks it up at its next step — after any tool in flight finishes — and * reads it as the user speaking. Use it to correct work you can already see going * wrong; to simply say the next thing, wait for `done` and send a normal message. * * Resolves `false` if the turn finished first, in which case send it as a normal * message instead — the caller has to know, so it is not swallowed. */ steer: (message: string) => Promise; } /** `cancel()` succeeded: generation stopped, and the request it stopped will not be * carried out later. The message stays in the conversation — the user typed it — but it * is marked unanswered and never replayed, so the next turn does not open by doing what * was just cancelled. A `disconnect()` or your own abort signal rejects with a plain * `AbortError` instead: the run carries on server-side there, so calling that cancelled * would be wrong. */ export declare class AgentCancelledError extends Error { constructor(); } export type UploadInput = Blob | ArrayBuffer | Uint8Array; /** Who may retrieve a document/source once it's indexed. `self` is the strictest — * not even an admin token sees it. Matched on every search, so a user can't get an * answer grounded in something they may not read. */ export type Visibility = "self" | "private" | "shared" | "groups" | "tenant"; /** Access control shared by uploads, presigned uploads and patches. */ export interface AclFields { visibility?: Visibility; /** Path prefix `shared` visibility applies under, e.g. "acme:finance". */ visibility_scope?: string | null; /** Roles/groups (matched against the reader's token) for `groups` visibility. */ acl_roles?: string[]; acl_groups?: string[]; } export interface UploadOptions extends AclFields { /** Optional when the input is a `File`, which carries its own name. Required for a * Blob, Buffer or ArrayBuffer, which do not — see `uploadName`. */ filename?: string; contentType?: string; tags?: string[]; /** Parallel part uploads (default 4). */ concurrency?: number; /** Per-part retry attempts (default 5). */ maxRetries?: number; /** Part-completion progress: fires after each part finishes (not byte-level; * fetch has no upload-progress event without XHR). */ onProgress?: (sent: number, total: number) => void; signal?: AbortSignal; } export interface DownloadOptions { /** Range chunk size in bytes (default 8 MiB). */ chunkSize?: number; maxRetries?: number; onProgress?: (received: number, total: number | null) => void; signal?: AbortSignal; } /** * Where a call is. Explicit state rather than something inferred from whether audio is * flowing — render it and your UI is honest about what is happening. * * `status` on the `state` event is the same thing in words a support agent can read; use * that rather than mapping these yourself, so a state added later renders correctly instead * of falling through to whichever branch omits it. */ export type VoiceState = "listening" | "delegating" | "bridging" | "external_speech" | "canceling" | "holding" | "resuming" | "transferred" | "ended"; /** How much of the caller is perceived during a hold. * * `interrupt_only` is the default and almost always right. `off` exists for the rare case * where being interrupted would break something — a caller who says "never mind" into hold * music and is not heard has been ignored by a system that thinks it is helping. */ export type VoiceListen = "full" | "interrupt_only" | "dtmf_only" | "off"; /** How faithfully the speech layer must realise the model's words. * * `strict` for figures, dates, addresses and confirmation codes: "about eighteen hundred" * is not an acceptable rendering of £1,847.23. `natural` (the default) may adapt punctuation * and disfluencies but never meaning. `free` lets the agent paraphrase, which is right for * small talk and wrong for anything a customer will act on. */ export type VoiceFidelity = "strict" | "natural" | "free"; export interface VoiceWaitingOptions { /** `"auto"` picks from measured latency: bridge under 2s, an announced wait to 10s, hold * beyond that. Anything else pins the behaviour, which is occasionally what you want for * a flow you have measured yourself. */ strategy?: "auto" | "bridge" | "wait" | "hold"; /** Named hold media from your project's assets. Omitted gets a default that is * unmistakably "you are on hold and the line is alive" — silence on a hold is * indistinguishable from a dropped call. */ hold_music?: string | null; /** Whether the caller may interrupt the agent mid-sentence. Leave it on. */ barge_in?: boolean; /** Perception during a hold. */ listen_on_hold?: VoiceListen; /** Keep the speech model quiet for the whole wait, not only the beat after it acknowledges. * * A conversational model with the floor and nothing to say does not wait, it talks — and what * it says is invented, because it has no idea what is being looked up. With this on it * acknowledges you once in its own words and then stays silent until you speak, the answer * arrives, or the wait has gone on long enough that the runtime remarks on it (irregularly, * and never with the same line twice running). Off by default: a frontend held quiet cannot * backchannel, and on a fast lookup the silence can feel more abrupt than a murmur. */ quiet_while_waiting?: boolean; /** Give the utterance *after* a barge-in longer to finish. * * A caller who cuts in is mid-thought, and mid-thought is where they pause — so at the * ordinary end-of-turn threshold the breath ends the turn, the half-sentence is transcribed, * and the agent answers a question nobody finished asking. On, the detector waits about a * second longer, and only on the turn that was interrupted. Off by default: it is latency on * every interrupted turn to fix a problem on some of them. */ patient_bargein?: boolean; /** Keep the answer in flight when you interrupt, instead of abandoning it. * * A barge-in fires 160ms into your speech — before anything can know whether you are asking * something else or just saying "mm-hmm" — so abandoning the turn there loses the answer to a * backchannel. On, the agent stops *speaking* at once and keeps *thinking*; a real new request * still supersedes it. Off by default: interrupt to change the subject and you may hear the * tail of the previous answer arrive behind the new one. */ keep_answer_through_bargein?: boolean; /** Keep the agent's own voice out of your transcript when your microphone hears it. * * With an echo path the transcriber attributes the agent's speech to you and the agent then * answers itself — reproduced at 0.5 echo gain, "Thank you for calling Wessex Power" and * "There's an active outage in your area" both came back as things the caller had said. Off by * default: set too strictly the protection costs turns, and not being heard is worse than * being misheard. */ guard_asr_echo?: boolean; /** Apply the echo guard to the transcriber's buffer at the onset only, as the detector does. * On is the corrected behaviour; off restores the per-frame exclusion that dropped a caller * who talked over the answer at around the agent's own volume. */ onset_echo_only?: boolean; /** How much louder than the agent's own output the caller must be to take the floor. Omit for * the detector's default. Lower admits quieter barge-ins and more of the agent's own echo. */ echo_ratio?: number; /** Learn how loud the agent's own voice comes back and lower the barge-in floor to suit the * room. Can only lower it, never raise it, so the worst case is `echo_ratio`'s behaviour. */ adaptive_echo?: boolean; /** Hold the speech model silent through every gap, not just while it waits. * * Measured cause of the corruption, the invented facts and the repetition, which are one * bug: 55 words handed over, 175 spoken. The runtime's text is exact; the other 120 are * the model filling gaps it has nothing to say in, and they come out as word salad. This * closes the last of them — at the cost of its own acknowledgement, so it is off by * default. */ quiet_unless_answering?: boolean; } export interface VoiceSessionRequest { /** Which voice, from the ones the frontend has. Omit for the project's default. */ voice?: string | null; /** Which role to condition it into — `support`, `sales`. Where the frontend supports it. */ role?: string | null; /** Extra instructions for the SPEAKING half. Your project's system prompt still governs * the answering half; this is for how it talks, not what it knows. */ system?: string | null; /** Which model answers. Bounded by the token's model allow-list, like everywhere else. */ model?: string | null; /** Which checkpoint on the deployment's speech host, where it serves more than one — a base * model and a fine-tune of it, say. `voice.frontend()` lists what there is. Not "which * host": there is one per deployment. Asking for one the host does not serve is a 400 * rather than a silent fallback, so an A/B cannot end up comparing a model with itself. */ voice_model?: string | null; /** Which speech frontend runs this call, overriding the project's setting. Per call for the * same reason the model is: comparing two arrangements means hearing them one after the * other, and a project-level setting alone makes that a round trip through the settings * page between every pair. Refused if the deployment does not serve it. */ frontend?: string | null; /** Narrow the tools this call may use, by name. Intersected with what the token already * allows — a call can decline a tool and can never buy one. Omit for everything the token * allows; `[]` means none, which is a different call. * * Worth using. A support line has no business running sandboxed compute, and every tool * the model can see is a tool it can spend a caller's silence on. */ allowed_tools?: string[] | null; fidelity?: VoiceFidelity; waiting?: VoiceWaitingOptions; /** Continue an existing conversation, so the caller picks up where they left off. */ session_id?: string | null; /** Hard ceiling on this call in seconds. Narrowed by the token's own `max_voice_seconds` * and by the deployment's — never widened. */ max_seconds?: number | null; } export interface VoiceSessionOut { id: string; /** The chat session this call's turns land in, so a transcript is readable afterwards. */ session_id: string; /** WebSocket URL for audio and events. Relative to the API host. */ stream_url: string; frontend: string; voice: string | null; role: string | null; /** The checkpoint serving this call, where the host names them. */ voice_model: string; /** Sample rate the frontend generates and expects, in Hz. Send whatever you have; it is * resampled. Play back at this rate. */ rate: number; /** Frame period in milliseconds. Informational — the server owns the clock. */ frame_ms: number; /** True when the caller is on a channel narrower than the model generates — a phone line. * It will not sound like the browser demo, and that is the telephone network rather than * a setting. Surfaced per call so support can stop guessing. */ narrowband: boolean; /** Whether this token may hand the call to a person. */ can_transfer: boolean; } /** A live call, as the server reports it. Everything a UI needs in one object. */ export interface VoiceSnapshot { state: VoiceState; /** The state in words, for a screen. Computed server-side from what is actually true, so * a new state renders correctly without a client release. */ status: string; generation: number | null; epoch: number; listening: VoiceListen; output: "voice" | "hold" | "announcement" | "party" | "silence"; queued_ms: number; hold: { id: string; reason: string; elapsed_ms: number; listen: VoiceListen; } | null; transfer: { id: string; target: string; } | null; turns: number; narrowband: boolean; } /** * One frame from a call. * * The register is deliberately flat and every event carries `at_ms` — milliseconds since the * call opened, measured server-side. Relative rather than absolute because every number that * matters here is a difference, and a wall clock makes two traces from two machines * incomparable. */ export type VoiceEvent = { event: "session.open"; data: { session_id: string; frontend: string; voice: string | null; transport: string; model: string | null; at_ms: number; }; } | { event: "session.close"; data: { session_id: string; reason: string; duration_ms: number; at_ms: number; }; } /** The call moved. `cause` says why, which is what makes a trace readable six states later. */ | { event: "state"; data: { from: VoiceState; to: VoiceState; cause: string; at_ms: number; }; } | { event: "caller.speech_start"; data: { at_ms: number; }; } | { event: "caller.speech_end"; data: { at_ms: number; }; } | { event: "caller.transcript"; data: { text: string; final: boolean; at_ms: number; }; } | { event: "agent.transcript"; data: { text: string; at_ms: number; }; } /** A request went to the reasoner. `instruction` is what it was asked, which is not * necessarily what the caller said. */ | { event: "delegate.start"; data: { generation_id: number; instruction: string; source: string; at_ms: number; turn_id: number; revision: number; }; } /** A request was abandoned. Nothing from it will be spoken — not "queued and skipped": a * superseded answer cannot reach the caller. */ | { event: "delegate.cancel"; data: { generation_id: number; reason: string; at_ms: number; }; } | { event: "delegate.failed"; data: { generation_id: number; detail: string; at_ms: number; }; } | { event: "delegate.end"; data: { generation_id: number; chars_spoken: number; at_ms: number; }; } /** The model's first user-visible character. Thinking and tool-call regions do not count — * those are never spoken, so counting them would make a slow model look fast. */ | { event: "reasoner.first_token"; data: { generation_id: number; ttft_ms: number; at_ms: number; }; } | { event: "reasoner.delta"; data: { generation_id: number; text: string; at_ms: number; }; } | { event: "reasoner.tool_start"; data: { generation_id: number; name: string; id: string; at_ms: number; }; } | { event: "reasoner.tool_end"; data: { generation_id: number; name: string; id: string; ms: number; at_ms: number; }; } /** The filler it chose while the model starts. */ | { event: "voice.bridge"; data: { generation_id: number; text: string; budget_ms: number; at_ms: number; }; } /** From here the caller is hearing the model's words in the frontend's voice. */ | { event: "voice.takeover"; data: { generation_id: number; buffered_chars: number; at_ms: number; }; } /** The caller interrupted and the agent stopped speaking but kept working. Only with * `keep_answer_through_bargein`; followed by `delegate.end`, it is an answer that survived an * interruption instead of being lost to a backchannel. */ | { event: "delegate.held"; data: { generation_id: number; reason: string; at_ms: number; }; } /** How many frames carried a word the model chose rather than one the runtime supplied. A count * over the host's own `forced` flag — no transcription, no constants. */ | { event: "voice.gated"; data: { kind: string; text: string; at_ms: number; }; } | { event: "voice.ungated"; data: { kind: string; at_ms: number; }; } | { event: "turn.held"; data: { reason: string; act: string; wait_ms: number; marked: boolean; at_ms: number; }; } | { event: "caller.echoed"; data: { text: string; kept: string; at_ms: number; }; } | { event: "voice.echo"; data: { gain: number; peak: number; floor: number; samples: number; at_ms: number; }; } | { event: "voice.own_words"; data: { frames: number; forced: number; own: number; sample: string; at_ms: number; }; } /** What the agent produced against what the caller actually heard, at the end of a call. The * one instrument that can see speech corruption. `worst_uninterrupted` is the number that * means something — an interrupted turn legitimately scores badly. */ | { event: "voice.fidelity"; data: { turns: number; unscorable: number; worst: number; worst_uninterrupted: number; spans_intact: boolean; wrote?: string; heard?: string; at_ms: number; }; } /** The runtime remarked on a wait that has gone on. Only with `quiet_while_waiting`: the * frontend is held silent for the whole lookup, and this stops the silence reading as a * dropped call. Irregular by design, and never the same line twice running. */ | { event: "voice.patience"; data: { generation_id: number; text: string; waited_ms: number; at_ms: number; }; } /** A grounded answer the frontend never said, spoken as written instead. `free` fidelity bets * that a frontend handed a fact will compose a sentence about it; this is that bet losing. */ | { event: "voice.grounding_backstop"; data: { generation_id: number; chars: number; waited_ms: number; at_ms: number; }; } /** The answer was given to the frontend as grounding rather than as words to say. Only on a * reference-conditioned arrangement: what the caller hears next is the frontend's own * sentence about this fact, not this text read out. */ | { event: "voice.conditioned"; data: { generation_id: number; chars: number; at_ms: number; }; } /** An answer thrown away because its generation was no longer current. On a * reference-conditioned frontend this is the only protection there is — conditioning cannot * be cut off mid-word the way injected text can. */ | { event: "voice.dropped"; data: { generation_id: number; chars: number; why: string; at_ms: number; }; } | { event: "voice.release"; data: { generation_id: number; at_ms: number; }; } /** The caller barged in. `dropped_chars` is what they did not hear. */ | { event: "voice.interrupted"; data: { generation_id: number; spoken_chars: number; dropped_chars: number; at_ms: number; }; } /** The paced queue ran dry mid-answer — the model is slower than speech. Audible as a * pause inside a sentence, and the number that says whether the bridge was long enough. */ | { event: "voice.starved"; data: { generation_id: number; ms: number; at_ms: number; }; } | { event: "wait.chosen"; data: { generation_id: number; mode: "bridge" | "wait" | "hold" | "transfer"; predicted_ms: number | null; basis: string; at_ms: number; }; } | { event: "hold.requested"; data: { hold_id: string; reason: string; mode: string; listen: VoiceListen; max_ms: number; at_ms: number; }; } | { event: "hold.entered"; data: { hold_id: string; media: string; at_ms: number; }; } | { event: "hold.interrupted"; data: { hold_id: string; heard: string; at_ms: number; }; } | { event: "hold.progress"; data: { hold_id: string; text: string; elapsed_ms: number; at_ms: number; }; } | { event: "hold.completed"; data: { hold_id: string; outcome: "done" | "failed" | "interrupted" | "timeout" | "abandoned"; elapsed_ms: number; at_ms: number; }; } | { event: "resume.started"; data: { hold_id: string; transition: string; speech_hint: string; at_ms: number; }; } | { event: "resume.done"; data: { hold_id: string; silence_ms: number; at_ms: number; }; } | { event: "transfer.requested"; data: { transfer_id: string; target: string; context: string; at_ms: number; }; } | { event: "transfer.consulting"; data: { transfer_id: string; target: string; at_ms: number; }; } | { event: "transfer.completed"; data: { transfer_id: string; target: string; elapsed_ms: number; at_ms: number; }; } /** Nobody took the call. The caller is back with the agent, not on a dead line. */ | { event: "transfer.failed"; data: { transfer_id: string; target: string; detail: string; at_ms: number; }; } | { event: "media.source"; data: { source: string; fade_ms: number; at_ms: number; }; } /** Audio has actually played out to the caller. The only honest "they heard it" — anything * else is a guess about a buffer that is not yours. */ /** A mark was placed where a protected figure finished being spoken, or an answer did. * Call `markPlayed(mark)` once your player has passed it — that confirmation is the only * thing that turns "the balance was sent" into "the balance was heard". */ | { event: "media.mark_placed"; data: { mark: string; source: string; what: string; at_ms: number; }; } /** Your confirmation came back and the server recorded it. */ | { event: "media.mark"; data: { mark: string; source: string; at_ms: number; }; } | { event: "context.injected"; data: { keys: string[]; at_ms: number; }; } | { event: "error"; data: { detail: string; where: string; at_ms: number; }; } /** What the caller said, kept as context rather than acted on — this frontend decides for * itself when a turn needs the agent. Reported because "the transcript changed nothing" and * "no transcript arrived" are different facts. */ | { event: "caller.context"; data: { text: string; at_ms: number; }; } /** A transcript arrived and this project has them switched off, so it was discarded. */ | { event: "caller.transcript_ignored"; data: { text: string; at_ms: number; }; } /** What the speech model itself said the caller wanted, and whether that needs the agent. The * delegation text coming from the model that *heard* the call rather than a transcript of it. */ | { event: "monologue.turn"; data: { text: string; delegating: boolean; at_ms: number; }; } /** The caller finished a turn and the speech model said nothing at all within the window. No * instruction, so no delegation — reported rather than silent. */ | { event: "monologue.empty"; data: { waited_ms: number; at_ms: number; }; } /** A second delegation for a turn already answered, refused. Two paths can start one; seeing * this is normal with a transcriber, never seeing it means one of them is dead. */ | { event: "delegate.duplicate"; data: { source: string; instruction: string; at_ms: number; }; } /** The selected model thinking. **Never spoken** — carried so a client can show it. */ | { event: "reasoner.reasoning"; data: { generation_id: number; text: string; at_ms: number; }; } /** A caller turn needed nothing looked up, the speech model did not answer it either, and the * runtime spoke rather than leave the caller listening to silence. */ | { event: "voice.backstop"; data: { waited_ms: number; delegating?: boolean; at_ms: number; }; } /** The frontend was given the gap while the agent thought, said nothing audible, and the * runtime covered it. An uncovered gap is a caller who believes the line has dropped. */ | { event: "voice.bridge_backstop"; data: { generation_id: number; waited_ms: number; at_ms: number; }; } /** A mark reached the caller's ear. The client's half of the fidelity guarantee: everything * the server knows is about what it *sent*. */ | { event: "media.mark_placed"; data: { mark: string; source: string; what: string; at_ms: number; }; } /** The application's own line was queued into the frontend's monologue, displacing whatever * of the previous answer had not been spoken yet. */ | { event: "speech.injected"; data: { chars: number; displaced_chars: number; at_ms: number; }; }; export type VoiceEventName = VoiceEvent["event"]; export interface VoiceHandlers { /** Audio for the caller: 16-bit signed little-endian PCM at `VoiceSessionOut.rate`. */ onAudio?: (pcm: ArrayBuffer) => void; /** Every event. Use this for a trace panel; use `on()` for one kind. */ onEvent?: (ev: VoiceEvent) => void; /** The call moved. The one handler a UI genuinely needs. */ onState?: (snapshot: { from: VoiceState; to: VoiceState; cause: string; }) => void; onError?: (detail: string) => void; /** The socket closed, for any reason. */ onClose?: (reason: string) => void; } /** * A live call. * * Audio in with `send`, audio out through `onAudio`, and everything else as events. The * call ends when you `close()` it, when the caller hangs up, or when its ceiling is reached. * * `hold`/`resume`/`transfer` are here as well as being tools the agent can call, because the * decision is sometimes your application's rather than the model's — an app that already * knows the CRM lookup it just triggered takes half a minute should not have to hope the * agent works that out. */ export interface VoiceCall { readonly id: string; readonly sessionId: string; readonly rate: number; readonly info: VoiceSessionOut; /** Send caller audio. 16-bit signed little-endian PCM, any rate — it is resampled. * * Any view is accepted, not just `Uint8Array`: a browser's Web Audio path produces an * `Int16Array` and requiring a byte view would make every caller write the same three lines * of reinterpretation. */ send(pcm: ArrayBuffer | ArrayBufferView): void; /** Subscribe to one kind of event. Returns an unsubscribe function. */ on(name: K, fn: (data: Extract["data"]) => void): () => void; /** The call as the server sees it, now. */ snapshot(): Promise; /** Put the caller on hold from your side. */ hold(opts?: { reason?: string; listen?: VoiceListen; max_seconds?: number; }): Promise; /** Take them off hold, optionally with a line to come back on. */ resume(opts?: { lead?: string; }): Promise; /** * Run something slow behind a hold, and come back when it finishes. * * The primitive you actually want when the slow thing is **yours** — a CRM lookup, a booking * system, a payment confirmation your own backend does. `hold()` and `resume()` are the two * halves; this owns the race between them, which is where the bugs are: * * ```ts * const account = await call.holdUntil( * () => crm.expensiveLookup(customerId), * { reason: "looking up the account", maxSeconds: 90 }, * ); * if (account.outcome === "done") { * // The caller is already back and has heard "thanks for waiting". Say the answer. * } * ``` * * Four things can happen and all four are returned rather than thrown: the work finishes, * the work fails, the ceiling is reached, or **the caller talks their way out of it**. That * last one is why this exists — a caller who says "actually never mind" into thirty seconds * of hold music and is not heard has been hung up on by a system that thinks it is helping, * and getting it right by hand means racing four conditions and cancelling in the right * order. * * A ceiling is enforced whether you pass one or not. A hold with no ceiling is a caller on * hold until they give up, which is the most common way an automated line loses a customer. */ holdUntil(work: () => Promise, opts?: { reason?: string; listen?: VoiceListen; maxSeconds?: number; lead?: string; }): Promise<{ outcome: "done" | "failed" | "timeout" | "interrupted"; result?: T; error?: unknown; }>; /** Hand the call to a person. Needs `voice:transfer`. */ transfer(to: string, context: string): Promise; /** Tell the frontend something it should KNOW and not necessarily say — an account tier * that shapes tone, a customer id it must never read out. Distinct from speech on * purpose: conflating the two is what makes an agent recite a reference number. */ inject(facts: Record): Promise; /** Say a line YOU wrote, in the agent's voice. The other half of `inject()`: that one is * "know this", this one is "say this". * * For what only your application knows in the moment — a card that was just declined, an * appointment that has moved, a line compliance requires on every call. **Not for * answers**: an answer is a turn, and it gets a model, a bridge while it is being composed, * and a latency you can measure. Text pushed through here gets none of that. * * It goes through the same machinery the agent's own speech does, so a caller can talk over * it — which is the point. A line the caller cannot interrupt is a line they keep hearing * while they are trying to object to it. * * `interrupt` (default true) **takes the floor**: an answer being spoken is abandoned, and * `displaced_chars` says how much of it. That is right for what this is for — a declined * card outranks the sentence it lands in the middle of. Pass `false` for a disclosure that * should wait; it is refused rather than chopping an answer in half. * * Returns what happened rather than resolving regardless. On hold or mid-transfer the * honest answer is no, and `reason` says which. */ say(text: string, opts?: { interrupt?: boolean; }): Promise<{ spoken: boolean; chars?: number; displaced_chars?: number; reason?: string; }>; /** The caller started talking. Send this the moment your own voice detection fires — * within a couple of hundred milliseconds — and stop your local playback in the same * breath. * * Waiting for a transcript is waiting for a whole utterance, by which time the agent has * talked over them. This is the client's half of barge-in; the server drops what it had * queued and abandons the answer in flight. */ speechStart(): void; /** The caller stopped talking. */ speechEnd(): void; /** What the caller said, if your client does its own transcription. Omit it and the * server's frontend supplies one — this is for a deployment that already has an ASR it * trusts, or for driving a call from text. */ transcript(text: string, final?: boolean): void; /** A keypad press. On a hold, this is the classic "press 1 to come back". */ dtmf(digit: string): void; /** The caller is gone. Distinct from `close()`: this says the *person* left, which is what * ends the conversation; `close()` only drops your connection to it. */ hangUp(): void; /** Audio the server sent has finished playing out to the caller. * * Pass the `mark` from a `media.mark_placed` event once your player has played past that * point. Until you do, `verbatim` figures in the call's fidelity report read `heard: null` — * unknown, which is the honest answer, because everything else the server knows is about * what it *sent*. * * Only meaningful if you can actually tell — a browser scheduling PCM cannot, and guessing * is worse than not reporting: a mark confirmed early tells the server a sentence was * heard when it was still queued, which is the one fact a barge-in decision must not get * wrong. */ markPlayed(mark: string): void; /** Latencies and counts for this call. The counts matter as much as the timings: an * average over turns that produced audio improves when the system gets worse. */ bench(): Promise<{ timings: Record; counts: Record; }>; close(): void; readonly closed: boolean; } /** * Make the arguments match the declared types, as far as that is safe. * * Narrow on purpose — this is the app's own schema and the values go straight back to it. * A container declared as `array`/`object` that arrived as its JSON text is parsed, and * that parsing recurses into array items, which is where the server's own coercion stops. */ export declare function coerceUiArgs(schema: unknown, args: Record): Record; /** How `args` fails `schema`, as sentences — empty when it does not. */ export declare function uiSchemaViolations(schema: unknown, args: Record): string[]; /** * Why a `dispatcher` will not work, or null if there is no reason to think it won't. * * The one real failure, and it is total: an `Agent` from a different undici major than the * one Node embeds rejects the handler Node's `fetch` hands it, so every request fails — * `tools.list()` included — with `TypeError: fetch failed` and a cause of * `invalid onRequestStart method`. Nothing in that names the dispatcher, and the advice to * pass one is ours. */ export declare function dispatcherProblem(dispatcher?: unknown): string | null; /** * Whether a blocking budget of `ms` will actually be honoured. * * Browsers have no such ceiling. On Node, anything past 300s needs the `dispatcher` * option; without one the request still ends at ~300s however high `timeoutMs` is. * * It used to answer "is one configured", which meant `true` for an `undici@8` Agent — * a setup where every single request fails. The one question a caller can ask before * spending an afternoon bisecting undici versions was answering "yours will hold" about * something that holds nothing. It asks the dispatcher itself now; see * {@link dispatcherProblem} for the sentence, which is the part you can act on. */ export declare function blockingBudgetIsHonoured(ms: number, dispatcher?: unknown): boolean; export declare class AgentApiError extends Error { status: number; /** The server's `detail`, in whatever shape it sent — except an HTML error page from * an intermediary, which is replaced by the same summary as `message`. Keeping four * kilobytes of someone else's markup here helped nobody and buried the status. */ detail: unknown; /** The run that failed, when the server said which. * * A failed `chat.send` used to carry neither this nor {@link sessionId}, so the reason * the server stores for the turn — the only durable answer to "why did this fail" — * could not be looked up by the person who hit it. `chat.stream` had both all along; * this is the same handle on the blocking path (OBE-142). */ readonly runId?: string; /** The conversation the failed turn belongs to, when the server said which. * * Present even when the turn failed before producing anything: the session exists from * the moment the turn is prepared, and whatever it wrote is readable with * `ai.chat.sessions.messages(err.sessionId)`. */ readonly sessionId?: string; constructor(status: number, detail: unknown, ids?: { runId?: string; sessionId?: string; }); } export declare class AgentStreamError extends Error { /** Why the stream ended, when the server said. See `StreamErrorCode`. */ readonly code?: StreamErrorCode; /** The run that was streaming, if one had been accepted. */ readonly runId?: string; /** The conversation the turn belongs to. * * Present whenever the stream got as far as its `start` frame — including on the * reconnect that failed, which is the case this exists for. A long turn used to die * with a bare 404 and take the session id with it, so a client holding half an answer * had nowhere to look for the rest; the answer had been written to the conversation * all along. With this: `ai.chat.sessions.messages(err.sessionId)`. */ readonly sessionId?: string; /** The text that had streamed before it broke. Render it rather than dropping it — * the user watched it arrive. */ readonly content?: string; /** The reasoning trace that had streamed, same reason. */ readonly reasoning?: string; /** The HTTP status the same failure would have carried on `POST /chat`, when the server * said — `429` for `usage_cap` and `rate_limited`. Retry logic that branches on numbers * then reads the same on both paths. */ readonly status?: number; /** * The server said why and closed, as opposed to the connection dropping. * * A property rather than something read out of the message, and that distinction is the * whole of OBE-182. The stream loop used to decide "is this terminal?" with * `err.message.startsWith("server:")` — and OBE-140 removed that prefix, because it put a * lowercase field name in front of the first word a customer read. Two things that had to * agree, one of them moved, and nothing failed: from that commit on, EVERY server `error` * frame was treated as a dropped connection. The frame's `status`, `code` and sentence * were discarded, the client reconnected ten times over 136 seconds against a 405 that * could never succeed, and the caller was finally handed "stream ended before completion * (retries exhausted)" with `status: null` and `code: null`. * * The text of a message is not a fact about it. This is. */ readonly terminal: boolean; constructor(message: string, ctx?: { code?: StreamErrorCode; runId?: string; sessionId?: string; content?: string; reasoning?: string; status?: number; terminal?: boolean; }); } /** * A blocking turn that outlived the runtime's own HTTP ceiling. * * Its own class because the alternative is what callers actually got: a bare * `TypeError: fetch failed` whose `cause.code` is `UND_ERR_HEADERS_TIMEOUT`, from a stack * naming undici and not this SDK. `catch (e) { if (e instanceof AgentTimeoutError) … }` * is the thing that was impossible to write. */ export declare class AgentTimeoutError extends Error { /** The original transport failure. Assigned rather than passed to `super`, because * `new Error(msg, { cause })` needs a newer lib target than every consumer of this * file compiles with. */ readonly original?: unknown; constructor(message: string, opts?: { cause?: unknown; }); } export declare class AgentFramework { readonly baseUrl: string; private readonly opts; private readonly _fetch; private readonly toolRegistry; private readonly uiRegistry; /** Active session watchers, so a streamed turn can mark its own messages seen. */ private readonly watchers; /** The bearer in use: `opts.token` initially, replaced on refresh. */ private currentToken?; /** The last token the server rejected, so we don't keep re-sending it. */ private rejectedToken?; /** In-flight refresh, shared so N concurrent 401s mint one token, not N. */ private refreshing?; constructor(opts: ClientOptions); /** Seconds before a token's own expiry at which we stop using it. * * Reacting to a 401 covers most requests, because the failed one is replayed. It * cannot cover an upload: a FormData body is a stream that may already have been * consumed, so a multipart request that 401s cannot be replayed and the expiry * surfaces as a failed upload with nothing the caller can do. Nor does it help a * long-running turn that starts with a token about to lapse. * * So a token is retired slightly before it expires, from the `exp` it carries. The * margin covers clock skew between the browser and the server and the round trip * itself. The 401 path stays exactly as it was — this only avoids reaching it. */ /** Everything behind `chat.sessions.handoff()`. * * A closure rather than a class because all of it is state that dies with the * hand-off, and because the whole point is that the caller holds one object and * nothing else. See the notes on `HandoffController` for what it takes off them. */ private buildHandoff; private static readonly EXPIRY_MARGIN_S; /** Seconds until this JWT expires, or null if it does not say. * * Reads `exp` without verifying anything: the signature is the server's business and * a token we cannot parse is simply used as-is, which is the behaviour that existed * before. Never throws — a malformed token must not break a request that might have * worked. */ private tokenLifeLeft; /** The bearer for the next request. */ private resolveToken; /** Whether pre-expiry refreshing is still worth attempting; see `resolveToken`. */ private proactive; /** Ask `getToken` for a replacement after a 401, at most one call in flight so N * concurrent rejections mint one token rather than N. Returns undefined when there * is no callback (or it failed) — the signal to let the 401 through untouched. */ private refreshToken; /** Register a client-side tool (handler run when the agent calls it). */ registerTool(tool: ClientToolDef): this; registerTools(tools: ClientToolDef[]): this; /** Register a UI component the agent can draw into your app. * * Unlike a tool, nothing is handed back: the agent calls it, your `render` runs, and * the turn carries on without waiting. */ registerUi(component: UiComponent): this; registerUiComponents(components: UiComponent[]): this; /** Merge the client-level registry with any per-call tools (per-call wins). */ private resolveTools; private resolveUi; /** Draw whatever the agent asked for, in order. * * A component that throws is logged and skipped: one broken chart must not take down * the turn that drew it, and there is nothing to report back to the agent anyway. */ private renderUi; /** Draw ONE component the agent called, coerced and checked against the declared schema. * * Its own method because both paths draw and only one of them did this. The streaming * path — which `chat.send`'s own comment points UI users at — called `render(args)` * straight off the event, so it skipped the schema warning AND the coercion: a model * calling `occupancy` where the schema says `occupied` drew bars of height `undefined` * with nothing in the console, and a `bars` array arriving as a JSON *string* was handed * to a chart renderer as a string. The diagnostic existed and was absent exactly where * the documentation sends people (OBE-100). */ private drawUi; /** `bearer` overrides token resolution — used to replay a request with the token a * refresh just produced, instead of asking for one again. */ private authHeaders; /** Like `request`, but hands back the raw Response (for binary payloads). */ raw(method: string, path: string, init?: { query?: Record; body?: unknown; form?: FormData; signal?: AbortSignal; headers?: Record; }): Promise; request(method: string, path: string, init?: { query?: Record; body?: unknown; form?: FormData; signal?: AbortSignal; headers?: Record; }): Promise; /** * Mint a narrower token from the current credential — for a backend holding a * project key that hands short-lived, per-end-user tokens to its frontend. Every * field is intersected/clamped with what the caller already holds, so this can only * ever narrow: a restricted token cannot mint a broader one. A bound the caller holds * applies whether or not you name the field, because an absent claim reads as * *unrestricted*. * * `expires_in` is the one exception, deliberately: it is clamped against the PROJECT's * maximum rather than the caller's remaining lifetime, so a short-lived token can mint * a longer-lived one. It is the same grant for longer, never a wider one — but size a * token's lifetime by what it may do, not by whatever minted it. */ auth: { token: (body?: TokenRequest) => Promise; }; private adminHeaders; tenants: { create: (body: { slug: string; name: string; settings?: Record; }) => Promise; list: () => Promise; /** Merge keys into a tenant's settings (model routing, retrieval models, allowed * browser origins, guardrail policy...). Merged, not replaced — omitted keys are * left alone. */ patchSettings: (id: string, settings: Record) => Promise; }; /** Liveness (`health`) and dependency readiness (`ready`). No auth required — use * them from a probe or a status page. `ready` reports "degraded" with a per-check * reason rather than failing, so you can tell "up but Postgres is unreachable" * apart from "down". */ health: { live: () => Promise<{ status: string; }>; ready: () => Promise<{ status: string; } & Record>; }; tools: { /** Every tool this token would actually be handed on its next request. * * `mcp_servers` is present when the project has any, and answers the question the * listing alone cannot: "still connecting", "handshake failed" and "this server has * no tools" all look identical as an absence of rows. Each entry says whether the * server answered, how many tools it contributed, and why not. * * `withheld` is present when this token holds a family the PROJECT cannot run — today * that means anything needing an embedding model. Those tools are deliberately absent * from `tools`, because this listing promises what the next turn would actually be * handed; `withheld` is there so you can render the control as unavailable with a * reason instead of silently not having it. */ list: () => Promise<{ tools: ToolInfo[]; withheld?: WithheldTool[]; mcp_servers?: McpServerStatus[]; }>; }; chat: { /** One round-trip. Returns `requires_action` + `tool_calls` for you to handle * manually — use `chat.run` to auto-dispatch client tools instead. * * Client *tools* stay manual here; that is the whole difference from `run`. UI * components do not, because there is no manual handling of one: the registry passed * to `createClient({ ui: [...] })` IS the handling, and both paths declare it and * draw from it. * * That was once not true — a blocking client with a registered chart renderer got an * empty `ui` and no error, because the declarations were never sent — and this comment * said so for longer than it was accurate. Measured on both paths since: `render` * fires once and `ui` comes back populated either way. Pick the path you want for * other reasons. */ send: (body: ChatRequest) => Promise; /** Blocking chat that AUTO-EXECUTES registered client tools: it sends the * message, and whenever the agent asks for client tools it runs their * handlers, submits the results, and repeats until the agent is done. * * Takes ONE argument, with the handlers inside it — unlike `chat.stream(req, * handlers)`, which takes two. Both forms are accepted here because the asymmetry * is a trap: passing handlers the way `stream` takes them was silently ignored, so * `onApproval` never fired and the turn came back with `approvals` set, which reads * exactly like approvals being broken. TypeScript catches it; the docs' own browser * examples are plain JS, where it does not. */ run: (opts?: RunOptions, handlers?: RunHandlers) => Promise; /** Streaming chat. When client tools are registered/passed, tool calls are * auto-executed and the stream resumes, so `done` only resolves when the * agent finishes (never with `requires_action`). */ stream: (body: ChatRequest, handlers?: StreamHandlers) => StreamHandle; /** * Re-attach to a turn that is already generating, from anywhere. * * `stream` reconnects on its own when a socket drops, but only inside the life of the * client that started the turn — the run id lived in a local variable. A page reload * destroys that, and a reload during a long turn is the case users actually hit: the * new page has the session id, the assistant's reply is not stored until the turn * ends, so `sessions.messages` shows the question and an empty answer. * * Same handlers, same `StreamHandle` — `cancel`, `steer`, `disconnect` and `done` all * work as if this client had started it. * * const { run_id } = await ai.chat.sessions.activeRun(sessionId); * if (run_id) ai.chat.attach(run_id, { onToken: render }); * * `lastEventId` defaults to 0, which replays the turn from its first token — what a * fresh page wants. Pass the count you already rendered to receive only the rest. * * Two things to know. Client tools are **not** auto-executed on a re-attached stream: * this client did not start the turn and has no continuation to resume, so a turn that * pauses arrives as a `done` with `requires_action`, and you answer it with * `chat.send({ session_id, tool_results })`. And a run lives in the memory of the API * process that started it, so behind several workers this finds it only on that * worker — the same constraint the underlying resume endpoint has always had. * * Attaching to a run whose frames the server no longer holds is not an error you have * to guess about: `done` rejects with an `AgentStreamError` whose `code` says whether * the turn finished (`stream_expired` — read the history) or died with its process * (`stream_lost` — send it again), and whose `sessionId` tells you which conversation * either way. */ attach: (runId: string, handlers?: StreamHandlers, opts?: { lastEventId?: number; }) => StreamHandle; /** Send a message into a turn that is still running (needs `steer`). * * Prefer `handle.steer(...)` when you have the stream handle. This is for a * caller that only kept the run id. Resolves false if the turn already ended. */ steer: (runId: string, message: string) => Promise; /** Resume a turn the agent paused on a question. * * Use it when you drive the picker yourself (`chat.send` returned `questions`); * with `onQuestion` on `chat.run`/`chat.stream` this happens for you. * * const r = await ai.chat.send({ message: "migrate the fetchers" }); * if (r.questions.length) { * const picked = await showPicker(r.questions[0]); // your UI * await ai.chat.answer(r.session_id, { * tool_call_id: r.questions[0].tool_call_id, * answers: [{ question_id: "Scope", selected: [picked] }], * }); * } * * Pass `{ chat_instead: true, message }` when the user would rather keep * talking — the agent drops the question instead of re-asking it. */ /** Tell the agent the user is finished with a page it handed over, resuming the * turn that stopped on it. */ handoffDone: (sessionId: string) => Promise; answer: (sessionId: string, ...answers: QuestionAnswer[]) => Promise; sessions: { /** Conversations this token may see, newest first. * * Paged: `limit` defaults to 100 and is capped at 500. This used to return every * session in one unbounded response — fine for one end-user, and an admin token * on a busy project gets the whole tenant's history to render a sidebar showing * twenty. Page with `offset`. * * A [subagent's](./subagents.md) working conversation is NOT in this list unless * you ask for it. It is a conversation like any other — that is what gives a * delegate a transcript and a plan — but it is not one anybody had, and a chat * sidebar built from here grew a row per delegate, titled with the orchestration * prompt that started it. */ list: (userRef?: string, opts?: { limit?: number; offset?: number; /** Include subagents' own working conversations. Off by default; `q` does not * reach them either. Their rows carry `parent_session_id`. */ includeSubagents?: boolean; /** Substring of an id, a title, a user or a model. Applied server-side — * the conversations somebody is looking for are a handful out of thousands, * and paging through the thousands to filter locally is not finding them. */ q?: string; /** Exact match on what the last turn ran on. `models()` lists what is there. */ model?: string; /** The last turn's outcome. */ status?: SessionOutcome; /** ISO timestamps, over `updated_at`. */ since?: string; until?: string; }) => Promise; /** Models these conversations have actually run on — what a model filter should * offer. Read from the traces, not from what the project has registered: a * project that changed default last month has history on a model no longer in * its list, and that is exactly the history somebody goes looking for. */ models: () => Promise; /** * The turn still generating on this conversation, if any — so a client that never * saw the run id can still attach to it. * * const { run_id, last_event_id } = await ai.chat.sessions.activeRun(sessionId); * if (run_id) ai.chat.attach(run_id, handlers); * * `run_id` is null when nothing is running, which is the ordinary answer. Without * this a client had to persist the id itself, so a tab close — or opening the same * conversation on another device — left an in-flight turn unrecoverable. * * `authoritative` says whether a null is the whole truth. A run lives in the memory * of the API process that started it, so a deployment running several workers can * only answer for the one that took your request: `authoritative: false` means "no * run HERE, possibly one elsewhere", which is a different thing from "the turn has * finished". True on a single-worker deployment, which is the common one. * * `status` is what a null `run_id` actually means, and it matters most in the case * that used to be invisible: `lost` says a turn WAS generating and the process * running it went away — a deploy, a crash — so nothing was persisted and sending it * again is the right move. A client reading only `run_id` was told "nothing is * running" and concluded the turn had finished, which is how a ten-minute answer * became a blank bubble nobody retried. */ activeRun: (sessionId: string) => Promise<{ run_id: string | null; last_event_id: number; /** Whether a null `run_id` is the whole truth. * * A run lives in the memory of the API process that started it, so a deployment * behind several workers can only answer for the one that took this request. * `false` means "no run **here**, and there may be one elsewhere" — a different * thing from "nothing is running", and the difference used to be invisible: a * client read `run_id: null` and concluded the turn had finished. `true` on a * single-worker deployment, which is the common one. */ authoritative: boolean; /** Whether `run_id` can be streamed. False for a turn sent as a blocking * `chat.send()`: it registers a run so "is this conversation busy?" has one * answer either way, but it emits no events. Show "a turn is running" and wait * for the response that request will get; resuming it answers 409. */ attachable: boolean; /** `running` (with a `run_id`) · `idle` · `lost` · `elsewhere`. */ status: "running" | "idle" | "lost" | "elsewhere"; }>; /** Name a conversation, or clear its name with `null`. * * Needs no capability: a title is your own label for a conversation you already * own. `auto_title` governs whether we spend a model call writing one for you, not * whether you may name your own chat. * * Setting one BEFORE the first turn — or passing `title` on the request that * creates the session — skips generation entirely, which is the cheap path when * you already know what the chat is about. */ rename: (sessionId: string, title: string | null) => Promise; /** The agent's plan for this conversation — what a UI renders on a page load, * or between turns. A turn that touched the list also returns it directly. */ todos: (sessionId: string) => Promise; /** What the handed-over page looks like right now. Needs `browser_handoff`. * * Poll this while the user has control, and pass the same `selector` the agent * handed over so the view doesn't jump. Re-read rather than sent once because * the thing a person has to act on — a CAPTCHA challenge grid — often only * appears after their first click. */ browserFrame: (sessionId: string, selector?: string) => Promise; /** Watch the handed-over page live, instead of asking for pictures of it. * * Frames are pushed as Chrome repaints. Polling `browserFrame` gives roughly one * frame a second, which is fine for watching a page settle and not enough to act * on one — a slider puzzle at that rate can't be completed. * * `onClip` fires only when the region worth showing MOVES (an overlay appears, * the page scrolls). Crop client-side against the last clip: the frames are the * whole viewport, so `origin` is both where to crop and what to echo back with a * pointer event. */ browserStream: (sessionId: string, handlers: BrowserStreamHandlers, selector?: string) => BrowserStream; /** Has the person finished with the page they were handed? * * Call it after they let go of the mouse. A separate small model looks at the * region and reports one of four words; when `done`, press Done for them with * `chat.handoffDone`. Rate-limited server-side, so calling it on every mouse-up * is fine — the extra calls come back `checked: false`. */ handoffCheck: (sessionId: string) => Promise; /** Relay one thing the user did into the page, and get the resulting frame. * * Send `x`/`y` in IMAGE space along with the `origin` of the frame they clicked * — echo it back rather than adding it yourself, so a frame that moved between * render and click can't displace the click. * * Watching `browserStream`? Set `want_frame: false` — otherwise every pointer * move renders a JPEG that arrives after the stream already showed it. */ browserInput: (sessionId: string, input: BrowserInputEvent) => Promise; /** The whole hand-off, driven for you. * * Call it when a `browser_handoff` event arrives with `active: true`, give it an * ``, and you are done: it opens the live stream, crops each frame to the * region the agent pointed at, maps clicks and drags back into page space, relays * them, and asks the server whether the gesture finished the job — pressing Done * for the user when it did. * * const view = ai.chat.sessions.handoff(sessionId, handoff, { * onView: () => render(), * onEnded: ({ resume }) => resume && ai.chat.handoffDone(sessionId), * }); * const detach = view.attach(imgElement); // in your effect * // …later: detach(); view.close(); * * Nothing here needs a browser until `attach()`, and `attach()` needs only an * object shaped like an image — so importing the SDK on a server is unaffected. */ handoff: (sessionId: string, handoff: Handoff, options?: HandoffOptions) => HandoffController; /** A few things the user might say next, in their voice — render as buttons and * send the clicked one as an ordinary message. Needs the `followups` capability. * * Call it AFTER the turn's `done`, not before: it is a separate request on * purpose, so the answer is never held up by a suggestion nobody asked for. * The agent is not told a message was suggested rather than typed, and nothing * is stored. Returns [] when nothing sensible follows, generation failed, or the * model did not answer inside the server's deadline (30s by default) — a missing * affordance is not worth an error, and from here those three are the same thing. * It used to be able to run past ten minutes, which is longer than this client * waits, so a hung suggestion became an exception the caller had to handle. */ followups: (sessionId: string, count?: number) => Promise<{ suggestions: string[]; }>; /** One or two sentences on where this conversation got to, for a user coming * back after a while. Needs the `recap` capability. * * Your client decides when to ask — only it knows the tab has been idle. This * is not context compaction: that summarizes FOR the model and is replayed to * it, whereas the agent never sees this. Returns "" on failure. */ recap: (sessionId: string) => Promise<{ recap: string; }>; messages: (sessionId: string) => Promise; delete: (sessionId: string) => Promise; /** * Thumbs up or down on one answer, as whoever this token names. Voting again * replaces your vote; `null` takes it back. `messageId` is the turn's `message_id`. * * This is what a project's A/B test is read from: each vote is counted against the * model that wrote the answer, and the project sees the totals per model. */ rate: (sessionId: string, messageId: string, rating: FeedbackRating | null, opts?: { comment?: string; }) => Promise; /** * Deliver messages that appear in a session which this client did not stream. * * A scheduled reminder (`schedule_reminder`, or any task with an `agent` * action) runs server-side and appends its answer to the session — there is no * stream to listen to, so without this a reminder fires and the UI never hears * about it. History is snapshotted on start and this client's own turns are * marked seen automatically, so `onMessage` only fires for genuinely new * messages. For server-to-server delivery, give the task a `callback_url` * instead of polling. */ watch: (sessionId: string, handlers: WatchHandlers) => SessionWatch; /** Branch a conversation: create a new chat by copying `sessionId` up to and * including `upToMessageId` (or the whole chat if omitted). */ fork: (sessionId: string, opts?: { upToMessageId?: string; title?: string; }) => Promise; }; }; /** * Live voice calls. * * `open()` creates the call and connects the audio socket; everything after that is * `send()` for microphone audio, `onAudio` for what to play, and events for what is * happening. The token is the same end-user JWT everything else uses, and the turns * behind the call are ordinary turns — same capability gate, same tool allow-list, same * per-customer cost attribution. * * Needs a `WebSocket` global: present in browsers and in Node 22+. On older Node, pass * one in as `webSocket` — the SDK deliberately has no dependencies, so it will not bring * an implementation of its own. */ voice: { open: (body?: VoiceSessionRequest, handlers?: VoiceHandlers, opts?: { webSocket?: unknown; }) => Promise; /** Reconnect to a call that is already open — a page reload, a second screen watching. * * Audio goes to whoever is connected; events go to everyone. So a supervisor can watch * a call without taking it over, which is what a call-centre floor actually needs. */ attach: (id: string, handlers?: VoiceHandlers, opts?: { webSocket?: unknown; }) => Promise; /** Every call this token can see. */ list: () => Promise; /** What the frontend can do — the voices and roles a project may ask for. Read it rather * than hard-coding a list: asking for a voice the host does not have fails the call at * `open()`, which is a worse place to find out than a dropdown. */ frontend: () => Promise<{ name: string; rate: number; frame_ms: number; voices: string[]; roles: string[]; /** The checkpoints this host serves. Empty means it serves one and does not name it — * and then `voice_model` on `open()` is refused rather than quietly ignored. */ models: string[]; default_model: string; /** What language each voice speaks, and the one used when the project names none. The * frontend is the authority here: a voice is trained in a language, so this is what * decides which phrasebook a call gets. */ voice_languages: Record; default_language: string; /** Whether the host will speak words it did not generate (a grounded answer read out as * written), and whether an answer can be handed to it as context instead. A frontend * with neither can only be bridged to, which changes what a slow lookup sounds like. */ supports_injection: boolean; supports_context: boolean; max_concurrent: number; in_use: number; /** The other speech arrangements this deployment can reach, if any. More than one means * a call may name which runs it — see `frontend` on `open()`. One or none means the * deployment has a single host and the choice does not exist. */ frontends: string[]; /** Whether an answer reaches the model as grounding rather than as words to say. Told * apart from `supports_injection` because a host can take a reference and still refuse * to be told what to say, and the two produce different failures. */ conditions_on_reference: boolean; /** Whether the selected model decides for itself when a turn needs the agent, and says so * with a directive in its own monologue. A checkpoint with a native retrieval trigger, or * one carrying a trained adapter, does; without either the runtime's router decides * instead. Worth reading rather than inferring from behaviour: the two arrangements fail * in different ways, and knowing which you are looking at is most of the diagnosis. */ emits_directives: boolean; /** Which of the host's models decide for themselves, by name. Per model, because it is a * property of the weights: a host serving a checkpoint and an adapter trained on top of it * answers differently for each, and `emits_directives` alone described the process. */ decides_by_model: Record; /** Whether the host transcribes the caller. Context for the request, never a trigger — * a transcript that also triggers races the model's own monologue and loses, because the * monologue is ready the moment the frontend stops talking. */ provides_transcripts: boolean; }>; }; private connectVoice; /** Agent Plugins — the skills this token can reach for, and the ones it may add. * * Two sources, one list: what the project published plus anything this end-user * uploaded. Uploading needs the `plugins:write` capability; reading does not, because * a project that publishes a procedure wants its agent to use it. */ plugins: { list: () => Promise; delete: (id: string) => Promise; /** Publish a skill. Re-uploading a name replaces it. * * Takes whatever the customer actually has: * * - a packaged Agent Plugin (`plugin.json` + `skills/`), read as-is; * - a zipped folder of skills, or a single `SKILL.md` — a manifest is written * for them, because requiring one to publish a file of instructions is a * packaging exercise standing in front of the feature; * - a folder's files, from a directory picker, each keyed by its relative path. * * An end-user's plugin is private to them and unioned on top of the project's — * only a project key can publish to everyone. */ upload: (file: UploadInput, opts?: { filename?: string; signal?: AbortSignal; }) => Promise; /** Publish a folder of skills without zipping it. * * `files` is what a browser directory picker gives you. Each part is sent under * its path relative to the folder, which is all the server needs to lay the * skills out — so no zip library is needed on your side. */ uploadFolder: (files: { path: string; content: UploadInput; }[], opts?: { signal?: AbortSignal; }) => Promise; }; documents: { /** * One PAGE of documents, newest first — `{ items, has_more, next_offset }`. * * It was a bare array, which asserts completeness by omission, on the one collection * that only grows: a live project of 302 documents returned all 302 and 181KB on every * call, and a 10k corpus would be ~6MB on a route a dashboard opens with (OBE-190). * `limit` defaults to 100 and is capped at 500; page with `next_offset`, which is given * rather than left as arithmetic because the cap can make `offset + limit` wrong. * * `with_total` adds a `total` and costs a second query — for a summary, not for paging. * "Is there another page" is `has_more`, and it is free. */ list: (query?: { tag?: string; status_filter?: string; limit?: number; offset?: number; with_total?: boolean; }) => Promise; get: (id: string) => Promise; delete: (id: string) => Promise; retrieve: (body: RetrieveRequest) => Promise; /** Small-file convenience upload via multipart form (server proxies to S3). */ uploadSimple: (file: UploadInput, opts?: AclFields & { filename?: string; contentType?: string; tags?: string[]; signal?: AbortSignal; }) => Promise; /** Resumable presigned multipart upload (direct to S3). */ upload: (file: UploadInput, opts?: UploadOptions) => Promise; /** Poll a document until ingestion finishes (status "ready" or "failed"). * Ingestion is async, so querying a just-uploaded doc may return nothing until * this resolves. Throws on "failed" or timeout. */ waitReady: (id: string, opts?: { timeoutMs?: number; intervalMs?: number; signal?: AbortSignal; }) => Promise; /** Upload a small file AND wait for it to finish ingesting — the common case. */ uploadAndWait: (file: UploadInput, opts?: AclFields & { filename?: string; contentType?: string; tags?: string[]; timeoutMs?: number; signal?: AbortSignal; }) => Promise; /** Get a presigned GET URL for the raw file. */ downloadUrl: (id: string) => Promise<{ url: string; expires_in: number; size: number | null; content_type: string | null; }>; /** Resumable ranged download; returns a Blob. */ download: (id: string, opts?: DownloadOptions) => Promise; /** Update a document's tags / visibility / ACL. */ update: (id: string, body: AclFields & { tags?: string[]; }) => Promise; /** Reprocess a document (retry a failed ingest, or re-embed with a new model). */ reingest: (id: string) => Promise; /** Inspect the stored chunks of a document (text + page + index). */ chunks: (id: string) => Promise<{ chunk_index: number | null; page: number | null; text: string; }[]>; }; tasks: { create: (body: TaskCreate) => Promise; list: (status?: string) => Promise; get: (id: string) => Promise; cancel: (id: string) => Promise; }; /** * Turns that start because something happened somewhere else. * * A trigger is a URL you give another system — a ticket tracker, a CI job, a Zapier step * — and the path is the credential, so it runs as a fixed subject chosen at creation. * These were the only routes in the API reference with no SDK method: every integration * hand-wrote `fetch` for them. * * The URL comes back absolute and is not a secret we can show once: the whole point is * that someone else's configuration holds it. `secret` IS shown once — with it, the * sender signs the body and the URL stops being a bearer token. */ triggers: { list: () => Promise; /** `prompt` may interpolate the event: `"A ticket arrived: {{ body.title }}"`. */ create: (opts: { prompt: string; name?: string; systemPrompt?: string; /** Run every event in one conversation. Off by default: two unrelated events sharing * a transcript confuse both. */ sessionId?: string; /** Require a signed body, and return the key to sign it with. */ signed?: boolean; }) => Promise; delete: (triggerId: string) => Promise; /** A new URL, with the old one alive for 24 hours — so telling the other system its new * address is not an outage. */ rotate: (triggerId: string) => Promise; }; /** * What the agent wrote down — remembered facts and wiki pages. * * Retrieval reads these back on every turn, so when an answer looks wrong a * remembered fact is often the reason. The ACL that governs retrieval governs this * too: a caller sees exactly what its token could have retrieved. */ memory: { /** `kind` is "memory" (facts, per end-user) or "wiki" (pages, per user or shared). */ list: (opts?: { kind?: "memory" | "wiki"; limit?: number; }) => Promise; /** Forget one. The agent can write it again; this removes what is there now. */ delete: (itemId: string) => Promise; }; /** * What this token can actually do. * * The three gates on a turn are the platform's kill switch, the token's capability * and the per-request `enable_*` flag, and until this existed a client could read * none of them. `flags[].effective` is the useful one: false means setting that flag * changes nothing on this token — which is otherwise indistinguishable from the agent * simply choosing not to use the tool. When `effective` is false because something is * missing rather than forbidden, `flags[].blocked` says what, in a sentence you can put * in front of whoever administers the project. * * Cheap and safe to call on load: a token asking what it holds is reading its own * claims back, so it needs no capability of its own. */ capabilities: () => Promise; audit: { /** * Read the tenant's audit trail (admin). Filter by action, subject, or document. * * `document` is the union of the two ways a row names one: a write NAMES it * (`resourceId` is the document, and the row is about it), a read merely SAW it * among others (`metadata.document_ids`, with `resourceId` being the query or the * session). So it answers "everything that has ever happened to this document" * without the caller having to know which rows keep it where. */ list: (opts?: { action?: string; subject?: string; document?: string; limit?: number; offset?: number; }) => Promise; /** Purge a subject's documents, sessions, and vectors (GDPR erasure; admin). */ forget: (subject: string) => Promise; }; /** * Sandboxed compute — the isolated machines the agent works in. Each session has a * stable id, survives sleep/wake, and can be reattached to a later turn via * `chat({ computer_session_id })`. * * Nothing to configure: the deployment runs one sandbox host, and a project with the * `computer` capability gets a machine. `host()` says what that machine is. * * const s = await ai.computers.create(); * await ai.computers.upload(s.id, file); // any file type, straight in * await ai.chat({ message: "summarise inbox/data.csv", computer_session_id: s.id }); * await ai.computers.pause(s.id); // sleep now instead of on idle */ computers: { /** What this deployment's sandboxes are and can do — use it to hide a feature * (port exposure, say) rather than offering a button that fails. */ host: () => Promise; /** Start a sandbox. Bind it to a chat session to give that conversation a * persistent workspace. */ create: (body?: { name?: string; chat_session_id?: string; }) => Promise; list: (opts?: { include_stopped?: boolean; }) => Promise; get: (id: string) => Promise; /** Put it to sleep now rather than waiting for it to go idle; the workspace is * preserved either way. */ pause: (id: string) => Promise; /** Wake a sleeping sandbox and reattach to its workspace. */ resume: (id: string) => Promise; destroy: (id: string) => Promise; /** Run a command yourself (same guardrails as the agent's tool). `timeout_s` is * clamped to the host's ceiling — 30 minutes — and then the command is killed. */ exec: (id: string, body: { command: string; cwd?: string; timeout_s?: number; stdin?: string; }) => Promise; /** * Push a file (any type) into the sandbox. * * The destination takes either shape — a bare path, or `{ dest }` / `{ path }` — because * the other two uploads on this client take an options object and a caller who has * written those first reasonably writes this one the same way: * * documents.upload(file, { filename, tags }) * skills.upload(zip, { filename }) * computers.upload(id, file, "data/berths.csv") <- the odd one out * * Passing an object used to stringify to `[object Object]`, which the sandbox sanitised * to `_object_Object_` and used as the filename — answering **200** with a plausible * `size`, so the upload genuinely succeeded and put the bytes somewhere the caller would * never look. Accept, coerce, refuse: it coerced, into something that looked like it * worked (OBE-158). */ upload: (id: string, file: Blob | File, dest?: string | { dest?: string; path?: string; }) => Promise<{ path: string; size: number; }>; /** Pull a file out of the sandbox as bytes. */ download: (id: string, path: string) => Promise; }; private runWithTools; private driveTools; /** Wrap startStream so a `done` carrying `requires_action` auto-executes the * client tools and continues the stream (same session) until the agent ends. */ private startStreamWithTools; private resyncWatchers; private watchSession; /** * `resume` re-attaches to a run that is ALREADY generating instead of starting one. * * The transport has always been able to do this — a dropped socket reconnects with * `GET /chat/stream/{runId}` and replays what was missed — but only within the life of * the client instance that made the POST, because the run id lived in a local variable. * A page reload is the case users actually hit, and it destroyed exactly that. See * `chat.attach`. */ private startStream; private multipartUpload; private rangedDownload; } /** Where projects are administered and end-user tokens are minted. A different host * from {@link DEFAULT_BASE_URL}, and a different credential — the two are not * interchangeable, which is the whole reason there are two clients. */ export declare const DEFAULT_CONTROL_PLANE_URL = "https://oberik.com"; export interface ProjectClientOptions { /** From the project's URL in the dashboard. */ projectId: string; /** `pk_…`. Server-side only — see the constructor. */ projectKey: string; /** Only for a self-hosted control plane. */ baseUrl?: string; fetch?: typeof fetch; } export interface MintTokenInput { /** Who this token is. Owns whatever it creates, and is the default visibility * boundary. A hierarchical path: `acme:finance:ana`. */ subject: string; /** What it may SEE — a prefix of `subject`. Omit for "only its own data". */ scope?: string; /** What it may DO. Intersected with the project's ceiling: you can narrow, never * widen. Omit and the token carries everything the project allows. */ capabilities?: string[]; roles?: string[]; groups?: string[]; /** Restrict this token to a subset of the project's models. */ models?: string[]; maxEffort?: "minimal" | "low" | "medium" | "high"; /** Ceiling on agent↔tool loops. Unset means unlimited. */ maxToolIterations?: number; maxContextTokens?: number; /** Seconds. Keep it short and mint per session. */ expiresIn?: number; } export interface MintedToken { access_token: string; token_type: string; expires_in: number; tenant_id: string; /** What was ACTUALLY granted. Compare with what you asked for to see what the * project's ceiling trimmed — a capability you expected and did not get is a * toggle in the dashboard, not a bug in your code. */ capabilities: string[]; scope: string | null; allowed_models: string[] | null; max_effort: string | null; max_context_tokens: number | null; /** Ceiling on agent↔tool loops for this token, after the project's own ceiling was * applied. Null when neither set one. */ max_tool_iterations: number | null; /** Said out loud when the mint did something you would want to know about. Three * reasons, and each of them used to be silent: * * - **A privileged role.** `roles: ["admin"]` gives the token every end-user's * sessions, documents and memories in the project, plus `audit.list` / * `audit.forget`. The likeliest way that happens is a backend forwarding its OWN * application's role names, where "admin" means something much smaller. * - **A capability name that does not exist.** `"tasks"` for `tasks:write`, `"MEMORY"` * for `memory`, or a typo. Without this the mint succeeds and the `403` arrives * later, from whichever call needed it — a good message at the wrong call. * - **A real capability the project's ceiling refuses.** Otherwise the only sign is the * agent saying in prose that it cannot do the thing, which no test can assert on. * * Assert on this in your mint tests: it is the one place all three are readable, and * each entry names what to do about it. */ warnings?: string[]; } /** * The control plane, from your backend. * * The other client in this package talks to the data plane as one end-user. This one * holds the project key and administers the project itself: minting those tokens, * setting the capability ceiling, curating the corpus. * * They are separate classes on purpose. A project key can mint a token with any * capability the project allows — and create further keys, and delete the project — so * it must never travel to the same place an end-user token does. Two types make that a * decision someone has to make rather than a field they can accidentally set. */ /** What deleting a project actually reached, on both planes. */ export type ProjectDeleted = { id: string; name: string; /** Control-plane rows removed, per table. */ rowsDeleted: Record; /** The tenant erase. `null` only if the project never had a tenant (it never went live). * A `null` INSIDE it means that store could not be reached and something is still * there — the rest of the deletion still happened. */ dataPlane: { rows_deleted: Record; sandboxes_destroyed: number; schedules_cancelled: number; vectors_purged: number | null; objects_deleted: number | null; } | null; }; export declare class OberikProject { readonly baseUrl: string; private readonly projectId; private readonly key; private readonly _fetch; constructor(opts: ProjectClientOptions); /** * Any Project API route, with this project's key — the escape hatch. * * The end-user client has always had `raw()`; this one had a private `request` and * nothing else, so the documented routes with no method here (the webhook secret, * webhook-tool rotation, `webhook-tools/deliveries`) meant hand-rolling `fetch` with * an `X-API-Key` header and re-deriving the base URL. A published SDK should not make * you leave it to use the API it wraps. * * `path` is relative to `/api/projects/` — `raw("GET", "/webhook-secret")`. */ raw(method: string, path: string, body?: unknown, form?: FormData): Promise; private request; tokens: { /** * Mint a short-lived token for ONE end-user. * * const { access_token } = await oberik.tokens.mint({ * subject: `${user.orgId}:${user.id}`, * scope: `${user.orgId}:${user.id}`, * capabilities: ["chat", "documents:read"], * }); */ mint: (input: MintTokenInput) => Promise; /** * The same thing shaped as the callback {@link createClient} wants, so the two * halves of this package fit together without a wrapper: * * const ai = createClient({ getToken: oberik.tokens.forUser({ subject: id }) }); * * Called again whenever a token expires, so the client refreshes on its own. * * **`warnings` are reported rather than discarded.** This method returns only the * token, which is the point — it is a `getToken` callback. It used to reach into the * response for `access_token` and drop the rest, including the sentence the mint had * just written to explain what it had NOT granted (OBE-264). So * `capabilities: ["documents"]` — the family name without the verb, which looks right * — produced a client that constructed cleanly and threw `403 token lacks capability: * documents:read` on first use, naming a capability the caller never typed, from a * different plane, at a call site with nothing to do with minting. The explanation * existed, was sent over the wire, and was dropped one line before it could arrive. * * Reported **once per distinct warning per callback**, because this is called again on * every refresh: the same sentence otherwise repeats for the life of the process, which * is how a useful line becomes noise people filter out. * * Pass `onWarning` to route them somewhere real — a logger, an alert, a test * assertion — or `onWarning: () => {}` to silence them deliberately. Silence should * cost a line of code; it used to be the default. */ forUser: (input: MintTokenInput, opts?: { onWarning?: (warnings: string[], input: MintTokenInput) => void; }) => (() => Promise); }; /** * The project itself — its name, description, and the settings that are properties of * the project rather than permissions on a token. * * These had no SDK path at all. `doneWebhookUrl` is documented in the capability table * but is NOT a capability, so the obvious `capabilities.set({ doneWebhookUrl })` stored * nothing; the only symptom was `turn.stopped` never firing. Renaming a project, or * turning citation markers off, meant dropping to raw HTTP through `raw()`. */ project: { /** The project, its capability ceiling and its settings — plus `readiness` and the * `health` / `healthDetail` badge, so "is this project working" is answered by the * first thing you fetch rather than one route further on. */ get: () => Promise & { health: ProjectHealthState | null; healthDetail: string | null; readiness: ProjectReadiness | null; }>; /** Merges — send only what you want to change. */ set: (fields: { name?: string; description?: string | null; /** Where `turn.stopped` is POSTed. Null clears it. */ doneWebhookUrl?: string | null; }) => Promise>; /** Whether `[2]` stays in the visible answer. Its own route, not a PATCH field. * `stripped` leaves `claims[].start/end` as the only way to place a footnote. */ citations: (markers: "inline" | "stripped") => Promise<{ markers: string; }>; /** * Delete the project and everything in it. Needs an **admin** key. * * Erases the tenant — documents, agent knowledge, conversations, uploaded files, * sandboxes and scheduled tasks — along with this project's API keys, provider * credentials and LLM access. Every token minted for it stops working. No undo. * * `name` has to match the project's name, and it is the confirmation rather than a * formality: the way this goes wrong is deleting the *wrong* project, and a client * already holds the id while the name is something it had to be told. A mismatch is a * 400 and deletes nothing. * * await oberik.project.delete({ name: "Support Bot", confirm: true }); */ delete: (confirm: { name: string; confirm: true; }) => Promise; }; /** The capability ceiling: the maximum any token minted here may hold. */ capabilities: { get: () => Promise>; /** * Merges — send only what you want to change. * * Read-only fields that `get()` returns are dropped rather than rejected, so * `set(await get())` — the obvious way to flip one flag — works. Anything else * unrecognised still errors: a capability nobody enforces must not be accepted in * silence, which is the failure this whole check exists for. * * That now includes the MEMBERS of the two modality lists, which take `image`, * `audio`, `video` or `file` and refuse anything else by name. They used to be * filtered — so `outputModalities: ["banana"]` and `outputModalities: ["image"]` on a * text-only project were the same 200 storing `[]`, and a token that may produce * nothing looks exactly like one that was saved correctly. A real modality no * registered model supports is still dropped rather than granted, but the response * says so in `warnings`. * * `{ confirm: true }` is the second argument, and two settings need it: clearing * `allowedTools` or `maxToolIterations` removes a restriction from every token the * project has ever issued and nothing downstream notices — the same widening-by-absence * `origins.set([])` and `limits.set({maxBudget: null})` are guarded against. Narrowing * them, and every other capability, needs nothing. * * **On `allowedTools`, `[]` and `null` are opposites.** `null` is no list, so every tool * your capabilities allow is reachable; `[]` is a list that permits nothing, so no token * reaches any tool at all. Both cost a confirm and each refusal says which one you are * about to get — `origins` is the other way round, where `[]` removes the restriction, * so "empty clears it" is not a rule that holds across the surface. */ set: (caps: Record, opts?: { confirm?: boolean; }) => Promise; }; /** Documents owned by the project rather than by any one end-user: the corpus you * curate and your users only read. */ /** * The corpus you curate. * * Uploads here are stored `tenant`-visible — readable by every end-user of the project — * which is what "a corpus you curate, that users only read" means. That is the default * and the only option, deliberately: a project key is not a person, so there is no * per-user subtree for it to write into. Per-user documents go through the data-plane * client with an end-user token, where the subject IS the owner. * * The docs used to show `visibility: "tenant"` being passed here, which was neither * accepted nor needed — a recipe that worked by luck rather than by expression. */ documents: { /** * One PAGE of the corpus — the same envelope, the same filters and the same paging as * the end-user client's `documents.list`, because they are the same route. * * The route became a page in OBE-190 and this declaration did not move with it: it * still said `Promise`, so a TypeScript caller wrote `.length` and got * `undefined` with no type error and no exception — a census that counted documents * reported zero. That is OBE-147 verbatim, one collection over, and the docstring * describing it sits sixty lines above this one (OBE-195). The query type had the * mirror-image gap: `limit`/`offset` worked at runtime and were unwritable in * TypeScript, so the documented paging loop did not compile against the client that * recommends it. * * `with_total` adds a `total`; it is what "how big is this corpus" is asked with, and * it costs a second query. "Is there another page" is `has_more`, and it is free. * * On this client it also answers `hidden` — how many documents exist that even a * project key may not list, which is every `self` document somebody else uploaded. * Without it, "how big is this corpus" is short by an amount nothing else would tell * you until `audit.forget` deleted them (OBE-277). */ list: (query?: { tag?: string; status_filter?: string; limit?: number; offset?: number; with_total?: boolean; }) => Promise; get: (documentId: string) => Promise; /** * Retag a document, or change who may see it — keeping its id. * * Tags are how a curated corpus is scoped (`retrieve({tags})`, `chat({tags})`), so * getting one wrong is an ordinary thing to do. There was no ordinary way back: this * method did not exist, an end-user token is refused (correctly — an end user must not * rewrite the operator's corpus), and the only door that worked was minting a token for * the subject `service`, which is what a project-key upload is recorded against and * appears in no documentation (OBE-151). * * The alternative was delete-and-re-upload, which **changes the document id** — and * silently breaks every citation, stored `document_ids` scope and audit record pointing * at it, for an operation whose intent was "fix a tag". * * Only the fields you pass are touched; `tags` REPLACES the existing list. */ update: (documentId: string, patch: { tags?: string[]; visibility?: "private" | "tenant" | "shared" | "groups"; visibility_scope?: string | null; acl_roles?: string[]; acl_groups?: string[]; }) => Promise; /** Re-extract and re-index a document, keeping its id — for one that failed, or one * whose processing settings have changed since it was ingested. */ reingest: (documentId: string) => Promise; /** `file` may be a Blob/File, an ArrayBuffer or a Uint8Array — the same shapes the * end-user client takes. It was `Blob | File` only, so the quickstart's own * `await readFile("q3-report.pdf")` (a Buffer) failed deep inside undici as * `parameter 2 is not of type 'Blob'`, with nothing naming Oberik in the stack. */ upload: (file: UploadInput, opts?: AclFields & { filename?: string; tags?: string[]; }) => Promise; /** Block until a document is indexed, or throw. * * Ingestion is asynchronous, and this is the client you seed a corpus with — the one * place you most want to wait, because the next line of a setup script is usually a * question the corpus has to be able to answer. The end-user client has had this * since the beginning; here there was only `upload`, so every setup script polled by * hand or simply hoped. */ waitReady: (documentId: string, opts?: { timeoutMs?: number; intervalMs?: number; }) => Promise; /** Upload and wait for it to be indexed — the common case when seeding a corpus. */ uploadAndWait: (file: Blob | File, opts?: { filename?: string; tags?: string[]; timeoutMs?: number; }) => Promise; /** Remove a document and its vectors. The corpus copy goes; re-adding it needs the * original file, so the route asks (OBE-253) and `confirm` is how you answer. */ delete: (documentId: string, confirm: { confirm: true; }) => Promise; }; /** * The models this project runs on, and the retrieval it uses. * * These had no methods at all: `project-api.md` documents them in a table and every * integration hand-rolled `fetch` for half its setup. `providers.create` is the one that * matters most — it is the step a new project cannot answer a question without, and it * finishes the rest of the setup itself (see `derived` in the response). * * `providers.catalog` is deliberately absent: it is not project-scoped, so it does not * belong on a client whose every path hangs off one project. */ providers: { list: () => Promise; /** Name a chat model AND an embedding model: the first lets the agent answer, the * second lets it index. The response's `derived` says what was set for you. * * `values` must carry every field the provider's catalog entry declares without * `optional` — `api_base` for a self-hosted server, `api_key` for a hosted API — and a * missing one is refused, naming it. A vLLM provider with a key and no base URL used * to register happily and could not be reached by anything. * * `create`, like every other collection here. It was `add`, and `create` is what a * developer guesses — a reporter wrote `providers.create` from memory, it threw * mid-run, and they filed it against themselves (OBE-106). Two collections used * `add`/`remove` while six used `create`/`delete` for the same operation, so the * minority moved. */ create: (opts: { provider: string; models: string[]; values: Record; label?: string; }) => Promise<{ id: string; derived?: Record; }>; /** * Change a provider's models, its label, its key — or what its models can do. * * `modelFacts` is the only way to say what a model NOBODY can describe reads and * produces, and it was missing from this type: a `vllm serve --served-model-name * hukuk-db` reports an id no index carries over a `/v1/models` that publishes no * capabilities, so it is recorded as text-only and the project's `input:image` ceiling * follows. The dashboard has always asked; from code there appeared to be no way, and * "a project as code" is a section of the docs. * * Send it on its own — no `models` needed. Registration is expensive and metadata is * not, so this changes what is recorded and re-registers nothing. */ update: (credId: string, opts: { models?: string[]; label?: string; values?: Record; /** Keyed by model name, exactly as the provider lists it. */ modelFacts?: Record; }) => Promise; /** Re-read the provider's catalog: a model registered before its price was published * bills nothing, so the usage cap never trips. */ refresh: (credId: string) => Promise; /** * Remove a provider credential and the model deployments registered from it. * * The one delete here whose target nothing can reconstruct: registration answers a * `keyHint` and never the key again, so the platform holds the only copy and removing * it destroys it. `confirm` is required because this method — not the CLI, not the * dashboard — is where that used to happen in a single unguarded call (OBE-253). */ delete: (credId: string, confirm: { confirm: true; }) => Promise; }; /** The model used when a request does not name one. */ defaultModel: { /** Which model a request that names none will run on. */ get: () => Promise<{ defaultModel: string | null; }>; /** * The model a request that names none runs on. * * An EMPTY string clears it, and then every request that does not name a model fails — * which is the normal case and the reason a default exists. So the API refuses without * `{ confirm: true }`, and this could not send one: the fourth setter behind that guard * and the fourth one the library had no way to satisfy (OBE-63 found three). */ set: (model: string, opts?: { confirm?: boolean; }) => Promise; }; /** Which of your models answers, by how much work the request looks like. * * `off` (the default) means the default model answers everything that names no model. * `auto` sorts each request into a tier from what it brings — a file, the sandbox, * delegates, several steps spelled out, a long brief — and answers it with the model you * put there. A tier left empty falls back to the default model, and a request that names * a model is never re-routed. * * The sorting reads the request rather than asking a model: a classifier call in front * of every turn would cost latency on the fast path this exists to make fast, and spend * money to decide how much money to spend. So it is wrong sometimes, and being wrong * means a neighbouring model answers rather than the turn failing. Every answer carries * `model` and `model_tier`, which is how you tune it. */ modelTiers: { get: () => Promise<{ mode: "off" | "auto"; simple: string | null; normal: string | null; complex: string | null; /** What an empty tier falls back to, so you can read the whole picture in one call. */ defaultModel: string | null; }>; /** Only the fields you pass change. An empty string clears a tier. */ set: (opts: { mode?: "off" | "auto"; simple?: string; normal?: string; complex?: string; }) => Promise; }; /** Compare two or more of the project's chat models on real conversations. * * Each new conversation that names no model is given one of them at random and keeps it; * a request that names a model is never enrolled, and while a test runs it takes * precedence over `modelTiers` — tiers pick by how hard a question looks, which is * exactly what must not decide the arm. Read the result with `feedback.summary`, from * `startedAt`. */ abTest: { get: () => Promise<{ models: string[]; startedAt: string | null; }>; /** Two or more registered chat models to start or change a test; `[]` to stop it. * Changing the set restarts `startedAt`; saving the same set does not. */ set: (models: string[]) => Promise<{ models: string[]; startedAt: string | null; }>; }; /** What people thought of the answers: thumbs up and down, per model. */ feedback: { summary: (opts?: { since?: string; until?: string; }) => Promise; }; /** Embedding and rerank overrides. Set for you when you register an embedding model, so * this is for changing it rather than for getting started. */ retrieval: { /** * The models in use, and what the platform found out about them. * * `embeddingCheck` and `rerankCheck` are the reason to call this rather than to * remember what you set: neither is written by you. The first carries the width * measured by embedding one word, the second what the reranker answered when it was * asked (OBE-236, OBE-237) — so a model that was accepted and does not work says so * here and nowhere else you would think to look. */ get: () => Promise<{ embeddingModel: string | null; embeddingDim: number | null; embeddingCheck?: { model: string; measured: number; at: string; } | null; rerankModel: string | null; rerankCheck?: { model: string; problem?: string; at: string; } | null; }>; /** * There is no dimension to give. * * `embeddingDim` was here and the route stopped reading it: a width is a fact about * the model, so it is measured by embedding one word, and a number somebody typed is * a number that can be wrong — one project was set to **2** and reported as * configured (OBE-236). Leaving the field on this signature would have offered a * parameter the server ignores, which is a worse failure than the one it replaced, * because it silently succeeds. */ set: (opts: { embeddingModel?: string; rerankModel?: string; }) => Promise; /** How many floats a model returns, measured by embedding one word. No provider * publishes it, and a wrong one fails at the first ingest rather than here. */ probe: (model: string) => Promise<{ dim: number | null; error?: string; }>; }; /** Who reads a page with no text layer. * * What NEEDS reading that way is not a setting: every PDF page is checked for a text * layer and those are extracted locally in about 150ms for nothing. This picks who reads * the rest — scans, photographed pages, images. * * - `auto` (the default) — the project's default model when it can be handed a page, * otherwise local OCR. Registering a vision model is all it takes. * - `model` — a specific vision/OCR model, called with the project's own key. Refused at * save time if the model's registered facts say it cannot be handed a page. * - `local` — never send a page to a model. Free, and lower quality on hard scans. * * `ocrComplexPages` additionally sends pages that DO have a text layer but a flattening * layout (tables, multi-column) to the model, at one call per such page. Ignored unless * the resolved engine is a model. */ documentProcessor: { set: (opts: { ocr: "auto" | "model" | "local"; ocrModel?: string; ocrComplexPages?: boolean; }) => Promise; }; /** What happens when a conversation outgrows the model's window. */ context: { get: () => Promise; set: (opts: Record) => Promise; }; /** Spend and rate caps on this project's LLM key. Enforced by the biller, so they hold * even when the usage views cannot be read. */ limits: { get: () => Promise; /** * Set the project's ceilings. Nulling every one of them REMOVES the spend cap, so the * API refuses without `confirm: true` — uncapping spend is the widening nothing * downstream notices until the invoice. * * `confirm` was accepted at runtime and absent from this type, which is the worse half * of the two: a JavaScript caller stumbled into it working while a TypeScript caller * could not write it without a cast, and neither had any way to know which they were * (OBE-63). */ set: (opts: { maxBudget?: number | null; /** `null` clears the reset window, which turns the budget into a running total. * * Nullable, like its three neighbours. It was `string`, so the docs' own * clear-everything line — `{maxBudget: null, budgetDuration: null, confirm: true}` — * did not compile while the API answered it 200, which is the same split the * `confirm` note above describes (OBE-195). */ budgetDuration?: string | null; tpmLimit?: number | null; rpmLimit?: number | null; confirm?: boolean; }) => Promise; }; /** Which models a delegate may run on, and how many may run at once. Without this the * subagents capability stays unavailable however it is granted. */ subagents: { set: (opts: { models: string[]; maxConcurrent?: number; }) => Promise; }; /** Procedures you publish as Agent Plugins, and what your end-users have added. */ skills: { list: () => Promise; /** * `zip` takes the same shapes as every other upload here — a Blob/File, an * ArrayBuffer or a Uint8Array — and `filename` goes in the options object. * * It was `Blob | File` with a positional filename, alone among four upload methods. * `await readFile("skill.zip")` (a Buffer) died as `parameter 2 is not of type 'Blob'` * inside undici with nothing naming Oberik in the stack — the identical failure that * was fixed for `documents.upload` and left here — and passing `{ filename }` in the * second slot, as the siblings take it, silently named the file `[object Object]`. */ upload: (zip: UploadInput, opts?: { filename?: string; }) => Promise; delete: (pluginId: string) => Promise; }; /** MCP servers whose tools join this project's catalog. */ mcp: { list: () => Promise; create: (opts: { name: string; url: string; transport?: "streamable_http" | "sse"; headers?: Record; /** * Register even if the server does not answer. * * Registration probes the URL first and refuses one that cannot work, in the words * the listing would otherwise have used a call later — "answered a web page" for a * docs URL, the resolver's own sentence for a host that does not exist (OBE-275). * * `force` is the retry path, not a way around the check: a server that is merely * down at this moment is not a mistake, and it will start working without being * re-added. A probe that cannot run accepts on its own, so this is only needed when * the server genuinely answered wrongly. */ force?: boolean; }) => Promise; delete: (mcpId: string) => Promise; }; /** Conversations, and what was said in them. */ sessions: { /** * Conversations on this project, newest first. * * A PAGE, and typed as one. This declared `Promise` and returned * `{items, has_more, next_offset}`, so a TypeScript caller wrote `.length` and got * `undefined` — no type error, no exception, and a census that counted sessions * reported zero (OBE-147). The declared type is the envelope it has always been. * * It also forwarded nothing at all, which made its own paging unreachable: the * response carried `has_more` and `next_offset`, the two fields whose only purpose is * to be fed back in, and there was nowhere to feed them. Past 100 sessions the tail * was unreachable through this client. * * `includeSubagents` is the one that cost somebody an hour: a delegate's working * conversation is excluded by default (correct — a sidebar built from here grew a row * per delegate), and with nothing forwarded, the admin-scope client an operator audits * with was the one that could not ask for them. */ list: (opts?: ProjectSessionQuery) => Promise; messages: (sessionId: string) => Promise; }; /** Scheduled work this project's end-users have created. */ tasks: { list: () => Promise; }; /** * What the agent has written down: remembered facts and wiki pages. * * `kind` defaults to `"wiki"` — the route's default, and the reason this looked like * it "returns pages only": it does, and there was no argument here to ask for the * memories. Same shape and same type as the end-user client's `knowledge.list`, so the * two do not disagree about what a stored item is. */ wiki: { list: (opts?: { kind?: "memory" | "wiki"; }) => Promise; /** Delete a wiki page or memory. What the agent wrote is not stored anywhere else. */ delete: (itemId: string, confirm: { confirm: true; }) => Promise; }; /** Live sandboxes, and what to do about one. */ sandboxes: { list: () => Promise; action: (sessionId: string, action: "pause" | "resume" | "delete") => Promise; }; /** * What this project's sandboxes may spend: how long one command may run, and how many * sandboxes it may hold at once. * * `execTimeoutS` is this project's OWN default for a command, and that is worth saying * because `sandbox.md` reads as if the numbers were the deployment's and fixed: it says * to read `exec_default_timeout_s` and `exec_max_timeout_s` off `GET /computers/host` * "rather than hard-coding these". The default those report is this. Both clamp against * the operator's ceiling and never past it. * * `null` on either field means "use the deployment's". This route existed, the dashboard * had it, the SSH gateway had a command for it, `GET /projects/:id` returned its value — * and it was the one setting on this client with no method, so the only way to reach it * was `raw("PUT", "/computer", …)`. */ computer: { get: () => Promise<{ execTimeoutS: number | null; maxSessions: number | null; }>; set: (opts: { execTimeoutS?: number | null; maxSessions?: number | null; }) => Promise<{ execTimeoutS: number | null; maxSessions: number | null; }>; }; /** Prepended to every request for this project, above anything a caller sends. */ systemPrompt: { /** * The prompt as it stands, which `set` tells you to read first. * * The API's own refusal says "read the current one with `prompt show`" — a CLI * command an SDK caller cannot run, leaving the instruction unfollowable through the * library that carries it (OBE-263). `origins.set` gives the same replace-not-merge * advice and has always had the `get` to take it. * * `null` when the project has none. */ get: () => Promise<{ systemPrompt: string | null; }>; /** * Replace the project's system prompt. * * An EMPTY string DELETES it, and that applies to every request the project ever * serves — so the API refuses without `{ confirm: true }` and says what it would * remove. That option was missing here, exactly as it had been on `origins.set` * (OBE-23), and with the same consequence: clearing a system prompt was impossible * through the method that exists for it, because the second argument was not merely * unsupported — it was silently ignored. `raw("PUT", "/system-prompt", …)` was the only * way out, which is not a library, it is a workaround (OBE-63). */ set: (systemPrompt: string, opts?: { confirm?: boolean; }) => Promise; }; /** Browser origins allowed to call the data plane with this project's tokens. */ origins: { /** What is allowed right now — the first thing you want when a browser request is * being refused, and previously unanswerable without the dashboard. */ get: () => Promise<{ origins: string[]; }>; /** * Replace the whole list. Read it first — this is not a merge. * * An EMPTY list means no origin restriction at all: every origin may call the API with * this project's tokens. Clearing a non-empty list is therefore a widening that nothing * downstream can notice — every existing caller keeps working — so it takes * `{ confirm: true }`. Without it the API answers 400 saying how many origins it would * have removed. * * That option was missing here, which made `origins.set([])` impossible from this * library: a deploy script that resets the list — the pattern "a project as code" is * about — could only do it by hand-rolling the request. */ set: (origins: string[], opts?: { confirm?: boolean; }) => Promise; }; /** Tools the agent calls by URL. The signing secret comes back once, on create. */ webhookTools: { list: () => Promise; create: (tool: { name: string; description: string; url: string; parameters?: Record; headers?: Record; }) => Promise<{ id: string; secret: string; }>; /** Change a tool WITHOUT rotating its secret. * * There was no update at all, so moving a URL after a tunnel restart meant * delete-and-recreate — which issues a new signing secret, shown once, that your * handler then has to be redeployed with. A URL change is not a credential * rotation. `name` is what the model calls and is not editable. */ update: (toolId: string, patch: { url?: string; description?: string; parameters?: Record; headers?: Record; }) => Promise; /** A new signing secret, with the old one still verifying for 24 hours. */ rotate: (toolId: string) => Promise<{ secret: string; previousSecretUntil: number; }>; /** Recent calls to your handlers: when, which tool, what came back, how long. */ deliveries: () => Promise; delete: (toolId: string) => Promise; }; /** The key that signs `turn.stopped` and scheduled-task deliveries to you. */ webhookSecret: { get: () => Promise<{ secret: string; }>; rotate: () => Promise<{ secret: string; }>; }; /** Server-side keys. A created one is returned once and never again. */ /** * Checks on what goes into the model and what comes back. * * The enforcement has existed for a long time and there was no way to configure it — no * route, no dashboard section, no column — so a documentation page described switches * that could not be reached. `set` takes a partial: what you do not mention is left as it * is. */ guardrails: { get: () => Promise; set: (policy: GuardrailUpdate) => Promise; }; /** * Whether this project can actually answer a question yet. * * A new project has no models, so it can neither answer nor index anything — and the * flag that used to be the closest thing to this (`hasLlm`) meant "a LiteLLM key was * provisioned", which is true from the moment a project exists. Every unfinished step * names what it blocks and the one call that fixes it. * * Worth calling in a deploy check: a project that is not ready fails every request with * the provider's own error, which reads as your bug rather than as missing setup. */ readiness: () => Promise; /** The starting snippet and this project's endpoints — the same one the dashboard and the * SSH gateway show, so there is one of it rather than three. */ connect: () => Promise; /** * Further project keys. * * `scope` is the important argument and it defaults to the narrow one. A `mint` key * can turn your signed-in user into an end-user token and nothing else — it cannot * read the corpus, raise the capability ceiling, issue more keys, or delete the * project. That is what almost every backend actually needs, and it is the difference * between a leaked key costing you some tokens and costing you the workspace. */ keys: { list: () => Promise; create: (name: string, opts?: { scope?: ProjectKeyScope; }) => Promise<{ id: string; /** The name you gave it. */ name: string; /** The first characters of the key, e.g. `pk_ab12`. The API has always returned it * and this type omitted it — and it is the only visible part of a key that exists * after the one moment `key` is shown, so it is what a UI can display next to * "revoke" for the rest of the key's life. */ prefix: string; scope: ProjectKeyScope; /** Shown once, at creation. Store it now or issue another. */ key: string; }>; /** Delete a key — `DELETE /keys/:keyId` on the raw API. */ revoke: (keyId: string) => Promise; }; /** Spend, requests, tokens and latency — including per end-user, since spend is * attributed to the token's subject. * * Both returned `unknown`, which meant every caller wrote its own guess at the shape. * The two things worth knowing before you bill from these: * * - `metricsAvailable: false` means the figures are **unknown, not zero**. Read it before * you read a number. * - `spendBilled` is the cumulative charge from the LLM gateway's billing record and is * the figure to invoice from; the windowed `totals.spend` is metered from the metrics * pipeline, which loses up to one bucket around a collector restart. */ usage: { summary: () => Promise; observability: (windowSeconds?: number) => Promise; }; } /** One point of a usage time series. `t` is unix ms at the bucket's START, so a point * timestamped 13:00 with an hourly step covers 13:00–14:00. */ export type UsagePoint = { t: number; v: number; }; /** Fields every usage response carries: what the window was, and whether it can be * trusted. */ export type UsageMeta = { /** Bucket width of the series, in seconds. */ stepSeconds: number; /** Unix ms of the bucket still filling, or null. It is the last point of every series and * it covers a period that has not finished, so it is always lower than its neighbours — * draw it as in-progress rather than as a drop in usage. */ partialFrom: number | null; /** False when the metrics backend could not be read. Every figure is then unknown, NOT * zero. */ metricsAvailable: boolean; /** Why, when it could not be read. */ metricsError: string | null; /** Metrics-collector restarts inside the window. Around each, up to one bucket of usage is * undercounted — `spendBilled` is unaffected. */ metricsRestarts: number; /** Boundaries where nothing could be read at all: the window has a hole in it, and the * usage in those buckets is missing rather than zero. */ metricsGaps: number; }; /** Cumulative spend from the LLM gateway's billing record — the figure spend caps are * enforced against, and the only one that survives a gateway restart. */ export type BilledSpend = { /** Null when the billing record could not be read. Never rendered as 0. */ spendBilled: number | null; /** What it covers: `"all time"`, or `"per 30d"` when the project has a reset window. */ spendBilledPeriod: string; }; /** `GET /usage` — the project at a glance. */ export type UsageSummary = UsageMeta & BilledSpend & { windowSeconds: number; spend24h: number; tokens24h: number; requests24h: number; spendSeries: UsagePoint[]; hasTenant: boolean; }; /** Spend, tokens and requests for one row of a breakdown. */ export type UsageFigures = { spend: number; tokens: number; requests: number; }; /** `GET /observability` — everything, over one window. * * Every figure is derived from the same readings, so `totals` equals the sum of its series * and the sum of either breakdown. A model with no known price reports real tokens and * requests against a spend of 0. */ export type Observability = UsageMeta & BilledSpend & { windowSeconds: number; totals: UsageFigures; spendSeries: UsagePoint[]; tokenSeries: UsagePoint[]; requestSeries: UsagePoint[]; latencyP95Series: UsagePoint[]; byModel: Array; /** Per end-user, largest spend first — the subject of the token that made the calls. * * `unattributed` marks a row that no actor is behind. Every billable call names one: the * token's subject, the service principal when your backend calls with an API key, or the * platform itself for work it does on its own behalf. So a row with `unattributed: true` * is a **defect on our side**, not a kind of usage — please report it. */ byUser: Array; data: { documents: number; tasks: number; tasksActive: number; }; }; /** Factory helper for the server-side client. */ export declare function createProjectClient(opts: ProjectClientOptions): OberikProject; /** Factory helper. */ export declare function createClient(opts: ClientOptions): AgentFramework; export default AgentFramework; //# sourceMappingURL=index.d.ts.map