A person in Slack decides whether a run goes on. The routine takes a screenshot with render/screenshot@1, posts it with io.b3os/slack/send-block-kit-message@1 as a message with Approve and Reject buttons, and waits on a token: true step. Each button opens the run's signed wait link, run.wait_urls.approve: a page that shows the screenshot and asks the person to confirm. Their answer completes the wait, and the run replies in the message's thread. The recipe publishes the routine, starts a run, presses Approve the way a person does, and deletes the routine.

Check

Verified 2026-11-03: this recipe's last run passed against https://beta-api.b3os.org, label fixture (recorded against a pinned fixture), from commit 6f4f6b98. The recipe harness runs it again, and a failed run takes this page out of the cookbook.

The routine

Save it as routine.json:

json
{ "name": "recipe-slack-approval-buttons", "inputs": { "type": "object", "properties": { "url": { "type": "string", "default": "https://beta.b3os.org/routines" }, "channel": { "type": "string", "default": "b3os-recipes" } } }, "steps": [ { "id": "shot", "uses": "render/screenshot@1", "with": { "url": "${{ inputs.url }}" } }, { "id": "ask", "uses": "io.b3os/slack/send-block-kit-message@1", "with": { "channel": "${{ inputs.channel }}", "text": "Approve the screenshot of ${{ inputs.url }}? Open the message to answer.", "blocks": "${{ toJSON([{'type': 'header', 'text': {'type': 'plain_text', 'text': 'Approve this screenshot?'}}, {'type': 'section', 'text': {'type': 'mrkdwn', 'text': format('<{0}|{0}>, ready to post. Your answer goes in this thread.', inputs.url)}}, {'type': 'image', 'image_url': steps.shot.outputs.artifacts[0].url, 'alt_text': format('Screenshot of {0}', inputs.url)}, {'type': 'actions', 'elements': [{'type': 'button', 'style': 'primary', 'text': {'type': 'plain_text', 'text': 'Approve'}, 'url': run.wait_urls.approve + '?decision=approve'}, {'type': 'button', 'style': 'danger', 'text': {'type': 'plain_text', 'text': 'Reject'}, 'url': run.wait_urls.approve + '?decision=reject'}]}, {'type': 'context', 'elements': [{'type': 'mrkdwn', 'text': 'Each button opens a page that asks you to confirm. The link works once, for an hour.'}]}]) }}" } }, { "id": "approve", "name": "Approve the screenshot", "wait": { "token": true, "ms": 3600000, "prompt": { "title": "Post this screenshot?", "body": "A screenshot of ${{ inputs.url }}, taken by this run. Your answer goes in the Slack thread.", "image": "${{ steps.shot.outputs.artifacts[0] }}", "link": "${{ inputs.url }}" } } }, { "id": "reply", "if": "${{ !steps.approve.outputs.timed_out }}", "uses": "io.b3os/slack/send-message@1", "with": { "channel": "${{ inputs.channel }}", "thread_ts": "${{ steps.ask.outputs.data.ret.ts }}", "text": "${{ steps.approve.outputs.payload.approved == true ? 'Approved through the link.' : 'Rejected through the link.' }}" } } ], "output": { "summary": "${{ steps.approve.outputs.timed_out ? 'Nobody answered within the hour' : format('{0} through the Slack buttons', steps.approve.outputs.payload.approved == true ? 'Approved' : 'Rejected') }}", "decision": "${{ steps.approve.outputs.timed_out ? 'expired' : steps.approve.outputs.payload.decision }}", "via": "${{ steps.approve.outputs.timed_out ? 'none' : steps.approve.outputs.payload.via }}" }}

blocks is one toJSON expression, so the screenshot's URL and the link are escaped for Slack. Slack gets five blocks: a header, a line with the page's link, the screenshot, the two buttons (each url is the wait link with ?decision=approve or ?decision=reject) and a line saying the link works once.

Run it

Publish it and start a run with your channel. The run waits until someone presses a button in Slack.

bash
curl -s -X PUT https://beta-api.b3os.org/v1/routines/recipe-slack-approval-buttons \ -H "Authorization: Bearer $B3OS_API_KEY" -H "Content-Type: application/json" -d @routine.jsoncurl -s https://beta-api.b3os.org/v1/routines/recipe-slack-approval-buttons/runs \ -H "Authorization: Bearer $B3OS_API_KEY" -H "Content-Type: application/json" \ -d '{"input": {"channel": "C0123LAUNCH"}}'

What it produced

json
{ "mode": "fixture", "run_id": "run_01m6h7a3e5cn6kshvh8vr62cnf", "run_status": "succeeded", "steps": [ { "id": "shot", "uses": "render/screenshot@1", "status": "succeeded", "duration_ms": 666.612 }, { "id": "ask", "uses": "core/http@1", "status": "succeeded", "duration_ms": 93.82 }, { "id": "approve", "uses": "core/wait@1", "status": "succeeded", "duration_ms": 895.616 }, { "id": "reply", "uses": "core/http@1", "status": "succeeded", "duration_ms": 77.416 } ], "message_blocks": [ "header", "section", "image", "actions", "context" ], "buttons": [ { "text": "Approve", "style": "primary", "opens_wait_link": true }, { "text": "Reject", "style": "danger", "opens_wait_link": true } ], "page_status": 200, "page_shows_title": true, "page_shows_image": true, "page_preselects_approve": true, "answer_status": 200, "second_answer_status": 410, "decision": "approve", "via": "link", "answer_to_finish_ms": 110}

The outputs this recipe publishes (the publish.outputs list in its recipe.json), with workspace identifiers redacted. The run's stdout, stderr and other outputs are not published.

Captured 2026-11-03 from commit 6f4f6b98 , label fixture (recorded against a pinned fixture). Case recipes/slack-approval-buttons .

How it works

The buttons carry a url and nothing else, so Slack only opens the link: the app needs no request URL and no signing secret, and nothing of yours has to be reachable from Slack. If the app has Interactivity turned on, Slack also sends it a payload for each press, which it has to acknowledge; with Interactivity off it sends none.

Opening the link answers nothing. ?decision=approve preselects Approve and the person confirms on the page, so link previews and scanners that fetch the URL decide nothing. The page shows the prompt and the screenshot, signed fresh each time it is served. The link works once: a second press gets the answered page, and a program gets wait_already_answered. It stops working when the step's ms deadline passes (an hour here), when the run ends, or when someone answers through the API instead, with the signal approve. See Ask a person.

The answer arrives as steps.approve.outputs.payload, {decision, approved, comment, via: "link"}, and the reply step posts it in the thread. The text field is what notifications show; it leaves the link out, so a lock screen never shows it. Slack's answer repeats the blocks, so the link is in the ask step's outputs: a key without routines:run sees [wait link] there.

The two Slack steps need a Slack connection in your project's vault: a bot token with chat:write, the bot invited to the channel. Without the recipes' Slack workspace, the harness runs the same routine with both Slack steps calling Slack's api.test method through core/http@1, form-encoded. Slack echoes the fields it got, blocks and buttons included, and needs no token (label fixture). The recipe then reads the link from that echo, as a person reads it from the message, and the reply has no thread to go to.

Owner: Routines. Products: Routines, Render, Actions and triggers, Vault.

The last green run used fixture origins in place of the slack workspace, so the outputs below come from the fixture.

Output of the last green run

json
{ "mode": "fixture", "run_id": "run_01m6h7a3e5cn6kshvh8vr62cnf", "run_status": "succeeded", "steps": [ { "id": "shot", "uses": "render/screenshot@1", "status": "succeeded", "duration_ms": 666.612 }, { "id": "ask", "uses": "core/http@1", "status": "succeeded", "duration_ms": 93.82 }, { "id": "approve", "uses": "core/wait@1", "status": "succeeded", "duration_ms": 895.616 }, { "id": "reply", "uses": "core/http@1", "status": "succeeded", "duration_ms": 77.416 } ], "message_blocks": [ "header", "section", "image", "actions", "context" ], "buttons": [ { "text": "Approve", "style": "primary", "opens_wait_link": true }, { "text": "Reject", "style": "danger", "opens_wait_link": true } ], "page_status": 200, "page_shows_title": true, "page_shows_image": true, "page_preselects_approve": true, "answer_status": 200, "second_answer_status": 410, "decision": "approve", "via": "link", "answer_to_finish_ms": 110}

The outputs this recipe publishes (the publish.outputs list in its recipe.json), with workspace identifiers redacted. The run's stdout, stderr and other outputs are not published.

Captured 2026-11-03 from commit 6f4f6b98 , label fixture (recorded against a pinned fixture). Case recipes/slack-approval-buttons .

Ask a question... ⌘I