Step 5: Ship it
A handful of commands, and the mock from every step above is gone for good — a real endpoint, a real key, a real roll.
This is not a step to complete. It's the same commands from every build on this site, plus one you haven't used yet: storing the secret your handler needs.
No live preview for this step. Real secrets, a real api/ deploy and a real outbound
call to a real hostname are the entire subject here — nothing this sandboxed page can do
safely or honestly. The terminal output below is the exact shape of it, run for real.
vvd create oracle-roll --tool
→ Creating tool oracle-roll in /Users/you/dev/oracle-roll — from the hello template ✓ Created oracle-roll (tool) → /Users/you/dev/oracle-roll
Replace src/tool.tsx with the real thing — Step 3's answer, with the mock fetchOracle
swapped for the useApi<OracleApi>() from that step's first code block. Here's the whole
file, so you have something to check your merge against. Note that Break it, on purpose
is gone: api.rollOracle() takes no fail flag — from here on, failure is the real network's
job:
import { useState } from "react"
import { type ToolRenderProps, WorkshopApiError, defineTool, useApi } from "@vvd/sdk"
interface OracleApi {
rollOracle(): Promise<{ roll: string }>
}
function OracleRoll({ context }: ToolRenderProps) {
const api = useApi<OracleApi>()
const [roll, setRoll] = useState<string | null>(null)
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle")
const rollOracle = async () => {
setStatus("loading")
try {
const { roll } = await api.rollOracle()
setRoll(roll)
setStatus("idle")
} catch (err) {
// A throw inside the handler comes back SANITIZED — the real error goes to
// the server log, never the browser. err.message is always safe to show.
setStatus("error")
console.error(err instanceof WorkshopApiError ? err.message : err)
}
}
return (
<div style={{ padding: 24, display: "grid", gap: 12, maxWidth: 420, font: "inherit" }}>
<strong style={{ fontSize: 15 }}>Fate oracle</strong>
<p style={{ minHeight: 44, margin: 0 }}>
{status === "loading"
? "Consulting…"
: status === "error"
? "Couldn't reach the oracle. Try again?"
: (roll ?? "Ask when the table needs a fair answer.")}
</p>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
disabled={!context.canEdit || status === "loading"}
onClick={() => rollOracle()}
style={BUTTON}
>
Ask the oracle
</button>
</div>
<span style={{ fontSize: 11, opacity: 0.5 }}>via random.org</span>
</div>
)
}
const BUTTON = {
padding: "6px 16px",
borderRadius: 999,
border: "1px solid rgba(127,127,127,0.5)",
background: "transparent",
color: "inherit",
font: "inherit",
fontSize: 13,
cursor: "pointer",
}
export default defineTool({
id: "oracle-roll",
name: "Oracle roll",
documentTypes: ["oracle-roll"],
render: OracleRoll,
})Then add api/roll-oracle.ts from Step 2 or Step 4 — whichever version you want, mock-free
either way:
cd oracle-roll && vvd secret set RANDOM_ORG_KEY 9f2b-live-key --scope dev
✓ RANDOM_ORG_KEY set (dev)
encrypted, this-tool-only — read it in api/ with ctx.secrets.get("RANDOM_ORG_KEY").vvd run --world=test-bench
● live in Test Bench https://beta.vvd.world/worlds/test-bench?dev=9f21c4 — a private tab, only you can see it
Your browser opens on that URL — the same oracle roll you built here, minus the mock. Click
the button and useApi really does dispatch to your api/roll-oracle.ts, which really does
call out to the hostname you declared, with the key you set.
vvd save
✓ Saved oracle-roll v1 — a private draft only you can see
A private draft is saved, not shipped. One more command puts it in front of your world:
vvd share
→ Sharing with your world… ✓ Shared oracle-roll v1 with your world.
Ship it covers what each command does, who can see the result afterwards, and how to roll back or unshare.
Recap
- Each file in
api/is one endpoint; the file's path is the route, and the folder's presence is the whole configuration. config.fetchandconfig.secretsare enforcement, not documentation — an undeclared name or host is unreachable, by construction.useApicalls your own endpoint as a typed function; a throw inside the handler comes back sanitized, never as your real server error.process.envis always{}in a tool bundle — a secret only ever reaches your code throughctx.secrets.get, inside a handler that declared it.