# 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](/docs/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](/docs/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: ```ts check import { 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 fence async 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 } ``` ```python check import json import threading import urllib.request from withruntime import Sandbox LANGUAGES = {"python", "javascript", "typescript", "bash"} TICKS = "`" * 3 # a Markdown code fence def 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_SOURCE ``` The 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-`, 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](/docs/images) 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: ``` 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. 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 ```bash no-run npx 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](https://withruntime.com/account/keys), set a daily limit on it, and put it in `RUNTIME_API_KEY`. Related: [a Slack bot that runs code](/use-cases/slack-bot-code-execution), [what a code interpreter is](/glossary/code-interpreter), [code interpreter for chatbots](/use-cases/code-interpreter-for-chatbots), [run untrusted LLM code safely](/use-cases/run-untrusted-llm-code). ## Sources Checked 25 September 2026. - [Discord, receiving and responding to interactions](https://docs.discord.com/developers/interactions/receiving-and-responding) Facts on this page were checked on 25 September 2026.