# How to let a Slack bot run code safely Acknowledge Slack within 3 seconds. Run the snippet in a microVM with no internet access and a time limit. Then post the output to response_url. **On Runtime, each Slack channel can keep its own machine. The machine pauses when the channel goes quiet.** A sandbox paused after two idle minutes keeps its files, installed packages and memory. It costs $0.08 per GB per 30-day month while paused. While it runs, a 1 vCPU, 2 GiB sandbox costs $0.01625 an hour while idle, because Runtime bills measured CPU at $0.025 per vCPU-hour ([pricing](/docs/pricing), 26 October 2026). ## What Slack requires A slash command reaches your app as a form-encoded POST. The form has 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". It also says a `response_url` accepts up to 5 responses within 30 minutes. So the bot acknowledges right away, runs the code afterward, and posts the result to `response_url`. With `response_type: "in_channel"`, the whole channel sees the result. The default, `ephemeral`, shows it only to the person who sent the command. ## The short answer Verify that the request came from Slack before anything runs. Then send the acknowledgement, and run the work after the response is sent: ```ts check import { Sandbox } from "withruntime"; const TICKS = "`".repeat(3); // a Markdown code fence function 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 { 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 } ``` ```python check import json import re import threading import urllib.request from withruntime import Sandbox TICKS = "`" * 3 # a Markdown code fence def 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…" ``` - Each channel's sandbox is found by name. Every `/run` in a channel uses the same machine. If the machine is paused, it wakes first. Variables do not carry over between runs, but files the snippets write do. - The snippet runs as a file, with an array command. No shell reads it, on your side or in the sandbox. - The bot posts the last 2,800 characters of output. 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 other packages, allow PyPI for one install, then turn it off again: ```ts check import { 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 code, 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: ```ts check import { Runtime } from "withruntime"; const runtime = new Runtime(); const archived = new Set(["slack-T123-C0OLD"]); // channels your app saw archived const 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 while your account has credit, or for the 1 to 365 days you set. ## What it costs Take 20,000 snippets a month. Each snippet keeps its channel's 1 vCPU, 2 GiB sandbox running for 150 seconds. That is the run plus two idle minutes before the pause. Each snippet uses 2 CPU-seconds. Fifty channels each keep 0.3 GB of their own data while paused: ``` CPU: 20,000 × 150 s × 0.05 vCPU / 3,600 × $0.025 = $1.04 Memory: 20,000 × 150 s / 3,600 × 2 GiB × $0.0075 = $12.50 Paused: 50 channels × 0.3 GB × $0.08 = $1.20 Total: $14.74 ``` The CPU line uses the floor. 2 CPU-seconds is less than a twentieth of a vCPU of the 150 seconds (7.5 CPU-seconds), so the floor sets the charge. Counting each snippet's running time separately gives an upper bound. Snippets in a busy channel share one wake. A new account gets 100 free hours of a 1 GB machine in its first 30 days, with no card required. ## Start ```bash no-run printf '%s\n' 'print(sum([125, 250, 375]))' > invoice.py npx withruntime sandboxes run -- python3 - < invoice.py ``` The first run prints a link to approve in your browser. On a server, give the bot its own key from [API keys](https://withruntime.com/account/keys) in `RUNTIME_API_KEY`, with a daily limit ([security](/docs/security)). Related: [a Discord bot that runs code](/use-cases/discord-bot-code-execution), [code interpreter for chatbots](/use-cases/code-interpreter-for-chatbots), [run untrusted LLM code safely](/use-cases/run-untrusted-llm-code), [pause and resume a sandbox](/how-to/pause-and-resume-a-sandbox). ## Sources Checked 26 October 2026. - [Slack, implementing slash commands](https://docs.slack.dev/interactivity/implementing-slash-commands) - [Slack, handling user interaction](https://docs.slack.dev/interactivity/handling-user-interaction) Facts on this page were checked on 26 October 2026.