How to let a Discord bot run code in a sandbox
Defer the interaction within 3 seconds, run the code in a throwaway microVM with no network, then edit the original reply with the output.
On Runtime a throwaway sandbox for one /run costs about four thousandths of
a cent. Compute has no one-minute minimum, so a machine 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, 25 September 2026).
A new sandbox ran its first Python command 351 ms after the request at the
median on 24 September 2026 (speed), well inside Discord's
15-minute window for the answer.
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, answer type 5, and run the code after the response has gone:
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, which returns a status
(ok, error, timeout), printed output and the value of the last
expression, so 2 ** 100 answers 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, stopped 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, and
a sandbox paused by idlePauseSeconds keeps its memory, so x = 5 in one
message and x * 2 an hour later still answers 10.
Languages
Python, JavaScript, TypeScript and Bash are in the default image. The
interpreter also runs R, Java and Go, which install from Ubuntu's archive the
first time a sandbox uses them: about 35 seconds for Java or Go and 90 for R
on 23 September 2026. That fits Discord's 15 minutes, but a throwaway sandbox
pays it every time, so build them into a custom image with
apt and create from it 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 raid of /run commands |
A daily spending limit on the bot's key; maxCostMicros per create |
| Bursts | 100 sandboxes at once 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:
TextCPU: 50,000 × 1.5 s / 3,600 × $0.025 = $0.52Memory: 50,000 × 8 s / 3,600 × 2 GiB × $0.0075 = $1.67Total: $2.191.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. New accounts get 50 free sandbox hours, no card, and the trial runs eight sandboxes at once, enough to try the bot on a real server.
Start
Terminalnpx withruntime sandbox run --trial -- 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 25 September 2026.
Facts on this page were checked on 25 September 2026.