Runtime

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_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-<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.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

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.