Skip to content
Runtime

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, 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 /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:

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

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