chat(message)hands back the answer. No route, no stream, no wiring.handler({ basePath, resolveUser })is the whole agent on one route, for a browser to talk to.respond(subject, message)is one turn as an AI SDK UI-message-streamResponse, for when you own the route already.
Give it hands
api() is your own API, read from .vendo/tools.json. tool() is anything
you would rather write yourself, with its own auth model.
lib/agent.ts
read, write, or destructive, and your label is final.
Leave it off and the tool is ungraded, which the guard asks about every time,
exactly as it does for destructive.
Scope it to the caller: the orderId came from the model and
ctx.principal is who the turn acts as, so bind the two yourself. An unscoped
lookup acts on someone else’s order however the approval card was answered.
One turn, in a line
chat() runs the turn and gives you what it answered.
TurnResult — what it said, on which thread, with which
tool calls. Skip the await and you hold the turn itself instead: threadId
and turnId are readable straight away, and events is the live feed while
it runs. The turn happens either way.
One route, the whole agent
handler() serves the chat turn, the thread list and transcript, and the
approvals wire off one mount. It is a fetch handler, so it goes on any server
that can hand it a Request.
app/api/agent/[[...path]]/route.ts
resolveUser is where identity is configured, once. Return null and the
mount answers 401; the subject you return is what every thread, grant and
audit row on that request is scoped to, and a thread id belonging to somebody
else reads back as absent.
In the browser,
useVendoChat from @vendoai/ui talks to that mount and keeps
nothing of its own — the transcript, pending approvals included, is read back
from the server, so a reload loses nothing.
app/support/page.tsx
Your own route
app/api/support/route.ts
user.idis the subject. Every thread, every workspace file, and every audit row is scoped to it, and Vendo mints no identity of its own.headers: req.headersforwards the caller’s own credentials, so a tool call reaches your API as the person signed in rather than as the agent.useris server-trusted identity the model may read, printed as[User]in the prompt. It is facts, not instructions.
Continue the conversation
The response carries the thread id onx-vendo-thread-id. Keep it and hand it
back on the next turn.
Turn 1
No thread id in the body. Vendo mints one.
Response
x-vendo-thread-id: thr_…
Turn 2
Send that id back. Same conversation.
app/support-client.ts
threadId is checked against the same subject that owns it. Someone else’s
thread, or one that never existed, comes back not-found rather than as a
silent new conversation.
Approvals
A turn can stop for a human. Adestructive or ungraded call raises an
approval, and what happens next depends on which verb is driving.

chat() and run() end the turn. The result comes back interrupted
with the cards on it, in the time the turn actually took. Answer them and the
turn carries on from exactly where it parked — a denied call is a refusal the
model reads, never a rerun.
handler() this is already wired: useVendoChat surfaces the same
cards as interruptions and its resume(decisions) posts them to the mount’s
approvals wire. The interruptions survive a reload, because they are read
back from the server rather than kept in the browser.
respond() and session() hold the stream open. The card goes down the
stream and the turn waits 90 seconds for a decision. Approve and the same call
runs with the same arguments; deny, or let the wait expire, and the model is
told so and carries on.
No card to render? session() is respond() with the object kept, and the
same decision arrives as an event your backend answers itself.
app/api/support/route.ts
request carries the call, its arguments, the tool’s descriptor, and the
principal the turn acts as. The wait is the same 90 seconds, so decide inside
it. Full shape: Server API.
Answer it tomorrow
turn.resume() is a closure, so it dies with the process. The ask does not: a
server restarts, the turn ran on a queue worker, the person answers on Monday.
forUser(subject).turns addresses a parked turn by id instead, from
anywhere over the same store.
turns.resume hands back the result, not a turn: it reads the store before
it can start anything, and a Turn is itself awaitable, so there would be no
handle left to hold. The answer is prose either way — an output schema
belonged to the code that called run({ output }) and was never persisted, so
resume through the result you are holding to keep the shape.
Every interruption the turn parked needs a decision in the same call; a partial
map is refused, naming the ids it is missing. A parked turn waits seven
days, and a resume after that fails saying the ask expired rather than acting
on a week-old yes.
Run
The same agent, unattended, through the other verb.
API tools
What
vendo init extracted, and how to change a grade or a name.