Recipes
One working call per task, and a link to the page that explains why it looks like that. Everything assumes a client:
import { createClient } from "@oberik/sdk";
const ai = createClient({ getToken }); // see Authentication
Answer a question over the customer's own documents
const res = await ai.chat.send({ message: "What's our refund window?", tags: ["policies"] });
res.content;
res.citations; // document · page · quote — the passages retrieved
res.claims; // which sentence came from which passage
Naming tags (or document_ids) also switches web search off for that turn, so the
answer cannot quietly come from the internet. → Chat
Stream the answer into a UI
const h = ai.chat.stream({ message, session_id }, {
onToken: (_d, full) => setText(full),
onCitations: setSources,
onTodos: setPlan,
});
const done = await h.done;
h.cancel() stops generation (and the spend); h.disconnect() just stops listening.
→ Chat
Let the model call your own functions
const ai = createClient({
getToken,
tools: [{
name: "cancel_order",
description: "Cancel an order belonging to the signed-in customer.",
parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
handler: async ({ order_id }) => {
await assertOwnedBySignedInUser(order_id as string); // arguments are model-generated
return await orders.cancel(order_id as string);
},
}],
});
await ai.chat.run({ message: "Cancel my most recent order" });
run (and stream) dispatch the call, submit the result and continue until the agent is
done. → Tools
Curate a corpus your users can only read
# Once, from your server — the project owns these, not any one user.
curl -sX POST "https://oberik.com/api/projects/$ID/documents" \
-H "X-API-Key: $KEY" -F file=@handbook.pdf -F tags=policies
curl -sX PATCH "https://oberik.com/api/projects/$ID" \
-H "X-API-Key: $KEY" -H "content-type: application/json" \
-d '{"capabilities":{"documentsMode":"read"}}'
With documentsMode: "read", no token minted for this project can ever carry
documents:write. → Project API · Documents
Keep one customer's users out of each other's data
// Two users of the same customer, in one project.
mint({ subject: "acme:fin:ana", scope: "acme:fin:ana" }); // only Ana's own
mint({ subject: "acme:fin:lead", scope: "acme:fin" }); // the whole finance team
A token sees its own subtree and nothing above it. Use a project per customer for hard isolation, scopes for the hierarchy inside one. → Scopes
Let the agent run code
await ai.chat.send({ message: "Convert these CSVs to one parquet file and send it back" });
// -> res.attachments: [{ id, kind, url, name, mime_type, size }]
The sandbox is provisioned only if the agent actually uses it, and it's bound to the chat session, so the next turn is in the same workspace. → Sandboxed compute
Stop it before something irreversible
await ai.chat.stream({ message: "email the Q3 numbers to finance" }, {
onApproval: async (req) => showDialog(req), // true | false | { approved, note }
});
Without onApproval the turn returns with approvals set and you resume it with
approval_decisions — nothing is approved by default, and tool auto-dispatch never sees
these. → Asking permission
Run something on a schedule
await ai.tasks.create({
name: "Monday digest",
kind: "recurring",
cron: "0 8 * * 1",
timezone: "Europe/Istanbul",
action: {
type: "agent",
prompt: "Summarize last week's support tickets.",
callback_url: "https://app.acme.com/hooks/oberik", // or poll ai.tasks.get(id)
},
});
Scheduled runs never bind ask_user or approvals: nobody is watching, so a turn that
paused would wait forever. → Scheduling
Start a turn when something happens in another system
curl -sX POST https://api.oberik.com/triggers \
-H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"prompt":"Triage this ticket: {{ body.subject }}","signed":true}'
# -> { "url": "https://api.oberik.com/triggers/<slug>", "secret": "…" }
prompt is a template filled in from the event, so the sending system needs to know
nothing about talking to an agent. The URL is the credential and the run happens as
whoever created the trigger, so treat it like a password; signed: true additionally
requires a matching X-Signature. Firing answers {"accepted": true} immediately rather
than waiting for the turn — a webhook sender retries a slow response, and that would run
the agent twice on one event. → Webhook triggers
Let the agent do something in a scheduled run
A client-side tool needs a client. A scheduled run has none — so publish the tool as a URL and Oberik calls it server-side:
curl -sX POST "https://oberik.com/api/projects/$ID/webhook-tools" \
-H "X-API-Key: $KEY" -H "content-type: application/json" -d '{
"name":"open_ticket","description":"Open a support ticket for the signed-in customer.",
"url":"https://acme.com/agent/tickets",
"parameters":{"type":"object","properties":{"subject":{"type":"string"}},"required":["subject"]}}'
Your endpoint gets the arguments and the end-user's subject, scope, roles and
groups, with an X-Oberik-Signature over the body so you can trust them. →
Tools you host
Call it from a browser
curl -sX PUT "https://oberik.com/api/projects/$ID/origins" \
-H "X-API-Key: $KEY" -H "content-type: application/json" \
-d '{"origins":["https://app.acme.com"]}'
Exact origins, no wildcards, and the token is still minted on your server. → Calling from the browser
Show the user what a long turn is doing
ai.chat.stream({ message }, {
onTodos: setChecklist, // the plan, as it's written and ticked off
onToolStart: (n) => setStatus(n),
onCommandOutput: (c) => appendLog(c.delta), // a build or test run, live
onSubagents: setWorkers,
});
→ Working with the user · Subagents
Correct it without waiting for it to finish
const h = ai.chat.stream({ message: "migrate every fetcher" }, { onToken: write });
const landed = await h.steer("only the forfaits-* ones");
if (!landed) { /* the turn ended first — send it as a normal message */ }
→ Steering
Cap what a single turn may cost
Per request, a client may only ever ask for less:
await ai.chat.send({
message,
model: "openai/gpt-4o-mini",
reasoning_effort: "low",
enable_computer: false,
enable_subagents: false,
});
The ceilings that a client cannot raise go on the token, at mint time —
maxToolIterations, maxEffort, maxContextTokens, models. There is no
platform-imposed tool-loop limit, so this is the place to set one. →
Bounding a run
Teach it one of your procedures
// From your server, with the project key. Applies to everyone in the project.
await oberik.skills.upload(zip, { filename: "acme-support.zip" });
// From your app, with an end-user's token. Private to them; needs `plugins:write`.
await ai.plugins.upload(zip); // a packaged plugin, a zipped folder, or one SKILL.md
One feature, two clients, and they are spelled differently: the project client calls it
skills, the end-user client calls it plugins. → Skills
Erase a user
await ai.audit.forget("acme:fin:ana");
// -> { subject, documents_deleted, sessions_deleted, sandboxes_destroyed,
// tasks_cancelled, vectors_purged, objects_deleted, rows_deleted }
Every row that names them goes — documents, conversations, sandboxes, scheduled tasks,
triggers, background jobs, uploaded plugins — and anything with a life outside the
database is torn down rather than merely deleted: a sandbox is destroyed on its host, a
schedule is cancelled with the scheduler, and their stored files are removed along
with the rows that named them, unless something that survives still points at one.
rows_deleted reports what each table gave up; objects_deleted, how many files went.
Find the conversation that went wrong
const failed = await ai.chat.sessions.list(undefined, { status: "error", limit: 20 });
for (const s of failed.items) console.log(s.id, s.last_model, s.last_error);
// narrow further — a search over id, title, user or model
await ai.chat.sessions.list(undefined, { q: "invoice", model: "gpt-5" });
// what the model filter should offer, read from the traces themselves
await ai.chat.sessions.models();
Every session carries what its last turn ran on and whether it worked, so finding a broken conversation does not mean opening them one at a time. Filtering happens server-side — the rows you want are a handful out of thousands.
Find out what the agent could actually do right now
const { tools } = await ai.tools.list();
Filtered exactly the way a turn is — ceiling ∩ token ∩ flags — so it answers "what would my next request get?" rather than "what exists". → Tools