Back to writing
app.yourproduct.comvendo
Show me what changed this week
NEEDS ATTENTION
6
UPDATED THIS WEEK
34
Awaiting reviewtoday
In progress2 days
Schedulednext week
vendo

Setting up Vendo Cloud for an agent-only product

Blog
From the team · vendo.run
Blog

Setting up Vendo Cloud for an agent-only product

One 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.

YHYousef Helal
Yousef Helal
Co-Founder, Vendo (YC S26)
Aug 19, 202612 min read

"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:

  • createVendo and s3Files come from @vendoai/vendo/server.
  • defineTool comes 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.