Worked example: a Slack on-call bot
A slash command you host answers who is on call: it checks Slack's signature, authorizes the workspace and user, makes one supported read and escapes what it sends back.
On this page
/oncall payments in Slack answers "On call for payments: @dana." Slack sends the command to a server
you host; that server checks the request really came from Slack, decides whether this workspace and
user may ask, makes one read from Anectico and formats the answer.
The runnable example is scripts/testing/examples/apps/slack_bot.py in the Anectico repository, with
the shared client anectico_rest.py. Python 3.11 or later, standard library only.
Before you start
| Decision | This example |
|---|---|
| Where it runs | Your bot's backend, reachable by Slack over HTTPS. |
| Project | Exactly one, named in every read. |
| Runtime permissions | oncall:read, projects:read, members:read. |
| Builder authority | Your agent's own credential provisions the key and, if needed, creates the schedule through its preview and confirmation; the bot never holds that credential. |
| Secrets | ANECTICO_API_KEY and Slack's SLACK_SIGNING_SECRET, both from the host's secret store. |
| Expiry and stop | A fixed expiry; anectico apikey revoke <key-id>. |
| Contract | contract-pin.json; the bot refuses to start against another contract. |
What it calls
| Operation | Qualification | What it means for this bot |
|---|---|---|
GET /api/v1/oncall/schedules/{id}/on-call |
Supported | A bounded lookup for one schedule at one instant. It returns found and the member's user_id, never contact details. Resolve it per command, or cache it for at most one minute. |
The bot maps schedule names to IDs (ONCALL_SCHEDULES) and member IDs to Slack users
(ONCALL_MEMBERS) from its own configuration. Anectico does not know your Slack users.
Check the request before anything else
def verify_slack(secret: str, timestamp: str, signature: str, body: bytes, now: float) -> bool:
"""Slack's v0 request signature, with a five-minute replay window."""
if not timestamp.isdigit() or abs(now - int(timestamp)) > 300:
return False
expected = "v0=" + hmac.new(secret.encode(), b"v0:" + timestamp.encode() + b":" + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
The server rejects a request with a wrong signature or a timestamp more than five minutes away with
401, before it reads anything. It then allows only the workspaces in SLACK_ALLOWED_TEAMS and, when
set, the users in SLACK_ALLOWED_USERS. Only then does it read Anectico. Request logs never include
the body, which holds user and workspace data.
Answer
def answer(self, form: dict[str, str]) -> dict[str, Any]:
"""The reply to one already-verified, already-authorized command."""
alias = form.get("text", "").strip().lower()
schedule_id = self._config.schedules.get(alias)
if schedule_id is None:
known = ", ".join(sorted(self._config.schedules))
return reply(f"Which schedule? Try one of: {escape(known)}.")
path = Client.path("/api/v1/oncall/schedules/{id}/on-call", id=schedule_id)
try:
answer = self._client.get(path, {"project_id": self._config.project_id}, deadline=2.0).body
except NotFound:
return reply(f"The {escape(alias)} schedule no longer exists in Anectico.")
except Busy as busy:
wait = f" in {busy.retry_after} s" if busy.retry_after else ""
return reply(f"Anectico is busy. Try again{wait}.")
except Paused:
return reply("Anectico reads are paused by a limit an owner set.")
except (AccessEnded, Forbidden):
return reply("This bot's access to Anectico has ended. Ask an admin.")
except AnecticoError:
return reply("Anectico refused the request.")
if not answer.get("found"):
return reply(f"Nobody is on call for {escape(alias)} right now.")
member = str(answer.get("user_id", ""))
slack_user = self._config.members.get(member)
who = f"<@{slack_user}>" if slack_user and SLACK_USER.match(slack_user) else f"member {escape(member)}"
return reply(f"On call for {escape(alias)}: {who}.")
- Three seconds. Slack expects an answer within three seconds, so the read has a two-second deadline and at most one retry. If Anectico asks for a longer wait, the bot answers "Anectico is busy. Try again in 5 s." at once instead of hanging.
- Escaping. Anything that came from data is escaped (
&,<,>) before it goes into a Slack message, so a stored value can never become@channel, a mention or a link. Only a mention built from the bot's own configuration is sent unescaped. - Nobody on call reads "Nobody is on call for payments right now." It is an answer, not an error.
- Access ended reads "This bot's access to Anectico has ended." The bot never switches to another key.
- Ephemeral. Replies go only to the person who asked.
Run it
export ANECTICO_API_URL=https://app.anectico.com ANECTICO_PROJECT_ID=<project-uuid>
export ANECTICO_API_KEY="$(your-secret-store read anectico/oncall-bot)"
export SLACK_SIGNING_SECRET="$(your-secret-store read slack/oncall-bot)" SLACK_ALLOWED_TEAMS=T0123ABCD
export ONCALL_SCHEDULES='payments=<schedule-uuid>' ONCALL_MEMBERS='<member-id>=U0456EFGH'
python3 slack_bot.py
Point the slash command's request URL at https://<your-host>/slack/commands. Your agent can check the
same answer with its own credential:
anectico oncall now <schedule-uuid> -o json
What the example's checks cover and what they do not: they drive the handler with correctly and incorrectly signed requests and a real Anectico read. Slack's own delivery, app installation and workspace configuration are yours to verify in Slack; a passing local check is not evidence that Slack reaches your server.
Stop it and clean up
Revoke the bot's key; the next command answers that access has ended. Messages already posted in Slack stay in Slack, under your Slack workspace's retention.