Slack Approval Bot
Route approval gates to a Slack channel with one-click approve and reject buttons. The router emits exec.approval_requested; a small bot posts an interactive message and calls back to the approve or reject endpoint on click. Expected time: 45 minutes including Slack app setup.
Prerequisites
- An API key with the
executionscope. - A Slack workspace with permission to create an app + bot.
- A public HTTPS endpoint for the bot (e.g.
ngrokfor dev). - The webhook family enabled for your project (see Webhooks).
Environment
Five environment variables drive the bot. Set them once; the snippets below reference them by name.
xoxb-...); needs chat:write + chat:update.
xeroctl webhooks --create. Used to verify HMAC.
execution scope.
https://xerotier.ai.
prj_...); read with xeroctl auth whoami.
Step 1: Register the Webhook
Point the Xerotier webhook at your bot and subscribe to the four terminal approval states:
exec.approval_requested-- new pending approval, post a Slack message.exec.approved-- decision recorded; mark the Slack message resolved.exec.rejected-- decision recorded; mark the Slack message resolved.exec.approval_timed_out-- no decision in window; mark the Slack message stale.
xeroctl webhooks --create \
--url https://your-bot.example.com/xerotier \
--events exec.approval_requested,exec.approved,exec.rejected,exec.approval_timed_out \
--secret "$(openssl rand -hex 32)"
Save the secret. The bot verifies every inbound request via HMAC using this value.
Step 2: Write the Slack Bot
A minimal Python + slack_sdk
implementation. Only the webhook-receive path is
shown; Slack app setup is standard.
import os
import hmac
import hashlib
from flask import Flask, request, abort
from slack_sdk import WebClient
app = Flask(__name__)
slack = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
XEROTIER_SECRET = os.environ["XEROTIER_WEBHOOK_SECRET"]
APPROVALS_CHANNEL = "#xem-approvals"
def verify_signature(body, signature, timestamp):
# Router emits "sha256=<hex>" where hex is
# HMAC-SHA256(secret, "<timestamp>.<body>").
if not signature.startswith("sha256="):
return False
expected = signature.split("=", 1)[1]
signed_payload = f"{timestamp}.".encode() + body
mac = hmac.new(XEROTIER_SECRET.encode(), signed_payload,
hashlib.sha256)
return hmac.compare_digest(mac.hexdigest(), expected)
@app.route("/xerotier", methods=["POST"])
def receive():
sig = request.headers.get("X-Webhook-Signature", "")
ts = request.headers.get("X-Webhook-Timestamp", "")
if not verify_signature(request.data, sig, ts):
abort(401)
event = request.get_json()
# Only act on the requested event; other terminal events
# (approved/rejected/timed_out) are handled separately.
if event.get("event_type") != "exec.approval_requested":
return "", 204
slack.chat_postMessage(
channel=APPROVALS_CHANNEL,
text=f"Approval requested: {event['tool_name']}",
blocks=[
{"type": "section",
"text": {"type": "mrkdwn",
"text": f"*Tool:* `{event['tool_name']}`\n"
f"*Workspace:* {event['workspace_id']}\n"
f"*Risk:* {event['risk']}\n"
f"*Invocation:* `{event['invocation_id']}`"}},
{"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "Approve"},
"style": "primary", "value": event["approval_id"],
"action_id": "approve"},
{"type": "button", "text": {"type": "plain_text", "text": "Reject"},
"style": "danger", "value": event["approval_id"],
"action_id": "reject"},
]}
])
return "", 204
Step 3: Interactive Buttons
Slack posts button clicks to the bot's
/slack/actions endpoint. Resolve them
by calling back to the Xerotier router:
import json
import os
import requests
XEROTIER_API_KEY = os.environ["XEROTIER_API_KEY"]
XEROTIER_BASE = os.environ["XEROTIER_BASE"]
XEROTIER_PROJECT_ID = os.environ["XEROTIER_PROJECT_ID"]
@app.route("/slack/actions", methods=["POST"])
def slack_action():
payload = json.loads(request.form["payload"])
action = payload["actions"][0]
approval_id = action["value"]
verb = action["action_id"] # "approve" or "reject"
# Approve/reject routes are mounted under the project prefix.
resp = requests.post(
f"{XEROTIER_BASE}/{XEROTIER_PROJECT_ID}"
f"/v1/exec/approvals/{approval_id}/{verb}",
headers={"Authorization": f"Bearer {XEROTIER_API_KEY}"})
resp.raise_for_status()
return "", 200
The approve/reject handler reads no request body;
the approver recorded in the audit log is the API-
key user id bound to the bot's service-account
key. Provision a dedicated service-account key for
the bot so the audit trail clearly attributes
decisions to the Slack channel. The router
callback path is mounted under
/:project_id/v1/exec/approvals/...;
obtain the project id from xeroctl auth
whoami (the key's bound project) and pass
it to the bot as XEROTIER_PROJECT_ID.
Step 4: Verify End-to-End
- Invoke a gated tool (hypothetical example):
xeroctl exec invoke-blocking --tool kubectl_drain .... Useinvoke-blockingso the operator's shell waits for the terminal outcome;xeroctl exec invokereturns as soon as the invocation is persisted inpending_approvaland does not surface the post-approval tool result in the same shell. - A message appears in
#xem-approvalswithin a few seconds. - Click Approve.
- The pending invocation proceeds. The
invoke-blockingcaller receives the tool result. If you used the non-blocking form, retrieve the result withxeroctl exec poll <invocation_id>.
Productionizing
- Run the bot behind a proper HTTPS endpoint with
a certificate. Do not leave
ngrokin place. - Use a dedicated service-account API key with the
minimum scope (
executiononly). - Log every approval decision to your audit system; the Xerotier audit log is authoritative, but a parallel record simplifies Slack-side forensics.
- Wire the
exec.approval_timed_outevent to update the Slack message so stale approvals do not remain clickable. For complete terminal coverage also subscribe toexec.approvedandexec.rejectedand update the message in the receive handler.
Troubleshooting
401at/xerotier- HMAC mismatch. Verify
XEROTIER_WEBHOOK_SECRETmatches the value returned byxeroctl webhooks --create, and that the canonical string is<timestamp>.<raw-body>(no JSON re-encoding before signing). - Slack message posted, Approve click does nothing
- Either
XEROTIER_PROJECT_IDis wrong (callback returns404) or the API key lacks theexecutionscope (callback returns403). Confirm withxeroctl auth whoamiandxeroctl api-keys show. - Approval shown as already resolved
- Either the Slack retry hit the callback twice (see the note in Step 3) or the approval window expired and an
exec.approval_timed_outevent raced ahead. Mark the Slack message stale on the timed-out event so the buttons read as resolved before the user clicks.