Skip to main content

Working with the user

Five capabilities for the back-and-forth around a turn. Two are things the agent does (keeping a plan, asking a question), one is something the user does mid-turn (steering), and two are affordances for your UI that the agent never sees at all.

Todos — the agent's plan

todo · per-turn flag enable_todo

For work with several distinct steps, the agent writes a checklist and updates it as it goes. The list is stored per chat session and injected into the agent's context each turn, so it survives a tool loop, a resumed stream, or a restart — and the agent never spends a call reading it back.

const done = await ai.chat.stream({ message: "migrate every fetcher" }, {
onTodos: (items) => render(items), // fires as each step is written or ticked off
});
done.todos; // final state
await ai.chat.sessions.todos(sessionId); // or read it any time

Each item has a number that is stable for the life of the session and never reused, so ticking off item 3 still means item 3 after item 1 is deleted.

The agent is told to open a list only for genuinely multi-step work — a question it can answer or a one-step edit gets none, because a one-item checklist is noise.

Asking permission

approvals · per-turn flag enable_approvals

Every other gate here is decided before the turn starts — a project ceiling, a capability on the token, a per-request flag. That is the right shape for "may this agent use a sandbox at all" and the wrong one for "may it run this command", because the thing being judged does not exist until the model proposes it.

So the agent can stop and describe exactly what it is about to do, and nothing happens until a person answers:

await ai.chat.stream({ message: "email the Q3 numbers to finance" }, {
onApproval: async (request) => {
// request.action — one line, with the specifics in it
// request.detail — the message it would actually send
// request.consequence — what cannot be undone
return await showApprovalDialog(request); // true / false / {approved, note}
},
});

Without onApproval the turn simply returns with approvals set and you resume it with approval_decisions — which is the right default. Nothing should be able to approve an irreversible action by accident, so tool auto-dispatch never sees the call being decided about: an approval arrives with requires_action, and whatever it is holding back is not in tool_calls.

One entry per decision, and only approved is required:

{
"session_id": "…",
"approval_decisions": [
{
"approved": false,
// Omit when only one approval is outstanding, which is the common case.
// Otherwise it is the `tool_call_id` from the matching `approvals[]` entry.
"tool_call_id": "call_abc123",
// Optional. The difference between "no" and "no, use the other address".
"note": "use finance@acme.com instead"
}
]
}

An outstanding approval you send no decision for stays outstanding: the turn pauses again rather than treating silence as either answer.

Saying no stops the work. The agent is told not to try another way, not to reach for a different tool that achieves the same thing, and not to continue with any part of the plan that depended on it — a refusal is a decision about the outcome, not about how it asked. It comes back and asks what you want instead. A note on the decision is the difference between "no" and "no, use the other address".

The model decides when to ask — which is weaker than a rule that fires whether it cooperates or not. It buys the large middle ground where the agent knows perfectly well that sending the mail is the irreversible step. For the cases where "usually asks" is not good enough, mark the tool instead.

A tool that cannot be called without asking

requiresApproval on one of your own client tools makes the pause a property of the tool rather than a decision the model gets to make:

const ai = createClient({
getToken,
tools: [{
name: "cancel_booking",
description: "Cancel a booking. Irreversible: the slot is released to the market immediately.",
parameters: {
type: "object",
properties: { booking_id: { type: "string" } },
required: ["booking_id"],
},
requiresApproval: true,
handler: async ({ booking_id }) => cancel(booking_id as string),
}],
});

Now a call to it comes back as an approval carrying the call itself, and the handler is unreachable until somebody says yes:

{
"requires_action": true,
"tool_calls": [], // ← the call is NOT here yet
"approvals": [{
"tool_call_id": "call_abc123",
"action": "Cancel a booking. Irreversible: the slot is released to the market immediately.",
"detail": "booking_id: NW-3009", // the arguments — this is what is being decided
"tool": "cancel_booking", // null when the MODEL asked, via request_approval
"arguments": { "booking_id": "NW-3009" }
}]
}

Approve it and the identical call arrives in tool_calls on the next round and runs normally — the SDK's auto-dispatch does the second round for you. Refuse it and the agent is told, in the same words as any other refusal, that stopping was the point.

Why it is a different thing from the paragraph above: this gate fires because the tool was called. A model that has never heard of request_approval, or that simply does not think to ask, cannot get past it — the only way to skip it is not to declare the tool. That matters because "usually asks" is measurable, and what was measured was a tool described in as many words as "Cancel a booking. Irreversible" being called straight through, twice, on a project that granted approvals, past an onApproval handler that refused everything and was never once consulted. Nothing was broken; the model just did not ask.

requiresApproval needs no capability and no flag: you are declaring a property of your own tool, not asking for permission to have one. action is the tool's description, so write that sentence for the person who will read it — it is already the sentence you wrote to tell the model when the tool applies.

Subagents never get it: there is no user on a delegate's turn, so it asks the main agent, and the main agent asks you if it comes to that.

Questions the agent asks

ask_user · per-turn flag enable_ask_user

When a decision is genuinely the user's and guessing wrong would waste real work, the agent can stop and ask. The turn pauses and comes back with requires_action and a questions array — and an empty tool_calls, because a question is not work for your client to execute.

const r = await ai.chat.send({ message: "migrate the fetchers" });
if (r.questions.length) {
const batch = r.questions[0];
// batch.questions[0] → { header, question, options[], multi_select, allow_other }
await ai.chat.answer(r.session_id, {
tool_call_id: batch.tool_call_id,
answers: [{ question_id: "Scope", selected: ["box-* too"] }],
});
}

Two escapes are always available and are not the agent's to remove: free text (text), and declining the picker entirely (chat_instead: true), which tells the agent to drop the question and keep talking rather than re-ask it.

Only where someone can answer

A paused turn waits forever if nobody is there. Set enable_ask_user: false for batch jobs, webhooks and scheduled runs — scheduled tasks never bind it for this reason.

With chat.run or chat.stream, pass an onQuestion handler and the pause is answered and resumed for you.

Steering — a message into a running turn

steer

Correct the agent mid-flight instead of watching it finish work you can already see is wrong.

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 */ }

The message is picked up at the agent's next step boundary, after any tool in flight finishes. That is deliberate: cancelling a half-done tool call would leave a call nothing answered, and that state fails every later turn on the session permanently. It arrives as a normal user message, so it cannot outrank your project's own system prompt, and it is stored with extra.steered so the transcript shows why the agent changed course.

A true means it will be acted on. If your message lands while the model is already writing its answer — which is most of the window on a fast tool — the turn takes one more step for it rather than finishing and dropping it. So the two answers mean exactly what they say: true, the agent will see it before this turn ends; false, the turn was already over and it is yours to send as a normal message.

And done.steered says what actually landed. true is about the message being accepted; this is about the agent having read it:

const done = await h.done;
done.steered; // ["only the forfaits-* ones"] — empty on an ordinary turn

Read it before you tell the user their correction took effect. It also explains something about content that surprises people: a steered turn speaks twice. The agent answers the instruction it had, you correct it, and it answers again — so content holds both, because it is the same text onToken gave you. If you want them separated, render chat.sessions.messages: the correction is a user row between the two answers, marked extra.steered. And if your tests assert "the reply is exactly X", remember that a corrected turn is not one reply.

Only streaming turns can be steered — a blocking /chat call has no run to send to.

Queueing needs no capability. Holding a message until the turn ends is just your next send. If you send several at once, pass them as messages and they arrive as separate user messages with one reply:

await ai.chat.send({ session_id, message: "start it", messages: ["skip archived"] });

Knowing when the agent stopped

A turn ends one of two ways, and the difference is everything: it finished, or it is waiting on a person. A connected client sees both on its stream. One that isn't — a closed tab, a backgrounded phone, a scheduled run nobody was watching — sees neither, and an agent blocked on a question nobody knows was asked just sits there.

Set a turn-stopped webhook on the project and the server POSTs when either happens:

{
"event": "turn.stopped",
"reason": "needs_client_tool", // completed | needs_answer | needs_approval |
// needs_client_tool | needs_browser |
// hit_tool_limit | subagents_incomplete |
// no_answer
"needs_input": true, // somebody is expected to act
"complete": false, // did this finish the work?
"scheduled": false, // a scheduled run: nobody was watching by definition
"tenant_id": "…",
"session_id": "…",
"user_ref": "your-own-id",
"preview": "Here you go. The Q3…", // the answer's OPENING, on a `completed` turn —
// at most 280 characters, never the whole thing.
// null when there was no prose to preview
"preview_truncated": true, // there is more; `preview` is a prefix
"content_chars": 1840, // how much there is, so you can decide whether
// fetching the session is worth it
"tools": ["lookup_order"], // when a client tool is why — you may be able to
// answer it without a human at all
"questions": [ // when `needs_answer` is why
{ "tool_call_id": "…", "headers": ["Scope"] }
],
"approvals": [ // when `needs_approval` is why — route it to
{ "tool_call_id": "…", // whoever is allowed to make the decision
"action": "Email the Q3 numbers to finance" }
]
}

Two booleans, so a receiver doesn't have to know every reason string — including ones added later. They are not opposites:

needs_inputcomplete
completedfalsetrue
needs_answer / needs_approval / needs_client_tool / needs_browsertruefalse
hit_tool_limit / subagents_incomplete / no_answerfalsefalse

Branch on the booleans, not the strings. They are computed from the turn's own state as well as from the reason, so a pause added after you wrote your handler still arrives as needs_input: true — with a reason string your code will not recognise, which is the harmless half of the problem.

A pause your own client answers is not delivered

The webhook is for the client that isn't there, but a turn stops before a connected client has had a chance to answer it. So a needs_input delivery on an interactive turn waits a few seconds and looks again: if the conversation has moved on — your onApproval or onQuestion handler dealt with it, which usually takes milliseconds — nothing is sent.

Without that, every approval handled in a browser also paged whoever your backend routes needs_input to, and there was no field on the payload saying it had already been decided.

Two consequences worth knowing: a genuine pause notification arrives a few seconds after the turn stops rather than instantly, and scheduled runs skip the wait entirely — nobody is watching those by definition, so they deliver immediately.

needs_input means somebody is expected to act — route it to a person or answer it from your backend. complete means the turn finished the work it was given. The last row is the one worth handling: the turn is over and nobody is waiting, but the answer is short of what was asked. subagents_incomplete means the turn ended while work it had delegated was still running, so part of the reply may be missing — see Subagents. no_answer means the turn produced nothing at all: no prose, no file, nothing to pause on. It is rare and it is not the user's fault — a provider dropping a message on a filter of its own is the usual cause — so the useful handling is a retry, or telling them it failed, rather than rendering an empty reply.

Private and metadata targets are refused, and delivery is best-effort — the reply is already saved, so a failed webhook costs a notification, not an answer.

Verifying a delivery

Every delivery carries X-Signature: sha256=<hmac> over the exact body with keys sorted, plus an X-Delivery-Id you can deduplicate on. Get the key with:

curl -s "$CP/api/projects/$OBERIK_PROJECT_ID/webhook-secret" -H "X-API-Key: $OBERIK_PROJECT_KEY"
# -> { "secret": "…" }
import { createHmac, timingSafeEqual } from "node:crypto";

const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const ok = timingSafeEqual(Buffer.from(expected), Buffer.from(req.header("x-signature")!));

Verify against the raw bytes, before any JSON parsing — re-serialising changes them. POST …/webhook-secret/rotate issues a new key; update your verifier and the next delivery uses it.

What this key covers

Three deliveries, one key, one scheme — so a single verifier handles all of them:

DeliveryeventSent when
the project's turn-stopped webhookturn.stoppeda turn finishes or needs a person
a scheduled task's action.callback_urlscheduled_task.firedan agent task ran
a scheduled task's webhook actionscheduled_task.fireda webhook task fired

All three carry X-Signature and X-Delivery-Id. A scheduled_task.fired body is:

{ "event": "scheduled_task.fired", "task_id": "…", "tenant_id": "…",
"session_id": "…", "message": "the agent's reply" }

X-Delivery-Id identifies one delivery: unique per notification, and stable across retries of that same notification. Deduplicate on it rather than on task_id or session_id — a retried delivery is the same reminder, not a second one, while two turns of the same conversation are two real notifications and both matter.

Not the same as a webhook tool

Webhook tools — the ones the agent calls as tools — sign with a per-tool secret, returned once when you create the tool, in a header named X-Oberik-Signature. Two schemes, two header names, two secrets. See Tools you host.

The dashboard playground pairs this with two notifications, because they reach different people. The Notification API covers a tab that is merely in the background. Web Push covers a tab that is closed — a service worker, a VAPID keypair and a subscription stored server-side — which is the case that matters, since a turn stopping because it needs you is exactly when you are not looking at it.

The playground runs the real thing rather than faking it. Three pieces, and each one is a command you can call, so the worked example is something you can watch rather than a description of one:

push key # the VAPID public key to hand a browser; blank when push is off
push subscribe # store one browser's subscription against an end-user subject
push hook # where the data plane POSTs `turn.stopped`, which sends the notification

Use them to see the shape, not as your own notification path. The notification they send links back into this dashboard's playground, which is the wrong destination for your product — your customer wants to land in your app. Everything else about the path is yours to copy: turn.stopped on a webhook, a lookup of who was waiting, a push. Nothing in it is privileged, and the three pieces are the three you will write.

It is off unless the deployment sets VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY (npx web-push generate-vapid-keys) — you need your own pair either way, because the keypair identifies you to the browser's push service. Those keys must stay put: rotating them silently invalidates every subscription already in a browser.

Naming a conversation

auto_title · per-turn flag enable_auto_title

Titles are empty by default, and that is deliberate. A conversation gets a name only if you set one or you switch this on — naming costs a model call on every new conversation, and a client that shows no sidebar, or knows its own subject already, should not pay for it.

Three ways to end up with a name, in the order they cost you anything:

// 1. You know what it is. Set it on the request that CREATES the session — nothing is
// generated, so this is free.
const r = await ai.chat.send({ message, title: "Q3 close — meal penalties" });

// 2. Let the agent name it. Needs the capability; happens once, from the first message.
const r2 = await ai.chat.send({ message, enable_auto_title: true });
r2.title; // "Meal penalty reconciliation" — on the turn that produced it

// 3. Rename whenever you like. No capability: it is your label for your own chat.
await ai.chat.sessions.rename(r2.session_id, "Week 12 payroll");
await ai.chat.sessions.rename(r2.session_id, null); // back to no name

title comes back on the response and on the stream's done frame, so a sidebar can label the row it just created without a second request. It is generated once — a conversation that already has a name is never renamed behind your back, which is why setting one up front skips generation entirely.

The name follows the conversation's language, and leaves filenames, ids and quoted values alone: a title exists to be recognised.

When the name is written

By default it is written from the user's first message, as they send it — alongside the turn, not after it. Streaming, that arrives as its own title event while the answer is still coming in:

await ai.chat.stream(
{ message, enable_auto_title: true },
{ onTitle: (name) => renameRowInSidebar(name) }, // fires mid-turn
).done;

title_from: "response" waits for the first exchange instead. It names slightly better, having seen what the agent made of the question — but it arrives only with done, and not at all for a turn that never gets there: a provider 429 or 500, a tool that raised, a run the user cancelled. Those conversations keep their placeholder until someone sends another message into them, and a chat whose first turn failed is exactly the one that never gets a second.

await ai.chat.send({ message, enable_auto_title: true, title_from: "response" });

Either way it is one call, once, and a name arriving late never overwrites one you set in the meantime.

What it is not

Not a summary — one line, for finding a conversation again in a list. If you want "where did we get to", that is the recap below, which is written for someone coming back and is regenerated each time you ask.

Follow-ups and the recap

followups · recap

Two UI affordances generated beside the conversation. Neither is stored, and the agent sees neither — that is the design, not an implementation detail.

// After the turn — never as part of it.
const { suggestions } = await ai.chat.sessions.followups(sessionId, 3);
// ["do the box-* ones too", "show me a diff first", "what breaks if I skip archived?"]

// When your UI decides the user has been away.
const { recap } = await ai.chat.sessions.recap(sessionId);
// "You were migrating the forfaits-* fetchers and hadn't decided about archived ones."

Follow-ups are written in the user's voice, for rendering as buttons. A clicked one is sent as an ordinary message and the agent is not told it was suggested — an agent that knew it had written the question would answer the one it meant rather than the one that was asked.

The recap is for someone returning after a while. When to ask is your call, because only your client knows the tab has been idle; the dashboard playground waits 90 seconds and renders it above the composer. It is not context compaction, which summarizes for the model and is replayed to it.

Both return empty on failure rather than erroring — a missing affordance is not worth breaking a chat over — and empty includes "took too long". Each is one model call with a deadline (30s by default), so suggestions: [] can mean the conversation has nowhere obvious to go or that the model did not answer in time; from a client's point of view those are the same thing, which is why they are not distinguished. Neither endpoint streams, so a bounded call is what lets you render buttons or nothing instead of a spinner: followups on a six-row conversation once ran past ten minutes, which is longer than any client waits.

Ask for them after the turn, not inside it. A turn that waited on a suggestion would make the user wait for something they never asked for.