Two authors, one model
Someone has to say an automation should exist. Exactly two can, and they differ only in what consent means.A user, in chat
They ask for it in words. Consent is the grants they allow while they
are present, and revoking one stops the run loudly rather than silently
widening it.
A developer, in code
agent.on(...) in your source. Consent is the code: it exists because
you deployed it, and your next deploy reconciles it.A user asks for it

The receipt is a card in the thread, not a config screen.
You declare it in code
lib/agent.ts
.on() is a declaration: it returns nothing, touches no database, and reconciles
once at boot. A bad schedule throws right there — "every monday" is not a cron,
and you find that out at the declaration site rather than at 2am.
See .on() for every shape it takes.
What wakes it
Your deployment decides what is due. Nothing else does.Schedule
A five-field cron, a plain interval, or a one-shot timestamp.
"0 8 * * 1-5" · { every: "15m" } · { at: "2026-09-01T09:00Z" }Host event
Your own product event, emitted from the code path that owns it.
{ event: "invoice.paid" }Webhook
A signed delivery from a connected service.
{ webhook: "stripe" }invoice.paid — the emitting
user’s, and those of every org they belong to — and answers with the run ids it
started.
Schedules need someone to knock, and the door is idempotent: a duplicate knock
claims nothing and fires nothing.
VENDO_API_KEY and publishes it, with its own URL, when it boots. Without a
key, set VENDO_TICK_SECRET yourself and knock from your own cron.
Cloud’s heartbeat is an alarm clock, not a brain. It calls
/api/vendo/tick
on every enrolled deployment once a minute with a signed, empty body. It holds
no schedule, decides nothing about what is due, and never writes a run. The
run ledger you read in the console is the one your deployment wrote.Agents are code, never stored
A record names an agent with a string. The agent itself is your code, registered under that name when your process boots and looked up when the automation fires.lib/vendo.ts
Permissions before the first fire
Nobody is there to approve anything at 2am, so the asking happens when the automation is turned on.missing is what the owner still has to allow. They belong to one grant set, so
one decision settles them all, and after that the automation runs as the person
who armed it, every time.
Reaching your API with nobody there
An away run carries no browser session. There is no cookie to forward, so auth material is minted for the owner before each outbound call — that is theactAs
seam. Present calls need none of it: a user is in the room, and the inbound
cookie or bearer already forwards.
createVendo({ auth: authJs() }) fills actAs for you — see
Wire auth — and most deployments stop reading here. The presets
below are the same wiring by hand, for when no shipped auth preset fits.
Auth.js and Supabase let a host holding the session secret mint a token their own
verifier accepts, so nothing else changes in your app. Clerk and Auth0 sign
sessions with private keys you do not hold, so their presets ship two halves: a
producer that signs a short-lived
VendoAway token, and a verify middleware you
mount on your host app.
They ship on @vendoai/vendo/actions, a subpath of the package you already
have:
lib/vendo.ts
actAs
half.
Mount the verify middleware
Clerk and Auth0 sign a host-owned token rather than a real provider session, so your host API has to accept it and turn it back into a user.middleware.ts
expressMiddleware. The middleware strips any
caller-supplied x-vendo-away-* headers, verifies a real VendoAway token, and
injects the subject on headers your API can trust. Generate the shared secret
with openssl rand -base64 32 and set it for both halves.
The impersonation guard
Before invokingactAs, Vendo compares the grant’s subject to the running
principal’s. On a mismatch the call fails closed with act-as-subject-mismatch
and no outbound request is made. You do not wire this; it applies on every
preset, and every attempt is audited with its disposition: minted, declined,
mismatch, or error.
Observe and control
In the browser there is nothing to build. One hook carries every verb into a panel you write yourself.RunContext last. That is what scopes the read to what this principal may see —
there is no ambient “current user”.
disable is a person’s decision, and it outranks your code: a redeploy’s
reconcile will never re-arm something a human switched off.