"Our users bring their own data files, and the only thing that ever touches them is an agent."
That is the shape a lot of new products have now. There is no dashboard someone clicks through, no settings page, no report builder. There is a user, a file they own, and an agent that works on it for them. Sometimes the agent is not even inside your product: it is an outside agent, driven from your own backend, reaching in over MCP.
This walkthrough sets Vendo Cloud up for exactly that. By the end you will have one API key, your own tool, an MCP door, a place to pin generated screens, a tenant connector, and an outside agent that uploads a CSV, builds a dashboard from it, and pins the dashboard. Every code block here is complete. Copy it, replace the placeholders in angle brackets, and it runs.
There is a self-host chapter at the end, because every single-player capability in Vendo has a bring-your-own path and that is worth stating plainly rather than burying.
Before you start
Two import paths matter more than anything else on this page, because getting either wrong fails on line one:
createVendoands3Filescome from@vendoai/vendo/server.defineToolcomes from the root,@vendoai/vendo.
They are different entries on purpose. The server entry pulls in server-only code, and the root stays importable from anywhere.
One more: schemas are zod 4. A zod 3 schema handed to defineTool is
rejected outright rather than silently misbehaving, so if your project is still
on zod 3 you will see that error immediately.
Everything below is against @vendoai/vendo 0.34.0.
Step 1: one key
Get a Cloud key and put it in your environment. The CLI writes it for you and never prints it:
npx vendo login
That leaves VENDO_API_KEY in .env.local. If you would rather mint one by
hand, npx vendo cloud keys create --project <your-project-id> does the same
job.
What that one key does is fill in the adapter slots you left unset. With the key present and nothing else configured, Cloud provides the store, the sandbox, inference, connectors, connections, knowledge, secrets, the org directory, and the MCP door's broker.
The rule underneath it is worth internalizing, because it is what keeps this from being magic: an adapter you pass explicitly always wins. Every one of those slots checks your config first and only reaches for the key when you left the slot empty. There are no hidden branches that behave differently because a key happens to be present.
One slot is not Cloud-selected at all: files. Leave it unset and your uploads
land in the store's blobs. That matters later, in Step 6 and in the self-host
chapter.
Step 2: your own tool
Vendo's agent should be able to call into your product, not just Vendo's own
verbs. defineTool is how you hand it one of yours.
// src/vendo/tools.ts
import { defineTool } from "@vendoai/vendo";
import { z } from "zod";
export const searchMemory = defineTool({
name: "search_memory",
description:
"Search this user's saved notes and past findings. Returns the matching "
+ "entries with their titles and text.",
input: z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(50).optional(),
}),
risk: "read",
async execute({ query, limit }, ctx) {
// ctx.principal.subject is the user this call belongs to. Scope your
// query to it; there is no other subject argument to get wrong.
const rows = await yourDb.searchNotes({
user: ctx.principal.subject,
query,
limit: limit ?? 10,
});
return { results: rows };
},
});
Three things to notice. The description is what the model reads to decide
whether to call this at all, so write it for the model rather than for a
changelog. The risk grade is the entire guard story for this tool, which is
Step 3. And ctx.principal.subject is the acting user, which is how one user's
memory stays unreachable from another's session.
Step 3: the guard, and the one preset that will deadlock you
This step is mostly about not doing something.
Vendo's no-config default parks exactly two grades of action for a human to
approve: destructive and ungraded. Everything else runs. That means a
graded write runs by default, which is what you want here, because uploading
a file and pinning a screen are both honest write actions.
So for this walkthrough you write no guard configuration at all. The default is already correct.
The sharp edge is the preset sitting right next to it. The opt-in cautious
preset parks every write for a human tap. On an agent-only product that is a
deadlock: your outside agent calls vendo_user_files_put, the call parks
waiting for a person, and there is no person and no UI for them to tap in. If
you have inherited a cautious policy from somewhere, that is the first thing
to look at when your first upload hangs.
The readonly preset blocks writes outright, which fails faster but fails just
as hard.
Step 4: open the door, mint a token
mcp: true turns your app into an MCP server at /api/vendo/mcp.
It has one hard prerequisite, and it throws at startup rather than failing
later: the door needs an OAuth half to mint the identity a call runs as, and
only an auth preset carrying one will do. Four do: clerk(), authJs(),
supabase(), and auth0(). Plain jwt() does not. Each lives on its own
import path: /auth/clerk, /auth/auth-js, /auth/supabase, /auth/auth0.
// src/vendo/server.ts
import { createVendo } from "@vendoai/vendo/server";
import { clerk } from "@vendoai/vendo/auth/clerk";
import { searchMemory } from "./tools.js";
export const vendo = createVendo({
auth: clerk(),
tools: [searchMemory],
mcp: true,
});
You also need VENDO_BASE_URL set to the public https origin your agent
actually reaches: discovery, the issuer, and the token audience all derive from
it, and Cloud registers it as your tenant's forwarding address, so a localhost
one is refused.
That is the entire door. On the first request that needs it, the key from Step 1 provisions this deployment's broker, federation secret, and service key. Nothing to visit, copy, or invent.
Getting a token. Your backend has no browser to bounce through, so it asks
your own composition: tokenFor(who: Request | string): Promise<string>.
const accessToken = await vendo.tokenFor("user_1904");
Pass a Request instead and it reads whoever is signed in, through the door's
own session seam, so the two can never disagree about who someone is. A blank,
null, or literal "undefined" subject is refused at mint rather than buying a
valid token for nobody.
The token lasts ten minutes, binds to that one user, and has no refresh path, so mint one per run. Every call made with it runs as that user, through the same guard and the same audit trail your own UI answers to.
Step 5: declare your slots
A slot is a named place a generated screen can be pinned to. Normally the
registry is populated by your pages: a page renders a <VendoSlot>, reports
itself in, and the entry ages out after thirty days of nobody rendering it.
An agent-only product has no page doing that. As such, the registry would be permanently empty and an agent would have nowhere to pin anything.
Declaring slots in your config fixes that. A declared slot never decays and needs no render:
export const vendo = createVendo({
auth: clerk(),
tools: [searchMemory],
mcp: true,
slots: [
{ id: "home.main", label: "Home", description: "The first thing the user sees when they open the workspace." },
{ id: "home.sidebar", label: "Sidebar", description: "A narrow column beside the main view. Good for summaries and counters." },
],
});
id and label are required, description is optional. The description is
what an agent reads to pick between two slots a label alone cannot separate, so
it is worth writing properly.
Validation happens at startup, not at first use: at most 200 slots, ids and
labels 1 to 256 characters, descriptions up to 1024. An over-long id throws
createVendo({ slots }): slot id must be 1-256 characters before your server
finishes booting.
A declared slot also beats a page-reported one with the same id, so nothing your frontend reports can quietly rewrite the description your model reads.
Step 6: a tenant connector
If your users' data lives somewhere like MotherDuck, you give one organization its own connection to it. This is what tenant connectors are for: one org's own MCP server or OpenAPI spec, registered at runtime from your own server-side code. No redeploy, no console, no Vendo-hosted UI.
MotherDuck is not a built-in Vendo connector. You register its MCP server the same way you would register any other:
// wherever your admin route lives
await vendo.tenantConnectors.register({
org: "<org-id>",
name: "motherduck",
kind: "mcp",
url: "<your-motherduck-mcp-url>",
token: "<the-org's-motherduck-token>",
});
The token never lands in a record. It goes into the store's encrypted secret
vault, which means this needs a store with an encryption key configured. Wire
it without one and register refuses with a not-implemented error, and
vendo doctor warns about it ahead of time as E-TENANT-001.
Isolation is structural: each org gets its own tool registry overlay, so an org can only ever see its own connector.
list, remove, and test sit alongside register on the same object, which
is enough to build a small admin form over.
I should be straight about one thing here. We set this path up and read it carefully, but we did not have a MotherDuck token on hand, so we never ran a live MotherDuck query end to end. Treat this step as setup instructions rather than as a verified result. The tenant connector machinery itself is exercised. The MotherDuck endpoint specifically is not.
Step 7: wire your agent, and the thing that will bite you first
Your agent is a stock MCP client. There is no Vendo import on this side, and the door's own listing is the tool set.
Before the code, the single most likely reason your first attempt fails:
vendo_make routinely runs longer than 60 seconds, and a stock MCP client
kills a call at 60 seconds unless you opt in. The door emits
notifications/progress every 15 seconds during a long call, but a progress
notification only extends the deadline for a client that asked it to. The SDK
flag that does that, resetTimeoutOnProgress, defaults to false. You need both
halves: onprogress is what puts a progress token on the request, without
which the door stays silent, and resetTimeoutOnProgress: true is what makes
the resulting heartbeats restart the clock. Neither one alone works.
Both ride in the third argument to callTool, the request options bag. The
second argument is the result schema, which you leave undefined:
// agent.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const accessToken = "<the-token-from-step-4>";
const client = new Client({ name: "<your-agent>", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(
new URL("https://<your-app-origin>/api/vendo/mcp"),
{ requestInit: { headers: { authorization: `Bearer ${accessToken}` } } },
));
// The opt-in, on every call. Cheap to always pass, painful to forget once.
const call = (name: string, args: Record<string, unknown>) =>
client.callTool({ name, arguments: args }, undefined, {
onprogress: () => {},
resetTimeoutOnProgress: true,
});
Install it with npm install @modelcontextprotocol/sdk. From here the loop is
ordinary: list the tools, hand them to your model, call them.
Step 8: upload, build, pin
Three calls, in order. This is the whole flow.
// 1. Put the user's CSV into their own file drawer.
const put = await call("vendo_user_files_put", {
name: "ledger.csv",
content: await fs.readFile("./ledger.csv", "utf8"),
});
// → { name: "ledger.csv", path: "/user/files/ledger.csv", bytes: …, mediaType: "text/csv" }
// 2. Build a screen from it.
const made = await call("vendo_make", {
request: "a dashboard of my spending by category from ledger.csv",
});
const { id } = JSON.parse(made.content[0].text);
// 3. Pin it to a slot you declared in Step 5.
const pinned = await call("vendo_apps_pin", { app: id, slot: "home.main" });
A few details that save you a debugging session.
The upload cap is 5 MiB by default, and the same number applies at both
doors: the browser drop zone and vendo_user_files_put refuse at the same
size, with the same sentence. You move it with createVendo({ uploadMaxBytes }),
which takes a number of bytes.
That option is checked when your server composes, not when the first file
arrives. It has to be a positive integer, and anything else refuses to start
with createVendo({ uploadMaxBytes }): must be a positive integer, got <value>.
That is worth knowing about, because of the failure it prevents. NaN and
Infinity are both numbers as far as TypeScript is concerned, and both would
make the doors' bytes > cap comparison false forever. A slip like that would
not move your upload limit, it would remove it. You hear about it at boot
instead, naming the option and the value you passed.
Raising the cap is also only half a fix if you left files unset. Without a
files adapter your uploads land in the store's blobs, which cap a single file
at 5 MiB of their own. Past that size you need a bucket, which is the
self-host chapter below.
What reads back is narrower than what stores. Any file type can be saved.
Only these read back as text: csv, tsv, txt, log, sql, md, json,
ndjson, xml, html, yaml, yml. A parquet file or an xlsx workbook
uploads and stores fine, but reading it returns a refusal that names its type
and size, says the content cannot be read back, and lists what can. That is
deliberate. Decoding unknown bytes as UTF-8 produces mojibake, and a confident
answer built on mojibake is worse than an honest no. There is no parquet
parsing and no DuckDB analysis here. If you need what is inside a workbook, ask
the user for a CSV.
Send text as content directly. Anything else goes base64 with
encoding: "base64", because a tool call is JSON and JSON has no bytes.
Reads come back 200 lines at a time. When a result says truncated, call
again with offset set to the nextOffset it gave you. offset counts lines,
never characters.
The pin works, but you cannot see it. This one surprises people. The pin
lands, the registry records it, and the receipt confirms it. However, an
agent-only product has no page rendering a <VendoSlot>, so there is no
surface on which that pinned screen appears to a human. Nothing is broken. If
you want a human to see it, mount the slot somewhere:
import { VendoSlot } from "@vendoai/ui/chrome";
<VendoSlot id="home.main" />
id is the only required prop, and it must be inside a VendoProvider.
Bringing your own
Everything above runs on one key. None of it has to.
The rule Vendo holds itself to is that every single-player capability keeps a no-key path. Cloud sells two things: infrastructure that is genuinely painful to run yourself, and coordination between multiple parties. Anything you could reasonably run alone, you can run alone. What follows is not a downgrade path, it is the same adapter interface with a different implementation passed in.
Your own bucket. s3Files speaks plain S3 and works against Cloudflare R2,
AWS S3, Supabase Storage, and MinIO. This one we did run for real, against a
live Cloudflare R2 bucket.
import { createVendo, s3Files } from "@vendoai/vendo/server";
export const vendo = createVendo({
files: s3Files({
// Origin only. The bucket does not go in this URL.
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
bucket: "<your-bucket>",
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
// region defaults to "auto", which is what R2 wants. Leave it off.
// prefix: "prod/", // optional, so one bucket can hold several deployments
}),
uploadMaxBytes: 50 * 1024 * 1024,
});
For AWS use https://s3.<region>.amazonaws.com with your real region. For
Supabase, https://<project-ref>.supabase.co/storage/v1/s3, also with its real
region. For MinIO, your own origin. Addressing is path-style always, so there
is no forcePathStyle option to look for. The adapter reads no environment
variables of its own, so whichever credentials you pass are the ones it uses.
One behaviour worth knowing: R2 answers 404 for both a missing object and a
missing bucket. A missing object resolves to undefined, as it should. A
missing bucket throws instead, naming what to check:
s3Files: GET <bucket>/<key> failed with 404 — check the endpoint, region, bucket and credentials passed to s3Files(). A typo in the bucket name will not
masquerade as an empty file drawer.
Your own Postgres. Pass a connection string and an encryption key:
import { createVendo, createStore } from "@vendoai/vendo/server";
export const vendo = createVendo({
store: createStore({
url: process.env.DATABASE_URL!,
encryption: { key: process.env.VENDO_ENCRYPTION_KEY! },
}),
});
The encryption key is what makes the secret vault work, which is what Step 6's tenant connector tokens need.
Your own sandbox. The E2B adapter ships on its own subpath so nobody pays for it who is not using it:
import { e2bSandbox } from "@vendoai/apps/e2b";
export const vendo = createVendo({
sandbox: e2bSandbox(),
});
Your own model key. An ANTHROPIC_API_KEY in the environment is used
directly. Managed inference is the fallback when you have not brought one.
Your own door. Without a Cloud key your app serves its own OAuth surface and
supplies the key tokenFor exchanges, via mcp: { serviceAuth: { keys: [...] } }.
That is also the only posture where PKCE is yours: an interactive client signing
in against your door must send code_challenge_method=S256, or it is refused
with PKCE with code_challenge_method=S256 is required. On the Cloud path the
broker owns sign-in and your door stops serving /authorize and /token. A
backend agent with a tokenFor token meets no PKCE either way, no browser being
in the loop.
Each of these takes precedence over the Cloud default the moment you pass it. You can move one slot at a time, in either direction, without touching anything else.
Where this leaves you
The setup is one key, one tool of your own, one flag for the door, one call to
tokenFor, a list of slots, and a client that opts into long calls. That is
genuinely all of it. The two pieces people expect to be hardest need no
configuration: the guard's default already runs the writes this flow depends on,
and the same key provisions the door's broker.
The parts I would not want you to learn the slow way are the three sharp edges:
the cautious preset deadlocking an agent-only product, the 60-second client
timeout that needs both onprogress and resetTimeoutOnProgress, and the pin
that lands correctly but has nowhere to be seen until you mount a slot.
Ultimately, what makes this shape work is that none of it is locked. The same walkthrough runs on your own bucket, your own database, your own sandbox, and your own model key, with the same code and a different adapter passed in. Build on the key because it is the fastest way to something real, and keep the door open to running it all yourself the day that becomes the better trade.
