Skip to content Skip to content

Wire Slack/Discord notifications for approvals

Pipe approval requests, blocked high-priority issues, and budget breaches to a Slack or Discord channel so the board sees decisions without watching the UI. The recipe is the same for both: a Paperclip routine pings a notifier agent on a schedule, the agent diffs against a “last-seen” cursor, and posts a structured message to a webhook URL.

Time to working notification: about 15 minutes.


┌──────────────────┐ schedule trigger ┌────────────────────┐
│ Paperclip routine│──────(every 60s)────▶│ Notifier agent │
└──────────────────┘ │ ─ poll API │
│ ─ diff vs cursor │
│ ─ format payload │
└─────────┬──────────┘
│ HTTPS POST
┌─────────────────────────┐
│ Slack/Discord webhook │
└─────────────────────────┘

Paperclip does not push outbound webhooks today, the routine + agent pair is the push. That keeps the moving parts in one place: you can read the notifier’s run history, replay a missed event by re-running the routine, and rotate the channel webhook by updating one secret. See Heartbeats & Routines for the underlying model.


Resist the urge to mirror everything. A noisy channel gets muted within a week. Three event classes are worth the noise budget:

EventWhy it mattersHow to detect
Pending approvalsThe board can’t decide what they don’t see. Hires, CEO strategy, and request_board_approval all block agent work until resolved.GET /api/companies/{companyId}/approvals?status=pending
Blocked high-priority issuesA critical or high issue that flips to blocked means an agent gave up and needs a human.GET /api/companies/{companyId}/issues?status=blocked&priority=critical,high
Budget breachesAgents auto-pause at 100% budget. You want to know before that happens.GET /api/companies/{companyId}/dashboard exposes per-agent budget utilisation.

Everything else (issue created, comment posted, agent woke up) is more usefully read in the dashboard, don’t pipe it.


Slack has two webhook flavours: classic incoming webhooks and the App-managed kind. Use the App-managed flow, it’s the only one Slack still develops.

  1. Visit api.slack.com/apps and click Create New App → From scratch.
  2. Name it Paperclip Notifications, pick the workspace, and create.
  3. In the left sidebar, open Incoming Webhooks and toggle Activate Incoming Webhooks on.
  4. Click Add New Webhook to Workspace, choose the channel (e.g. #paperclip-board), and authorise.
  5. Copy the webhook URL. It looks like https://hooks.slack.com/services/T.../B.../xxxxxxxxxxxx.

The URL is itself a bearer token, anyone who has it can post to the channel as Paperclip. Never commit it. Store it as a secret on the notifier agent’s environment (see Signing + secrets below).


Discord webhooks are channel-level, not server-level. You’ll need Manage Channels on the target channel.

  1. Open the target channel’s settings (gear icon → Edit Channel).
  2. Integrations → Webhooks → New Webhook.
  3. Rename it Paperclip and optionally upload an avatar.
  4. Copy Webhook URL. Format: https://discord.com/api/webhooks/<id>/<token>.

Same warning as Slack: the URL contains the auth token. Treat it like a password.


Hire a small agent with the only job of fanning out notifications. Any code-capable adapter works. What matters is that it can read three env vars and POST to two URLs.

Create a routine that fires on a schedule and assigns itself to the notifier:

Terminal window
curl -X POST "$PAPERCLIP_API_URL/api/companies/$COMPANY_ID/routines" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Notify board channel",
"description": "Diff approvals and blocked issues since last run; post to Slack and Discord.",
"assigneeAgentId": "<notifier-agent-id>",
"priority": "low",
"concurrencyPolicy": "skip_if_active",
"catchUpPolicy": "skip_missed"
}'

Then attach a 1-minute schedule trigger:

Terminal window
curl -X POST "$PAPERCLIP_API_URL/api/routines/<routine-id>/triggers" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "schedule",
"label": "Every minute",
"enabled": true,
"cronExpression": "* * * * *",
"timezone": "UTC"
}'

skip_if_active plus skip_missed is the right pair here: if a previous run is still finishing, skip this tick instead of stacking duplicates, and don’t try to catch up on missed minutes after a restart.


Three things, in order. The shape is short enough to fit in the agent’s instructions:

1. Read PAPERCLIP_NOTIFIER_LAST_SEEN_AT from a tiny KV store
(or a comment on a "state" issue you own, anything durable).
2. Fetch:
- GET /api/companies/{COMPANY_ID}/approvals?status=pending
- GET /api/companies/{COMPANY_ID}/issues?status=blocked&priority=critical,high
Drop anything with updatedAt <= last-seen-at.
3. For each new event, POST a message to SLACK_WEBHOOK_URL
and DISCORD_WEBHOOK_URL. On success, write the new max
updatedAt back to last-seen-at.

The cursor is what stops a 60-second routine from re-posting the same approval forever. If you’re using a custom adapter, persist the cursor in your own store; if you’re using another adapter, write it as a markdown comment on a dedicated notifier-state issue and read it on next wake.


A useful notification names the event, the requester, and the action, and gives a one-click path to the decision page. A bare link with no context is what gets channels muted.

{
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "Approval pending: Hire CTO" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Type*\nhire_agent" },
{ "type": "mrkdwn", "text": "*Requested by*\nCEO" },
{ "type": "mrkdwn", "text": "*Budget*\n$200/mo" },
{ "type": "mrkdwn", "text": "*Adapter*\nhttp" }
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Need a CTO to own backend platform decisions and review hires below the C-level."
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "Review approval" },
"url": "https://paperclip.example.com/PAP/approvals/<approval-id>",
"style": "primary"
}
]
}
]
}
{
"username": "Paperclip",
"embeds": [
{
"title": "Approval pending: Hire CTO",
"url": "https://paperclip.example.com/PAP/approvals/<approval-id>",
"color": 2278750,
"description": "Need a CTO to own backend platform decisions and review hires below the C-level.",
"fields": [
{ "name": "Type", "value": "hire_agent", "inline": true },
{ "name": "Requested by", "value": "CEO", "inline": true },
{ "name": "Budget", "value": "$200/mo", "inline": true },
{ "name": "Adapter", "value": "http", "inline": true }
],
"footer": { "text": "Paperclip • approvals" }
}
]
}

For blocked-issue alerts, swap the header for "Blocked: PAP-142, Migrate billing to Stripe", replace the fields with priority + assignee + last-comment-author, and point the button at /PAP/issues/PAP-142. For budget breaches, header "Budget at 92%: backend-engineer" with fields for monthly cap, MTD spend, and link to /PAP/agents/backend-engineer/runs.


Slack and Discord webhook URLs are bearer tokens, possession is auth. Anyone with the URL can post to the channel as Paperclip. Two rules cover the security posture:

  1. Never commit the URL. Store it as a secret on the notifier agent’s environment in the agent’s settings in the web app. Rotate the secret from your company Secrets surface whenever the URL changes.
  2. Rotate on exposure. In Slack: regenerate the webhook from the App’s Incoming Webhooks page (the old URL stops working immediately). In Discord: open the webhook settings and click Copy Webhook URL → Regenerate.

If you also want to receive webhooks into Paperclip (Stripe → Paperclip routine, GitHub → Paperclip routine), that’s the other direction and uses Paperclip’s signed-trigger model with bearer, hmac_sha256, or github_hmac modes. Documented in Routines API → Webhook triggers.


Before turning the routine active, dry-run the webhook from your laptop:

Terminal window
curl -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"text":"Paperclip notifier: wiring check"}'
curl -X POST "$DISCORD_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"content":"Paperclip notifier: wiring check"}'

A 200 OK plus the message in the channel means the URL is good. If you get 400, the JSON is malformed. If you get 403, the URL has been rotated or revoked.

Then run the routine once manually:

Terminal window
curl -X POST "$PAPERCLIP_API_URL/api/routines/<routine-id>/run" \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "source": "manual" }'

Check the resulting issue from the routine detail page’s Runs tab. The first run will post one message per currently-pending approval and currently-blocked high-priority issue, then write its cursor. The next minute’s tick should be a no-op.


  • Same approval re-posted every minute. The cursor isn’t being persisted. Print last-seen-at at the top of every run and confirm it advances.
  • Slack returns invalid_blocks. Block Kit is strict: no unknown fields, no empty fields array, button URLs must be HTTPS. Validate with Block Kit Builder.
  • Discord returns 429 rate limited. You’re over Discord’s per-webhook limit (~5/2s). Batch into one embed per message with multiple embeds[] entries instead of N separate messages.
  • Routine fires but nothing posts. Check the notifier agent’s run history: failed runs include the exception. The most common cause is missing env vars on the agent.