On Runtime a throwaway sandbox for one /run costs about four thousandths of
a cent. Compute has no one-minute minimum, so a sandbox that lives for eight
seconds is billed for eight seconds: $0.025 per vCPU-hour of measured CPU
and $0.0075 per GiB-hour of memory (pricing, 26
October 2026).
What Discord requires
Discord's documentation says a bot "must send an initial response within 3
seconds of receiving the event". Response type 5,
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, acknowledges the interaction and shows
the user a loading state. The interaction token then stays valid for 15
minutes, and PATCH /webhooks/{application.id}/{interaction.token}/messages/@original
replaces the loading state with the real answer. That endpoint authenticates
with the interaction token, so the code runner needs no bot token at all.
The short answer
Verify the interaction's signature as Discord's docs describe, respond with type 5, and run the code after the response is sent:
TypeScriptimport { Sandbox } from "withruntime";type Interaction = { application_id: string; token: string; member?: { user: { id: string } }; data: { options: { name: string; value: string }[] };};const LANGUAGES = ["python", "javascript", "typescript", "bash"] as const;type Language = (typeof LANGUAGES)[number];const TICKS = "`".repeat(3); // a Markdown code fenceasync function runAndEdit(interaction: Interaction) { const option = (name: string) => interaction.data.options.find((o) => o.name === name)?.value; const language = (option("language") ?? "python") as Language; const code = option("code") ?? ""; let content: string; if (!LANGUAGES.includes(language)) content = `Unsupported language: ${language}`; else { await using sbx = await Sandbox.create({ vcpu: 1, memoryMiB: 2048, network: { internet: false }, timeoutSeconds: 120, onLeaseEnd: "stop", labels: { app: "discord-runner", user: interaction.member?.user.id ?? "dm" }, }); const cell = await sbx.interpreter.run(code, { language, timeoutMs: 15_000 }); const printed = cell.stdout || String(cell.results[0]?.data["text/plain"] ?? ""); const failure = cell.error ? `${cell.error.name}: ${cell.error.value}` : ""; const body = (printed + failure).slice(-1_800) || "(no output)"; content = `${cell.status}\n${TICKS}\n${body}\n${TICKS}`; } const url = `https://discord.com/api/v10/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`; await fetch(url, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ content }), });}// Your interactions endpoint, after verifying the request's signature.export function onRunCommand(interaction: Interaction) { void runAndEdit(interaction).catch((error) => console.error(error)); return { type: 5 }; // DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE}Pythonimport jsonimport threadingimport urllib.requestfrom withruntime import SandboxLANGUAGES = {"python", "javascript", "typescript", "bash"}TICKS = "`" * 3 # a Markdown code fencedef run_and_edit(interaction: dict) -> None: options = {o["name"]: o["value"] for o in interaction["data"]["options"]} language = options.get("language", "python") if language not in LANGUAGES: content = f"Unsupported language: {language}" else: with Sandbox.create(vcpu=1, memory_mib=2048, network={"internet": False}, timeout_seconds=120, on_lease_end="stop", labels={"app": "discord-runner"}) as sbx: cell = sbx.interpreter.run(options.get("code", ""), language=language, timeout_ms=15_000) printed = cell["stdout"] or str((cell["results"] or [{}])[0].get("data", {}).get("text/plain", "")) error = cell["error"] or {} failure = f"{error.get('name')}: {error.get('value')}" if error else "" body = (printed + failure)[-1_800:] or "(no output)" content = f"{cell['status']}\n{TICKS}\n{body}\n{TICKS}" url = (f"https://discord.com/api/v10/webhooks/{interaction['application_id']}" f"/{interaction['token']}/messages/@original") request = urllib.request.Request(url, data=json.dumps({"content": content}).encode(), method="PATCH", headers={"content-type": "application/json"}) urllib.request.urlopen(request, timeout=10)def on_run_command(interaction: dict) -> dict: threading.Thread(target=run_and_edit, args=(interaction,), daemon=True).start() return {"type": 5} # DEFERRED_CHANNEL_MESSAGE_WITH_SOURCEThe code runs in the sandbox's code interpreter. It returns a status (ok,
error, timeout), printed output and the value of the last expression, so
2 ** 100 returns its result without a print. The sandbox stops when the
block ends, even when the snippet raised an error.
Throwaway or one per user?
| Choice | Good for | How |
|---|---|---|
| A new sandbox per command | Public servers, strangers, tidy billing | Sandbox.create, deleted when the block ends (above) |
| One sandbox per user | Variables that carry over between runs | Sandbox.getOrCreate named discord-<user id>, with idlePauseSeconds |
| One sandbox per server | A shared scratchpad for a study group | getOrCreate named by guild id |
With one sandbox per user, the interpreter keeps variables between cells. A
sandbox paused by idlePauseSeconds keeps its memory, so x = 5 in one message
and x * 2 an hour later still returns 10.
Languages
Python, JavaScript, TypeScript and Bash are in the default image. The
interpreter also runs R, Java and Go. They install from Ubuntu's archive the
first time a sandbox uses them: about 35 seconds for Java or Go, and 90 seconds
for R on 23 September 2026. That fits within Discord's 15-minute window, but a
throwaway sandbox pays the install every time. Build them into a custom
image with apt, and create sandboxes from that image with
image.
What keeps a public bot safe
| Risk | What handles it |
|---|---|
| Code that reads the bot's token | The token never enters the sandbox; your process edits the reply |
| One user's code seeing another's | A Firecracker microVM with its own kernel per sandbox |
| Requests to the internet from a snippet | Internet off, enforced on the host, root included |
while True: pass |
timeoutMs ends the cell with status timeout |
| A machine left running | timeoutSeconds with onLeaseEnd: "stop" ends it on the host |
A flood of /run commands |
A daily spending limit on the bot's key; maxTotalCostMicros per sandbox |
| Bursts | 100 at the same time on a paid account; creates wait for room |
Labels record which user started each sandbox, so runtime.sandboxes.list
with labels finds everything one user ran.
What it costs
Take 50,000 /run commands a month. Each creates a 1 vCPU, 2 GiB sandbox that
lives 8 seconds, start to stop, and the snippet uses 1.5 CPU-seconds:
- CPU
- 50,000 × 1.5 s / 3,600 × $0.025
- $0.52
- Memory
- 50,000 × 8 s / 3,600 × 2 GiB × $0.0075
- $1.67
- Total
- $2.19
1.5 CPU-seconds in 8 seconds is above the floor of a twentieth of a vCPU (0.4 CPU-seconds), so the floor adds nothing. A new account gets 100 free hours of a 1 GB machine in its first 30 days without a card. An account without credit can run four sandboxes at the same time, enough to try the bot on a real server.
Start
Terminalnpx withruntime sandboxes run -- python3 -c 'print(2 ** 100)'The first run prints a link to approve in your browser. For the bot's server,
make a key at API keys, set a daily
limit on it, and put it in RUNTIME_API_KEY.
Related: a Slack bot that runs code, what a code interpreter is, code interpreter for chatbots, run untrusted LLM code safely.
Sources
Checked 26 October 2026.
Facts on this page were checked on 26 October 2026.