Skip to main content
Someone is on the other end. There are three ways to answer them, and they are the same turn:
  • 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-stream Response, 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
Label the risk: 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.
Awaiting it gives the 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
Three things are doing work there.
  • user.id is 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.headers forwards the caller’s own credentials, so a tool call reaches your API as the person signed in rather than as the agent.
  • user is 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 on x-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
A 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. A destructive or ungraded call raises an approval, and what happens next depends on which verb is driving.
A Maple turn paused mid-stream on an approval card for a $200 transfer, with Approve and Deny buttons
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.
Behind 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.