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.
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_input | complete | |
|---|---|---|
completed | false | true |
needs_answer / needs_approval / needs_client_tool / needs_browser | true | false |
hit_tool_limit / subagents_incomplete / no_answer | false | false |
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.
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:
| Delivery | event | Sent when |
|---|---|---|
| the project's turn-stopped webhook | turn.stopped | a turn finishes or needs a person |
a scheduled task's action.callback_url | scheduled_task.fired | an agent task ran |
| a scheduled task's webhook action | scheduled_task.fired | a 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.
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.
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.