Skip to content
Tools— browse docs
On this page

Step 2: Two places your code runs

Give your project a second folder — api/ — for the one thing a browser can't be trusted with.

Everything you've built on this site so far — the notes board, a sheet, a pin board — ran entirely in the browser. That's fine for data two people edit together. It's not fine for an API key: anything in src/ ships to every reader's browser, in cleartext, visible in their network tab.

So a vvd project can have a second folder, next to src/:

oracle-roll/
├── src/
│   └── tool.tsx        the UI — runs in the browser
└── api/
    └── roll-oracle.ts  one endpoint — runs on the server, never ships to the browser

Each file in api/ is one endpoint. The file's path is its route — api/roll-oracle.ts becomes the roll-oracle endpoint, no router to configure. Its mere presence turns on a second build; there's nothing to switch on.

Here's the whole endpoint:

api/roll-oracle.ts
import { defineHandler } from "@vvd/sdk/server"

export const config = {
  fetch: ["www.random.org"], // egress allowlist — exact hostnames, no wildcards
}

export default defineHandler(async (ctx) => {
  const res = await ctx.fetch(
    "https://www.random.org/integers/?num=1&min=1&max=20&col=1&base=10&format=plain&rnd=new",
  )
  const n = Number((await res.text()).trim())
  return { roll: bandResult(n) }
})

function bandResult(n: number): string {
  if (n <= 5) return "No — and it gets worse."
  if (n <= 10) return "No, but it's not hopeless."
  if (n <= 15) return "Yes, but there's a complication."
  return "Yes."
}

config is enforcement, not documentation. ctx.fetch can reach www.random.org and nothing else declared here. Try to call a hostname you didn't list and the request never leaves the server — the error names the exact line to add.

Note:

Why not just fetch() from the component?

A plain fetch("https://www.random.org/integers/...") right there in tool.tsx would actually work for this particular call — random.org's basic integer generator allows cross-origin requests with no key. Most APIs worth calling don't: they need a key on every request, and a key in src/ is a key in every reader's browser. Routing every external call through api/ means the pattern is identical whether today's call needs a key or not — and by Step 4, once your group starts streaming sessions and wants a provably-fair roll, this one will.

Why does my own dev session run this in-process, but a published tool doesn't?Deep dive

Your vvd run session runs api/ in-process — it's your code and your account, so there's nothing to isolate it from. A published, third-party tool runs the same defineHandler inside a sandboxed isolate that reveals only that tool's own secrets and permits outbound requests only to the hosts its config.fetch declared. Same handler, same code, different provider underneath — the swap is plumbing, never something you write for.

Still the same oracle roll from Step 1 — nothing about the UI changed yet:

oracle roll, unchanged
src/tool.tsx, running
Starting the example…

Next steps