An escalated request is only useful if a human sees it fast. Sanction delivers every escalation and budget alert three ways; pick any or all.
The wallet owner's email receives every escalation with an approve link, and a heads-up when any budget crosses its 80% line. Nothing to configure.
One path: Add to Slack. Escalations arrive as a card with Approve, Deny, and Review in Sanction; a click resolves the approval through the same path as the dashboard and mints the same one-use grant.
- Have a wallet and an admin session (create one if you don't).
- Dashboard → Approvals → Add to Slack. Pick the channel at install. Sanction stores the workspace bot token under the wallet's encrypted vault.
- Press Send a test escalation. A real $30 escalation is raised on one of your agents, labeled as a test, and the card lands in the channel within seconds. Approve it there; the grant shows on the Approvals page.
Who can decide: anyone in that channel. The channel is the approver group,
so connect a private channel for approvals. The actor is recorded as
slack:<username> on the decision.
Security: Slack's v0 HMAC over the raw body; timestamps older than five
minutes and bad signatures are 401; each button carries a two-hour token bound
to the wallet, approval, workspace, and channel; the endpoint fails closed (503)
if the signing secret is unset. Disconnect on the same page revokes the install.
Self-hosting: the Slack app you need
Create a Slack app with scopes chat:write and incoming-webhook. Enable
Interactivity with Request URL https://<your-host>/api/slack/interactive
and set the OAuth Redirect URL to https://<your-host>/api/slack/oauth/callback.
Set SANCTION_SLACK_SIGNING_SECRET, SANCTION_SLACK_CLIENT_ID, and
SANCTION_SLACK_CLIENT_SECRET on the deployment. OAuth start returns 503 until
the client id is set.
Notification-only routes (no buttons)
An incoming webhook URL (https://hooks.slack.com/...) pasted into
Notification routes gets readable Block Kit messages with a Review in
Sanction link — no app, the URL is the secret, but Slack cannot send button
clicks back. The env SANCTION_SLACK_BOT_TOKEN plus a pasted channel archive
URL (https://slack.com/archives/C…) is the older platform-token fallback and
also posts the link only. Interactive decisions require Add to Slack.
Each notification route subscribes to its own event list, so channel routing is just multiple routes:
| Channel | Subscribe to |
|---|---|
#approvals |
approval.created, approval.resolved |
#finance-alerts |
budget.threshold, budget.exhausted |
#leadership |
report.weekly_digest (Monday rollup, nothing else) |
#agent-ops |
* (everything) |
Add each Slack URL as its own route with the matching events via
POST /v1/webhooks (events array), or the dashboard form for the default set.
Routes subscribed to report.weekly_digest get last week in one message every
Monday: spend and token cost with week-over-week deltas, approved / denied /
escalated counts, secret accesses, and the busiest agent. It is never in the
default event set — subscribe the routes that want it (or a * route hears it
like everything else). A quiet week still reports; all zeros is information.
Any non-Slack https endpoint receives the raw event JSON, signed:
POST <your-url>
x-sanction-event: approval.created
x-sanction-signature: sha256=<HMAC-SHA256 of the exact body, keyed by your route's whsec_ secret>
Verify the signature before trusting the event. The signing secret is shown
once when the route is created. Endpoints must be public https — loopback,
private ranges, and cloud-metadata hosts are rejected at registration.
- Agent calls
/v1/authorize→ policy says escalate. - You get the Slack ping → tap Approve or Deny (interactive app) or Review in Sanction (incoming webhook).
- Approval mints a one-use grant; the agent redeems it with
grant_idand proceeds. Every step is in the audit feed (GET /v1/audit-events).