Pitot
Guides / 06 — Build: Slack approval

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.approval request, 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.approval kind, 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

Bench demo / release approvalSimulated · illustrative

Run the scenario. Then click a button — or do not click, and watch the deadline answer for you.

03Prerequisites

Pitot in the repo

Your repository has a pin and shims. If it does not, follow Install & the repo-pinned shim first.

A Slack app

Create an app at api.slack.com. Turn on Socket Mode. Add the chat:write bot scope. Install the app to your workspace.

Two tokens

Copy the bot token (xoxb-…) and an app-level token with the connections:write scope (xapp-…).

The channel

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.

01 pitot install typescript

installs @operatorstack/pitot from your registry, pinned to the CLI version

02 npm 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.

approval-bot/slack_approval.mjs
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.`);
});

Read the file top to bottom:

  • The Slack part is standard Bolt. Socket Mode connects out to Slack. The two app.action handlers acknowledge the click and record who decided.
  • The Pitot part is three names. runController reads each correlated request from Pitot and sends back your return value. allow and deny build 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.
Boundary

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.

.pitot/conf.d/release-approval.yaml
controllers:
  release.approval:
    id: slack-approval
    command: ["node", "slack_approval.mjs"]
    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

Run
01 export SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-…

Pitot starts your bot with this environment

02 export PITOT_RUNTIME="${XDG_RUNTIME_DIR:-$TMPDIR}/pitot/project.json"
pitot run --runtime "$PITOT_RUNTIME"

discovers your fragment and starts the bot

03 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.
Start here

pitot init --template release-approval scaffolds a runnable request/response Controller. Replace its local decision with the Slack round-trip above.