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
/runin 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.74The 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.