External APIs and secrets
An api/ folder gives your creation a server half — and config.secrets is where the API key lives, because process.env is empty.
Let's say you're building a tool that pulls creature art from a service you pay for. The
call needs an API key, and the key can't live in your tool's code — a bundle is a file
anyone who installs your creation can read. It can't live in an environment variable either,
because process.env is empty inside a creation.
So your creation grows a server half: an api/ folder. Each file in it is one endpoint.
It runs on the platform, never enters your browser bundle, and your UI calls it as if it
were a local function.
You will learn
- How to add a server endpoint your browser bundle never sees
- How
configdecides which secrets and which hosts an endpoint can reach - How to call your own endpoints as typed functions, with no fetch and no URL
- Where your API key actually lives — and why
process.envcan't hold it
The folder is the configuration
Add an api/ folder and the build turns on a second, server-side bundle. There is nothing
to switch on and nothing to configure.
bestiary/
├── src/
│ └── tool.tsx the browser half
└── api/
├── fetch-art.ts → fetch-art
└── webhooks/stripe.ts → webhooks/stripeThe file path is the route. That's the whole routing story.
An endpoint
import { defineHandler } from "@vvd/sdk/server"
export const config = {
secrets: ["ART_API_KEY"], // UPPER_SNAKE_CASE, the env-var convention
fetch: ["art.example.com"], // outbound allowlist — exact hostnames
}
export default defineHandler(async (ctx, { creature }: { creature: string }) => {
const key = await ctx.secrets.get("ART_API_KEY") // decrypted server-side only
const res = await ctx.fetch(
`https://art.example.com/v1/search?q=${encodeURIComponent(creature)}`,
{ headers: { authorization: `Bearer ${key}` } },
)
if (!res.ok) return { ok: false as const, status: res.status }
const { images } = await res.json()
return { ok: true as const, url: images[0]?.url ?? null } // a plain value — JSON-marshalled for you
})Two things are doing more work than they look like they are.
config is enforcement, not documentation. ctx.secrets.get resolves only names
listed in config.secrets. ctx.fetch reaches only hostnames listed in config.fetch —
exact, lowercase, no scheme, no path, no wildcards; art.example.com does not cover
cdn.art.example.com. Undeclared means unreachable, and the error names the line to add —
so the top of the file is the complete set of things this endpoint can touch.
ctx is the only way out. There is no ambient fetch and no process.env. Every
capability an endpoint has arrives on ctx, assembled from the config above it.
Calling it from your UI
Your client calls its own endpoints as typed functions. No fetch, no JSON plumbing, no
URL, and — importantly — no tool id.
import { useApi } from "@vvd/sdk"
interface ArtApi {
fetchArt(args: { creature: string }): Promise<{ ok: boolean; url?: string | null }>
}
const api = useApi<ArtApi>()
const { url } = await api.fetchArt({ creature: "Ashfall Drake" })Method names map camelCase to the kebab-case file path: fetchArt → api/fetch-art.ts.
Every method also takes a trailing { signal } for cancellation, and each method's function
identity is stable across renders, so it's safe in a dependency array. The typed client
sends JSON in a body, so defineHandler endpoints answer POST and nothing else — a GET
gets a 405 saying so, and reaches raw handlers only.
The tool id is bound by the host, per mount. useApi() doesn't take one — the host that
mounted your tool put its id into the capability, so a tool physically cannot call another
tool's endpoints through this door. That's a security property, not a convenience.
It works because the host provides it
The tool below calls useApi() exactly the way yours will — but this page has no server, so
the six lines marked as the stand-in wire the server capability to a function instead of
the dispatch route. The component doesn't know the difference: swap the host, swap what a
call means, change no tool code. Press both buttons — the second hits a handler that throws.
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
When a call fails
Failures reject as WorkshopApiError, carrying code, status and details — catch it
and show err.message. A throw inside your handler comes back sanitised: you saw the
exact wording in the demo above. The real error goes to the server log; the browser learns
which endpoint failed and nothing else, because an exception can easily contain a URL, a
header, or a fragment of a key. Debugging therefore happens in the logs — use ctx.log(...)
liberally.
A host with no server capability at all — a published page, an anonymous reader —
fails loud at the boundary. Gate the affordance rather than declaring it in needs:
const host = useHost()
return host.server ? <FetchArtButton /> : nullWebhooks and the raw form
When a caller needs the metal — verifying a signature over the exact request bytes, streaming a response — opt into the raw form, the Next.js shape exported directly:
import type { ServerContext } from "@vvd/sdk/server"
export const config = { raw: true, secrets: ["STRIPE_WEBHOOK_SECRET"] }
export async function POST(req: Request, ctx: ServerContext) {
const signature = req.headers.get("stripe-signature")
const body = await req.text() // signature verification needs the raw bytes
const secret = await ctx.secrets.get("STRIPE_WEBHOOK_SECRET")
// verify `signature` over `body` with `secret` using crypto.subtle…
ctx.log("stripe webhook", { verified: true })
return Response.json({ received: true })
}Raw handlers get the untouched Request, may export POST and/or GET, and are
HTTP-only: they exist for external callers, so useApi has no typed caller for them —
or for any nested path, which is almost always a webhook.
What ctx gives you
ctx.* | What it is |
|---|---|
secrets.get(name) | The decrypted value of a declared secret — this creation's only |
fetch(url, init) | Outbound HTTP(S), allowlisted to your declared hosts |
identity | Who called: { userId, kind } where kind is auth, guest or anon — a resolved principal, never a raw token |
scope | The world (and optionally project) the call is scoped to |
log(…) | Structured logs to your dashboard; fire-and-forget, never throws |
index.generate(req) | The World Index — declare config.index first; the deep dive is under Tools |
There is deliberately no ctx.documents in v1 — read world data from the client, where
the grant system already works.
Node built-ins are a build error
The server runtime is isolate-shaped: Web-standard APIs (fetch, Request/Response,
crypto.subtle, URL, TextEncoder) plus ctx. No filesystem, no processes, no native
addons — and you find out at build time, with an error naming the specifier and the
Web-standard alternative:
Server code can't use "node:fs": the isolate runtime has no filesystem — state goes through ctx. (Full-Node needs route to the container tier later.)This is why the webhook example above verifies its signature with crypto.subtle rather
than node:crypto.
Where your API key lives
Every instinct built up over a decade of Node says: put the key in an environment variable,
read process.env.MY_KEY, move on. Do that here and you get undefined — no error, no
warning — and you spend the next hour convinced the key is wrong.
process.env is empty inside a creation. Not discouraged — empty. In server code
(api/) it is literally {}; in client code (src/) it's a tiny stub holding a couple of
the host's own public values. No mechanism puts your variables into it, in dev or in
production. Secrets arrive through config.secrets and await ctx.secrets.get(), and
nothing else.
There's a design reason. A creation is a distributable artifact: the same bytes run in your dev session and on a stranger's machine after they install it. There are no environments to set variables for — there are installs — so configuration can't ride ambient process state; it has to be handed to a specific piece of code, on purpose.
That leaves exactly two channels:
| What you have | Where it goes |
|---|---|
| A secret — an API key, a webhook signing secret, anything a browser must never hold | config.secrets: ["NAME"] on an endpoint + await ctx.secrets.get("NAME") — server handlers only |
| Non-secret configuration — a default, a mode, a label, a colour | Your creation's own data: a field in the document, or (for apps) a declared customization parameter |
Storing a secret
From your project folder:
vvd secret set ART_API_KEY sk-live-8f2b
✓ ART_API_KEY set (dev)
encrypted, this-tool-only — read it in api/ with ctx.secrets.get("ART_API_KEY").Leave the value off and the CLI prompts for it, keeping it out of your shell history (it
echoes as you type, though — on a shared screen, pipe it in). Names are
UPPER_SNAKE_CASE, the same rule config.secrets enforces; anything else is rejected
before it leaves your machine.
The read path is the endpoint at the top of this page. The declaration is the permission: an endpoint can't read a name it didn't declare, and a creation can't reach another creation's values at all.
Write-only, and loud when it's missing
After vvd secret set, nothing reads the value back — there is no vvd secret reveal.
The only path to plaintext is ctx.secrets.get, inside a declared handler of yours, at call
time. What you can see is which names exist and whether each has a value:
vvd secret list
● ART_API_KEY set ○ SEARCH_TOKEN not set values are write-only — update one with vvd secret set <NAME>
A declared name with no stored value fails immediately and names the fix, rather than
sending an empty Authorization header and letting someone else's 401 be your error
message:
secrets: "ART_API_KEY" is declared but has no stored value — set it (e.g. `vvd secret set ART_API_KEY …`) before calling this endpoint.Never put one in src/
A secret in client code ships to every browser that opens your creation — your bundle is
a file the platform serves, and no minification, obfuscation or NEXT_PUBLIC_ convention
changes that. If your UI needs the result of using a key, call the handler and return the
result. That's what the server half is for.
Rotating, removing, and --scope
Secrets are resolved at call time: setting a name again replaces the value, live on the
next request, for every version of your creation at once. Rotating a leaked key needs no
rebuild or re-save, and it reaches people who installed an older version. To remove a name,
use the Workshop's Environment Variables panel — write-only, exactly like the CLI, and
despite the name it has nothing to do with process.env.
Two honest caveats. --scope dev|published is recorded but not enforced yet — a
creation has one value per name, shared by vvd run and your published version, so if you
need a separate testing key, get one from the upstream provider. And --scope user —
bring-your-own-key — isn't available yet, and says so.
Non-secret configuration
A default label, a display mode, an accent colour — these are ordinary data, and they belong
with your creation's data. In a tool, that's a field in your codec — it syncs, undoes,
and is per-document like everything else (Storing data). In
an app, declare a customization block in vvd.json and the platform renders a
Customize panel, with values arriving through useAppCustomization()
(Theming). Constants stay constants in your source.
Testing a handler
createFakeServerContext() is the createFakeHost() twin: a complete, dependency-free
ServerContext made of inert fakes. Seed the secrets, run the handler, assert on what it
did — no dispatch route, no network, no server.
import { createFakeServerContext } from "@vvd/sdk/server"
import fetchArt from "./fetch-art"
const ctx = createFakeServerContext({ secretValues: { ART_API_KEY: "k-123" } })
const result = await fetchArt(ctx, { creature: "Ashfall Drake" })
expect(ctx.fetch.calls[0].url).toContain("art.example.com")
expect(ctx.fetch.calls[0].init?.headers).toMatchObject({ authorization: "Bearer k-123" })ctx.fetch records every call and answers 200 {}; ctx.log.entries records every log
line; ctx.secrets.get throws for any name you didn't seed — so you find out you forgot to
declare one before your users do.
The trust boundary, honestly
Your own vvd run session executes your handlers in-process — it's your code, called by
you. A published third-party package runs in a workerd isolate, handed only that
creation's own secrets, with every outbound request — ctx.fetch or a raw
globalThis.fetch — routed through a gateway that only forwards declared hosts. The same
defineHandler runs either way, but dev is more permissive: a handler that reaches for
globalThis.fetch can work under vvd run and then fail once installed with
EGRESS_DENIED. Declare the host and use ctx.fetch from day one.
The dev loop
api/ is part of vvd run: edit an endpoint and it rebuilds and pushes exactly like a
src/ edit. One wrinkle that looks like a broken build: the folder is watched only if it
existed when vvd run started. Create api/ mid-session and nothing happens — restart
vvd run and it picks it up.
When it doesn't work
Your handler says the key is "not found", but you definitely set it.
Nine times out of ten it's process.env. Replace each use with
await ctx.secrets.get("NAME") plus a config.secrets entry.
secrets: "ART_API_KEY" is not declared.
The endpoint's config.secrets doesn't list it. The message includes the list it does
have, which usually shows you the typo.
secrets: "ART_API_KEY" is declared but has no stored value.
Nothing stored under that exact name. Run vvd secret list — names are case-sensitive.
fetch: host "…" is not in this handler's egress allowlist.
Matching is exact and there are no subdomain wildcards. Add the hostname. A relative URL
fails too — ctx.fetch is outbound egress, so pass a full https:// URL.
Host capability "server" is not provided by this host.
A surface with no server — a published page, or a test. Check host.server before you
render the affordance.
You set a secret and your teammate's install still fails.
Secrets are per-creation, not per-install — this is not the version pin. Check
vvd secret list from the project folder, signed in as the account that owns the creation.
Recap
- An
api/folder gives your creation a server half. Each file is one endpoint, and the path is the route. config.secretsandconfig.fetchare enforcement: undeclared is unreachable, and the error tells you which line to add.useApi()calls your own endpoints as typed functions. The host binds the tool id, so a tool cannot reach another tool's endpoints.process.envis empty inside a creation. Secrets live inconfig.secrets+await ctx.secrets.get(), server-side only, write-only once set, and rotation is live on the next request.- Handler throws come back sanitised, and
createFakeServerContext()tests a handler with no server at all.