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, 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:
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…"- Each channel's sandbox is found by name. Every
/runin 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:
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 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:
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 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
Terminalprintf '%s\n' 'print(sum([125, 250, 375]))' > invoice.pynpx withruntime sandboxes run -- python3 - < invoice.pyThe 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 26 October 2026.
Facts on this page were checked on 26 October 2026.