Skip to main content

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.

Guardrails & auditing

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