Runtime

How to let a Slack bot run code safely

Acknowledge Slack within 3 seconds, run the snippet in a microVM with no internet and a time limit, then post the output to response_url.

On Runtime each Slack channel can keep its own machine that pauses when the channel goes quiet. A sandbox paused after two idle minutes keeps its files, installed packages and memory for $0.08 per GB per 30-day month, and the next /run wakes it, usually in about half a second. While it runs, a 1 vCPU, 2 GiB sandbox costs $0.01625 an hour waiting, because Runtime bills measured CPU at $0.025 per vCPU-hour (pricing, 25 September 2026).

What Slack requires

A slash command reaches your app as a form-encoded POST with fields such as text, response_url, channel_id, user_id and team_id. Slack's documentation says the acknowledgement "must be received by Slack within 3000 milliseconds", and that a response_url accepts up to 5 responses within 30 minutes. So the bot answers at once, runs the code after, and posts the result to response_url. With response_type: "in_channel" the whole channel sees it; the default, ephemeral, shows it only to the person who asked.

The short answer

Verify that the request came from Slack before anything runs. Then acknowledge, and do the work after the response has gone:

TypeScriptimport { Sandbox } from "withruntime";const TICKS = "`".repeat(3); // a Markdown code fencefunction unfence(text: string) {  const t = text.trim();  if (t.length < 6 || !t.startsWith(TICKS) || !t.endsWith(TICKS)) return t;  return t.slice(3, -3).replace(/^\w*\n/, ""); // drop a language tag such as "python"}async function runAndReply(form: URLSearchParams) {  const code = unfence(form.get("text") ?? "");  const sbx = await Sandbox.getOrCreate(`slack-${form.get("team_id")}-${form.get("channel_id")}`, {    vcpu: 1,    memoryMiB: 2048,    idlePauseSeconds: 120,    network: { internet: false },    labels: { app: "slack-runner" },  });  await sbx.files.write("/workspace/snippet.py", code);  const run = await sbx.exec(["python3", "/workspace/snippet.py"], { timeoutMs: 20_000 });  const output = (run.stdout + run.stderr).slice(-2_800) || "(no output)";  const status = run.timedOut ? "timed out after 20 s" : `exit ${run.exitCode}`;  await fetch(form.get("response_url")!, {    method: "POST",    headers: { "content-type": "application/json" },    body: JSON.stringify({      response_type: "in_channel",      text: `${status}\n${TICKS}${output}${TICKS}`,    }),  });}// Your HTTP route for the slash command, after checking Slack's signature.export async function slashCommand(request: Request): Promise<Response> {  const form = new URLSearchParams(await request.text());  void runAndReply(form).catch((error) => console.error(error));  return new Response("Running…"); // the acknowledgement, well inside 3 seconds}
Pythonimport jsonimport reimport threadingimport urllib.requestfrom withruntime import SandboxTICKS = "`" * 3  # a Markdown code fencedef unfence(text: str) -> str:    t = text.strip()    if len(t) < 6 or not (t.startswith(TICKS) and t.endswith(TICKS)):        return t    return re.sub(r"^\w*\n", "", t[3:-3])  # drop a language tag such as "python"def run_and_reply(form: dict) -> None:    code = unfence(form.get("text", ""))    sbx = Sandbox.get_or_create(        f"slack-{form['team_id']}-{form['channel_id']}",        vcpu=1,        memory_mib=2048,        idle_pause_seconds=120,        network={"internet": False},        labels={"app": "slack-runner"},    )    sbx.files.write("/workspace/snippet.py", code)    run = sbx.exec(["python3", "/workspace/snippet.py"], timeout_ms=20_000)    output = (run.stdout + run.stderr)[-2_800:] or "(no output)"    status = "timed out after 20 s" if run.timed_out else f"exit {run.exit_code}"    body = json.dumps({"response_type": "in_channel", "text": f"{status}\n{TICKS}{output}{TICKS}"}).encode()    request = urllib.request.Request(form["response_url"], data=body,                                     headers={"content-type": "application/json"})    urllib.request.urlopen(request, timeout=10)def slash_command(form: dict) -> str:    """Call from your web framework's route, after checking Slack's signature."""    threading.Thread(target=run_and_reply, args=(form,), daemon=True).start()    return "Running…"
  • The sandbox is found by name, so every /run in a channel lands on the same machine, woken if it paused. Variables do not carry over between runs, but files the snippets write do.
  • The snippet runs as a file with an array command: nothing in it is read by a shell on your side or in the sandbox.
  • The bot posts the last 2,800 characters of output, so a snippet that prints a megabyte still produces a readable message. A result holds at most 64 KiB of each stream in any case.

What each setting stops

A snippet that... What stops it Setting
Reads your bot's tokens or database It runs on another machine, a microVM with its own kernel Your app never runs the code
Reads another channel's files Each channel has its own sandbox One name per team and channel
Calls out to the internet Every outbound connection refused, root included network: { internet: false }
Loops forever The command returns timedOut: true after 20 seconds timeoutMs
Keeps the machine busy overnight It pauses after two idle minutes; CPU billing stops idlePauseSeconds
Runs up a bill Creates past the key's daily limit fail and charge nothing A daily spending limit per key

Your bot's Slack token stays in your app. The sandbox never needs it, because the reply is posted from your process, not from the snippet.

Let a channel install packages

The default image already has NumPy, pandas and matplotlib. For anything else, open PyPI for one install, then close it again:

TypeScriptimport { Sandbox } from "withruntime";const sbx = await Sandbox.getOrCreate("slack-T123-C456", { network: { internet: false } });await sbx.network.set({ internet: true, allow: ["pypi.org", "*.pythonhosted.org"] });await sbx.exec(["pip", "install", "--quiet", "tabulate"], { check: true, timeoutMs: 120_000 });await sbx.network.set({ internet: false });

The package stays in that channel's sandbox across pauses. Map a /run-install tabulate command to this, and check the package name against an allowed list before you pass it on.

Clean up channels nobody uses

Every channel sandbox carries the label app: slack-runner. List them and stop the ones you no longer need:

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const archived = new Set(["slack-T123-C0OLD"]); // channels your app saw archivedconst page = await runtime.sandboxes.list({ labels: { app: "slack-runner" }, state: ["paused"] });for await (const sbx of page) if (archived.has(sbx.info.name ?? "")) await sbx.stop();

A paused sandbox is kept for 30 days by default on paid credit, and 1 to 365 days if you set it.

What it costs

Take 20,000 snippets a month. Each keeps its channel's 1 vCPU, 2 GiB sandbox running for 150 seconds, the run plus two idle minutes before the pause, and uses 2 CPU-seconds. Fifty channels each keep 0.3 GB of their own while paused:

TextCPU:    20,000 × 150 s × 0.05 vCPU / 3,600 × $0.025 = $1.04Memory: 20,000 × 150 s / 3,600 × 2 GiB × $0.0075   = $12.50Paused: 50 channels × 0.3 GB × $0.08               = $1.20Total:                                               $14.74

The CPU line uses the floor: 2 CPU-seconds is less than a twentieth of a vCPU over 150 seconds (7.5 CPU-seconds), so the floor sets the charge. Counting each snippet's running time separately is an upper bound, since snippets in a busy channel share one wake. New accounts get 50 free sandbox hours, no card.

Start

Terminalnpx withruntime sandbox run --trial -- python3 -c 'print(6 * 7)'

The first run prints a link to approve in your browser. On a server, give the bot its own key from API keys in RUNTIME_API_KEY, with a daily limit (security).

Related: a Discord bot that runs code, code interpreter for chatbots, run untrusted LLM code safely, pause and resume a sandbox.

Sources

Checked 25 September 2026.

Facts on this page were checked on 25 September 2026.