Guide 06 / Build — human in the loop
A Slack approval bot for releases
The agent prepares the release. A person on your team approves it. This guide builds the full path: the agent makes one request, your bot posts one Slack message, a person clicks one button, and Pitot returns one correlated answer. If nobody answers before the deadline, the answer is deny.
01What you build
You write one program and one config fragment:
- A Controller program. It receives each
release.approvalrequest, posts a message with Approve and Deny buttons to#releases, waits for a click, and returns the decision. The full file is below, in TypeScript and in Python. - A tenant fragment. It registers your program for the
release.approvalkind, sets a 10-minute deadline, and sets deny as the default for timeout and for bot failure.
The Slack connection uses Bolt in Socket Mode. Socket Mode does not need a public URL. The bot runs on your machine or in your CI, next to Pitot.
02See it on the bench — you are the approver
Run the scenario. Then click a button — or do not click, and watch the deadline answer for you.
03Prerequisites
Your repository has a pin and shims. If it does not, follow Install & the repo-pinned shim first.
Create an app at api.slack.com. Turn on Socket Mode. Add the chat:write bot scope. Install the app to your workspace.
Copy the bot token (xoxb-…) and an app-level token with the connections:write scope (xapp-…).
Invite the bot to #releases: /invite @your-bot.
04Step 1 — Install the SDK
Pick your language. The tabs below remember your choice for the whole page.
pitot install typescript
installs @operatorstack/pitot from your registry, pinned to the CLI version
npm install @slack/bolt
Slack's official Bolt framework
pitot install python
installs operatorstack-pitot from your registry, pinned to the CLI version · import name: pitot
pip install slack-bolt
Slack's official Bolt framework
The Pitot SDK gives you three names: runController, allow, and deny. See Tenant config & typed SDKs for the registry details.
05Step 2 — Write the Controller
This is the complete program. It does four things: it connects to Slack, it receives each request, it posts the buttons, and it returns one decision. The bot answers within 540 seconds. The fragment deadline is 600 seconds. The gap makes sure your answer arrives before Pitot's declared default has to answer for you.
import bolt from "@slack/bolt"; import { runController, allow, deny } from "@operatorstack/pitot"; const { App } = bolt; const APPROVAL_TIMEOUT_MS = 540_000; // answer before the fragment deadline (600 s) const app = new App({ token: process.env.SLACK_BOT_TOKEN, appToken: process.env.SLACK_APP_TOKEN, socketMode: true, }); let settleDecision = null; app.action("approve_release", async ({ ack, body }) => { await ack(); settleDecision?.({ outcome: "allow", user: body.user.username }); }); app.action("deny_release", async ({ ack, body }) => { await ack(); settleDecision?.({ outcome: "deny", user: body.user.username }); }); const waitForDecision = () => new Promise((resolve) => { const timer = setTimeout(() => resolve(null), APPROVAL_TIMEOUT_MS); settleDecision = (decision) => { clearTimeout(timer); settleDecision = null; resolve(decision); }; }); await app.start(); runController("slack-approval", async (request) => { const { release } = request.data; await app.client.chat.postMessage({ channel: "#releases", text: `Approve ${release}?`, blocks: [ { type: "section", text: { type: "mrkdwn", text: `Release *${release}* requests approval.` } }, { type: "actions", elements: [ { type: "button", action_id: "approve_release", style: "primary", text: { type: "plain_text", text: "Approve" } }, { type: "button", action_id: "deny_release", style: "danger", text: { type: "plain_text", text: "Deny" } }, ] }, ], }); const decision = await waitForDecision(); if (!decision) return deny(`No decision for ${release} in 540 seconds.`); if (decision.outcome === "allow") return allow(`${release} approved by @${decision.user} in #releases.`); return deny(`${release} denied by @${decision.user} in #releases.`); });
import os import queue from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from pitot import run_controller, allow, deny app = App(token=os.environ["SLACK_BOT_TOKEN"]) decisions = queue.Queue() APPROVAL_TIMEOUT_S = 540 # answer before the fragment deadline (600 s) @app.action("approve_release") def on_approve(ack, body): ack() decisions.put(("allow", body["user"]["username"])) @app.action("deny_release") def on_deny(ack, body): ack() decisions.put(("deny", body["user"]["username"])) def handle(request): release = request.data["release"] app.client.chat_postMessage( channel="#releases", text=f"Approve {release}?", blocks=[ {"type": "section", "text": {"type": "mrkdwn", "text": f"Release *{release}* requests approval."}}, {"type": "actions", "elements": [ {"type": "button", "action_id": "approve_release", "style": "primary", "text": {"type": "plain_text", "text": "Approve"}}, {"type": "button", "action_id": "deny_release", "style": "danger", "text": {"type": "plain_text", "text": "Deny"}}, ]}, ], ) try: outcome, user = decisions.get(timeout=APPROVAL_TIMEOUT_S) except queue.Empty: return deny(f"No decision for {release} in {APPROVAL_TIMEOUT_S} seconds.") if outcome == "allow": return allow(f"{release} approved by @{user} in #releases.") return deny(f"{release} denied by @{user} in #releases.") if __name__ == "__main__": SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).connect() run_controller("slack-approval", handle)
Read the file top to bottom:
- The Slack part is standard Bolt. Socket Mode connects out to Slack. The two
app.actionhandlers acknowledge the click and record who decided. - The Pitot part is three names.
runControllerreads each correlated request from Pitot and sends back your return value.allowanddenybuild the response. You never touch the wire format. - Every decision names a person. The response message records who clicked, in which channel. The agent receives that message as the tool result.
Pitot does not include a Slack integration. This file is yours: your app, your tokens, your channel. Pitot transports the request and the decision.
06Step 3 — Register the fragment
One fragment registers your bot for the release.approval kind. Only the command line differs between languages.
controllers: release.approval: id: slack-approval command: ["node", "slack_approval.mjs"] dir: "approval-bot" deadline_ms: 600000 on_timeout: deny on_unavailable: deny
controllers: release.approval: id: slack-approval command: ["python3", "slack_approval.py"] dir: "approval-bot" deadline_ms: 600000 on_timeout: deny on_unavailable: deny
The three policy lines do the safety work. deadline_ms: 600000 gives a person ten minutes. on_timeout: deny makes silence a no. on_unavailable: deny makes a crashed bot a no. Exactly one Controller owns release.approval — a second claim fails discovery with an error that names both files.
07Step 4 — Run it
export SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-…
Pitot starts your bot with this environment
export PITOT_RUNTIME="${XDG_RUNTIME_DIR:-$TMPDIR}/pitot/project.json"
pitot run --runtime "$PITOT_RUNTIME"
discovers your fragment and starts the bot
pitot request release.approval --data '{"release":"v1.4.0"}' --runtime "$PITOT_RUNTIME"
from a second terminal, a skill, or any script the agent runs
Three outcomes are possible. Click Approve, and the request returns allow with the approver's name. Click Deny, and it returns deny with the same attribution. Do nothing for ten minutes, and Pitot answers deny from the declared default.
08What Pitot guarantees, so your bot does not have to
- Correlation. A response can resolve only the request it belongs to.
- Single response. Pitot rejects late, stale, duplicate, mismatched, and malformed responses. Each pending action gets exactly one terminal resolution.
- Declared defaults. Timeout and unavailability behavior is configuration you can audit, not a code path someone forgot to test.
- Meaning stays yours. Pitot does not know what "approved" means. Your bot, your channel, and your team own that definition.
pitot init --template release-approval scaffolds a runnable request/response Controller. Replace its local decision with the Slack round-trip above.