Skip to content
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 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_SOURCE

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

Related.

100 free hoursin your first 30 days.

of a 1 GB machine

  • No credit card
  • Four sandboxes at once, 2 vCPU and 4 GiB each
  • Then prepaid credit from $10, no plan fee
Start free, no card
Start free, no card