Entry points
Type imports come from the umbrella, not the block packages. Importing the same type from
@vendoai/vendo/core is a TS2307 under strict linking, because it is not your dependency.
The split between the root and /server matters. createVendo and the types shaped for its config live on /server; the bulk of the types, including HostOAuthAdapter, live on the root.
createVendo
The Vendo handle
HarnessTurns carries stream, warm, workspace, threads (get, list, delete), and evictSubject.
Next.js
nextVendoHandler adapts the fetch handler to a Next.js route module. wellKnownVendoHandler forwards only this composition’s own door discovery documents and answers 404 for anything else.
mountVendo() and handleVendoRequest(request, env) are not package exports. They are functions vendo init writes into your own vendo/server.ts, and both just call vendo.handler(request).
Values built by another function
Three slots take a value another exported function builds. That is where knobs a host might look for oncreateVendo itself actually live.
policy, judge, and approvals were top-level createVendo keys, and so was the agent: { … } options bag. All four are gone, and passing one now throws at boot naming the slot it moved to. What the deployment curates — instructions, toolOutputCap, maxInitialTools, loadout — stays at the top level.
@vendoai/vendo/server exports
The sandbox adapter for e2b,
e2bSandbox(), ships on @vendoai/vendo/sandbox/e2b rather than the umbrella. With VENDO_API_KEY set and no sandbox passed, cloudSandbox fills the slot on its own.
eraseStore
The sanctioned deletion path across every store table, and the only supported way to delete rows from the append-only vendo_audit table.
createVendo({ files }). Defaulting it let a host with a wired bucket erase the rows and silently keep the objects.
Each call returns a map of per-table deleted counts. Full semantics are on Persistence.
Tool pack for your own agent loop
Two subpaths build the guarded Vendo tool pack for a host that keeps its own agent loop. Both are thin shims over one framework-neutral core.vendo.guardedTools, the same guard-bound registry Vendo’s own loop uses.
include and exclude match final namespaced tool names exactly, and exclude wins. @mastra/core is an optional peer dependency used only by the ./mastra subpath.
Tool outputs are the versioned envelopes documented in Embeds and envelopes.
@vendoai/vendo
The standalone backend agent. Every value and option type below is exported
from @vendoai/vendo. The shared block types its signatures name are not:
Harness, Principal, SandboxAdapter, ThreadId, ToolCall,
ToolOutcome, TurnId, VendoErrorCode, VendoStore and When come from
@vendoai/vendo, GuardRules from @vendoai/vendo/server, and
LanguageModel and UIMessage from ai.
agent(config)
The smallest useful composition is two keys:
Harness
Unset, the agent thinks in this process. Swapping in the Claude Agent SDK on this server is one line:machine: "local" is not a boundary, and it says so out loud: it prints a
warning on the first turn because the SDK’s Bash, Write and Edit are
auto-allowed and are not confined to the workspace, and there is no egress
allowlist on this path. It reads and writes what this OS user can.
The boxed path is the confined one, and it is not one line. claudeCode()
without machine needs a sandbox and a way back in — a VENDO_API_KEY alone
will not fill either on a standalone install:
Identity
as on chat() and run() is the subject the turn acts for. Every thread, grant, workspace file and audit row is scoped to it; unset, the agent acts as itself (vendo:agent:<name>). Behind handler() the same thing is configured once, in resolveUser.
forUser(subject, { profile, context }) binds that identity once and returns a user-scoped view — user.chat, user.turns, user.threads, user.memories — instead of repeating it on every call.
profile and context are FACTS about a person, so they are bound here and ride every call — including a turns.resume, where the parked request’s own context is gone and the facade’s is the resuming caller’s. headers are the authority of one request, so they stay per call and are never kept. A thread this user does not own reads back as not-found; threads.get(id).messages() is a separate read, so listing twenty conversations does not load twenty transcripts.
RunOptions.as is deprecated in its favour, and session() is deprecated in favour of forUser plus threads. respond() is not deprecated: it stays the one-liner for a route you already own.
Memory
memory: true on agent() gives the agent a durable memory of the person it is talking to. Reads are automatic: a capped [Memory] block goes into the prompt, read as the user’s own words and never as instruction. Writes are a visible, guard-bound remember tool, so anything the agent keeps about someone is a tool call in the transcript and an audit row, never a silent write. A MemoryAdapter in the same slot is used verbatim.
user.memories is { list, delete, clear } over the same rows — the person’s own view, which is why there is no add. On an agent with no memory configured all three throw, naming the line that turns it on, rather than answering empty: an empty list would read as “this person has no memories” when the truth is that nothing is remembering anything.
VendoAgent
door is present exactly when the harness thinks outside this process. Mount it at DOOR_PATH.
chat
One turn of a conversation, and the answer. Bare, the agent talks as itself; as names the subject it acts for.
handler
The whole agent as one fetch handler: the chat turn, the thread lifecycle, the approvals and grants wire, and the engine’s dial-back door. Mount it on one catch-all route.
HandlerUser is { subject, profile?, context? } — profile is the server-trusted identity the model may read. The routes it serves are in HTTP routes; the browser half is useVendoChat from @vendoai/vendo/ui.
The permission wire is mounted under this basePath, behind this mount’s resolveUser, rather than on the fixed PERMISSIONS_PATH — deciding an approval is what unblocks a parked turn, so it has to be reachable by the same person the turns belong to.
respond
One turn for a waiting person. Returns an AI SDK UI-message-stream Response with x-vendo-thread-id set.
It is
session(subject, options) plus stream(message) collapsed into one call, with the header stamped.
run
One non-interactive run.
Turn and TurnResult
Both chat() and run() hand back a Turn. It is returned rather than awaited, so the ids are readable while the turn is still going.
events. events has ONE reader — attaching a second one while the first is reading throws.
Awaiting the turn gives a
TurnResult: four ends and no fifth, as a discriminated union, so reading status once types everything you then touch.
text is the assistant’s own words. On the error arm it is empty, always: a turn that broke never spoke, and an internal failure does not get to wear the agent’s voice.
usage is { inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, model? }, and it is what you meter on.
Approvals and resume
A call the guard wants a person for PARKS, and parking ends the turn. Both chat() and run() answer interrupted in the time the turn took, with an Interruption per card and resume() to carry on.
Interruption is a two-arm union, so narrow on type before you read a card:
input arm is frozen but unemitted: ask_user ends the turn in v1 and the answer arrives as the next message, so nothing mints one yet. It is in the union because @vendoai/vendo/ui and the resume route both parse this shape, and a shape added later is a shape two shipped readers reject.
A Decisions map is keyed by id. An approval takes "approve" or "deny" — an { answers } value type-checks against it and is refused at runtime, so a shape nobody meant never lands as a denial nobody made.
resume() is not a rerun. The turn picks up byte for byte from where it parked, on the same turnId, and a denied call is a refusal the model reads and works around. ResumeOptions is { headers?, context? } — a resume brings its own, because the parked turn’s request-lifetime authority is gone.
respond() and session() are the exception, and they are unchanged: they hold the stream open and wait 90 seconds for the person to decide.
turns
TurnResult.resume() is a closure and dies with the process. The park does not, so forUser(subject).turns addresses one by id from anywhere over the same store.
resume hands back the result, not a Turn: it has to read the store before anything can start, and a Turn is itself a thenable, so awaiting the promise would unwrap the handle anyway. The answer is prose — a run({ output }) schema belonged to the calling code and was never persisted, so resume through the result you are holding to keep the shape.
list reads the guard’s own pending feed rather than a copy of it, so an ask answered anywhere is gone from this list in the same instant.
POST /turns/:turnId/resume is mounted but still answers not-implemented; over HTTP an approval is decided on the mount’s approvals wire, which is what useVendoChat’s resume posts to.
session
session(subject, options) returns an AgentSession with a threadId, a stream(message, options) returning a UI-message-stream Response, and on("approval", handler) for present-user decisions.
SessionOptions is { user?, context?, headers?, threadId? }.
Tools
api({ dir?, actAs?, baseUrl?, untrustedOriginPolicy?, fetch? }) serves .vendo/tools.json with layered overrides, present-header forwarding behind the origin gate, and actAs for away runs.
tool() takes { name, description, risk?, inputSchema, outputSchema?, execute }. description is required and validated non-empty at boot: it is the only thing the model reads when it decides whether to call the tool, so a tool without one is effectively invisible.
risk is your call and it is final: read, write, or destructive.
Omit risk and the tool is ungraded, which the guard treats like destructive and asks about — which unattended means the turn parks and answers interrupted.
outputSchema is the tool’s declared result shape in JSON Schema. Surfaces print it beside the tool, so the model knows what comes back before any call.
Omit it and those surfaces say the result shape is unknown, which costs a throwaway call to learn the fields. It is advisory only: nothing checks a result against it.
serve
.on() only validates and stashes — a declaration is inert until serve() (or createVendo’s boot, which runs the same reconcile) arms it. The FIRST agent’s composition is the deployment’s: its store holds the records and the run ledger, its guard audits them. Every agent is registered under its own name, and two agents wearing one name throw here, at boot.
close() stops the scheduler and nothing else — the records stay in the store, armed, because stopping a process is not a decision about what should fire.
Adapters
postgres(url, { blobs?, encryption?, allowUnencryptedSecrets? }) keeps the blob adapter beside the store it was configured with.
e2b({ apiKey?, template?, timeoutMs? }) reads E2B_API_KEY as its credential. The harness’s own template always wins over the adapter’s.
Constants
Credentials
One ladder, everywhere: what you pass always wins, andVENDO_API_KEY fills only the slots you left unset.
There are no key-conditional branches and no capability checks. A key problem surfaces on the first real service call.