Connecting a Jetty Runbook to Slack
This guide shows how to let a Jetty runbook post messages to a Slack channel and, when it needs to, read the messages already there. The worked example is a runbook that writes a short haiku, posts it to a channel as a bot, and confirms that Slack stored it by fetching the message's permalink. You can reuse the same setup for any runbook that posts a digest, a report, an alert on a failing check, or anything else your team should see in Slack.
Time needed: about 10 minutes, one time.
Prerequisites:
- A Slack workspace where you can create and install apps, or an admin who approves them
- A channel for the runbook to post to, where you can invite the bot
- A Jetty collection where you can set environment variables and create tasks
How it works
Runbooks run in an isolated sandbox with no Slack login. You give the runbook one credential:
| Variable | Description |
|---|---|
SLACK_BOT_TOKEN | The bot token of a small Slack app you create for your runbooks (it starts with xoxb-), allowed to post messages and nothing else |
During each run, the runbook sends the token as a Bearer header to the Slack Web API: auth.test to check it, chat.postMessage to post, and chat.getPermalink to confirm the message exists. SLACK_BOT_TOKEN is the name Jetty's integrations page uses for a Slack bot token.
Scope. The app gets one bot scope, chat:write. It can post in channels the bot has been invited to, reply in threads and edit its own messages, and nothing else: no reading messages, no listing channels. auth.test and chat.getPermalink need no scope at all. A runbook that reads a channel also needs channels:history (see Reading from Slack); scopes can be added later without changing the token.
Every reply is HTTP 200. Slack reports failures in the body, as "ok": false with an error name, so the runbook checks ok after every call rather than the status code.
Note: Why not an incoming webhook, or a user token? An incoming webhook is simpler, but it posts to the one channel chosen when it was created and answers with a plain
ok: no message timestamp, so a runbook can't confirm the post, reply in its thread or edit it later. A user token (xoxp-) posts as a person and carries everything that person can see. A bot token withchat:writeis the smallest credential that can post, confirm and follow up.
Set up Slack
Steps 1 to 3 are done once, in your browser and in Slack.
Step 1: Create a Slack app
The quickest way is from a manifest, which sets the app's name, its bot user and its scope in one paste.
- Open api.slack.com/apps and click Create New App > From a manifest.
- Pick the workspace the runbook will post to and click Next.
- Switch the editor to YAML, replace its contents with the manifest below, then click Next > Create.
display_information:
name: Jetty Runbook Example
description: Posts messages from Jetty runbooks
features:
bot_user:
display_name: Jetty Runbook Example
always_online: false
oauth_config:
scopes:
bot:
- chat:write
settings:
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: falseThe two names are what your team sees next to the messages; change them as you like. The app has no commands or event subscriptions, so there is nothing to host.
Warning: Leave
token_rotation_enabledatfalse. With token rotation on, the bot token expires every 12 hours and a runbook has no way to refresh it, so scheduled runs fail within a day. Slack doesn't let you turn rotation off again once it's on.
Step 2: Install the app and copy the token
- Open Install App in the app's sidebar and click Install to Workspace, then Allow. If your workspace requires approval for new apps, an admin approves it first.
- Copy the Bot User OAuth Token that appears. It starts with
xoxb-.
Step 3: Invite the bot to the channel
In the channel the runbook will post to, send /invite @Jetty Runbook Example (the app's name from Step 1). Slack confirms that the app was added. A bot with only chat:write can post nowhere else, so this is how you decide, channel by channel, where it may write.
Configure Jetty
Step 4: Store the token in your Jetty collection
Add SLACK_BOT_TOKEN as an environment variable on your Jetty collection. Any of these three ways works.
Web app. Open Settings > Integrations and add SLACK_BOT_TOKEN.
Coding agent. If you work through the Jetty plugin for Claude Code or the Jetty MCP server in Cursor, VS Code, Windsurf or Zed, save the token in a file of its own, for example ~/slack-token.txt, so it never goes through your chat input or shell history, and ask the agent to store it. It calls the set-environment-vars tool for you:
Read the token in ~/slack-token.txt and store it as SLACK_BOT_TOKEN on my <your-collection> collection.The tool keeps your other variables and only adds or replaces this key. Two things to know: the agent reads the file and sends the value in its tool call, so the token still lands in that session's transcript, and the tool's reply echoes the collection's variables back to the agent, values included. Use it from a session you'd trust with every key in that collection, and delete the file afterwards.
API. Send a PATCH to the collection's environment endpoint. The body must be wrapped in environment_variables; the call merges this key into your existing variables.
curl -X PATCH "https://flows-api.jetty.io/api/v1/collections/<your-collection>/environment" \
-H "Authorization: Bearer $JETTY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"environment_variables": {"SLACK_BOT_TOKEN": "xoxb-..."}}'Step 5: Declare the secret in your runbook
Important: Collection variables aren't automatically visible to a runbook. A runbook only receives the environment variables listed in the
secrets:block of its frontmatter. If you skip this, the sandbox sees noSLACK_BOT_TOKENand the run stops at preflight.
---
name: slack-post
version: 1.0.0
description: Write an original haiku and post it to a Slack channel through the Slack Web API, then confirm the message exists with its permalink
secrets:
SLACK_BOT_TOKEN: { env: SLACK_BOT_TOKEN }
---env: names the environment variable the run delivers the value in. If you work through the Jetty plugin or MCP server, its check-secrets preflight reads this block and tells you which declared secrets your collection has set.
Step 6: Find the channel ID
Click the channel's name at the top of the channel: the ID is at the bottom of the About tab, next to a copy button, and starts with C (or G for some private channels). In Slack in a browser, it's also the last part of the URL, https://app.slack.com/client/TXXXXXXX/CXXXXXXX.
Pass the ID to the runbook as a variable rather than a channel name: an ID survives the channel being renamed, and a schedule can pick the channel at run time.
Create and test the runbook
Step 7: Create the task
The complete example runbook follows. Its Steps 1, 3, 4 and 5 (preflight, post, verify and summary) are generic; Step 2 writes the message.
---
name: slack-post
version: 1.0.0
description: Write an original haiku and post it to a Slack channel through the Slack Web API, then confirm the message exists with its permalink
secrets:
SLACK_BOT_TOKEN: { env: SLACK_BOT_TOKEN }
---
# Slack Post
You write one original haiku and post it to a Slack channel as the bot whose token is in the
environment, then confirm that Slack stored the message by fetching its permalink.
## Inputs
- Channel ID (starts with `C` for a channel; the bot must be a member): `{{channel}}`
- Theme (optional, may be empty): `{{theme}}`
The token is already in the environment as `SLACK_BOT_TOKEN`. Never print, echo, or write its value
to any file or log, never put it in a URL, and never run `env` or `printenv` without filtering it out.
Run with the inputs exactly as given, even if one looks wrong. Do not ask anyone anything: if an input is
wrong, Slack says so, and Step 4 records the error as a `FAIL`. Always finish with `/app/results/summary.md`.
## Step 1 — Preflight
```bash
mkdir -p /app/results
[ -n "${SLACK_BOT_TOKEN:-}" ] || echo "MISSING SLACK_BOT_TOKEN"
curl --version | head -1; python3 --version
```
If the token is missing, write `/app/results/summary.md` saying so, and stop.
## Step 2 — Write the haiku
Compose one original haiku (three lines, 5-7-5 syllables). If a theme was given, use it;
otherwise pick a seasonal image fitting today's date. Count the syllables of every line
and rewrite until each line is exactly 5, 7, 5. Save it to `/app/results/haiku.txt`
as exactly three lines followed by a trailing newline, with no title or other text.
## Step 3 — Post the message
Run this exactly, as one script. Slack answers every Web API call with HTTP 200, success or not:
the JSON field `ok` says which, and `error` names the problem, so the script checks `ok` itself.
```bash
set -euo pipefail
cd /app/results
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" https://slack.com/api/auth.test > auth.json
python3 -c 'import json; d=json.load(open("auth.json")); assert d.get("ok"), d.get("error"); print("token ok:", d["team"], d["user"])'
python3 - <<'PY' > message.json
import json
haiku = open("/app/results/haiku.txt").read().strip()
print(json.dumps({"channel": "{{channel}}", "text": haiku + "\n_Posted by the slack-post Jetty runbook._",
"unfurl_links": False}))
PY
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" -H "Content-Type: application/json; charset=utf-8" \
--data-binary @message.json https://slack.com/api/chat.postMessage > post.json
python3 -c 'import json; d=json.load(open("post.json")); assert d.get("ok"), d.get("error"); print("posted", d["channel"], d["ts"])'
TS=$(python3 -c 'import json; print(json.load(open("post.json"))["ts"])')
curl -sS -G -H "Authorization: Bearer $SLACK_BOT_TOKEN" --data-urlencode "channel={{channel}}" \
--data-urlencode "message_ts=$TS" https://slack.com/api/chat.getPermalink > permalink.json
python3 -c 'import json; d=json.load(open("permalink.json")); assert d.get("ok"), d.get("error"); print(d["permalink"])'
```
## Step 4 — Verify
`post.json` must have `"ok": true`, a `ts`, and a `channel` equal to `{{channel}}`; `permalink.json` must
have `"ok": true` and a `permalink`. If a call returned `"ok": false`, its `error` names the cause
(for example `invalid_auth`, `not_authed`, `channel_not_found`, `not_in_channel`, `missing_scope` or
`ratelimited`). On `ratelimited`, wait the number of seconds Slack asks for and run Step 3 once more.
Otherwise do not retry: record the call, the `error` value and any `needed` scope (never the token) in
`/app/results/summary.md` and stop.
## Step 5 — Summary
Write `/app/results/summary.md` with:
- the haiku
- the workspace and bot user from `auth.json`, the channel, the message `ts` and the permalink
- the result: `PASS` if Step 4 verified, otherwise `FAIL` with the call and the `error` valueTo reuse this for your own runbook, keep Steps 1, 3, 4 and 5 as they are, replace Step 2 with your own work that writes the message to a file, and point the haiku = open(...) line in Step 3 at that file. Keep the text under 4,000 characters (Slack truncates past 40,000) and link to anything longer. For a richer layout, add a blocks array next to text; text then becomes the fallback shown in notifications.
Task configuration. Create the task with default values for the variables, so a run with no inputs still knows where to post:
"init_params": {
"instruction": "<contents of RUNBOOK.md>",
"vars": { "channel": "<your channel id>", "theme": "" }
}Use the python312-uv sandbox snapshot or similar; the runbook only needs curl and python3. It finishes in under a minute, so a timeout_sec of 900 is plenty.
Step 8: Run a test
Start a run from the Jetty web app, or from the API:
curl -X POST "https://flows-api.jetty.io/api/v1/run/<your-collection>/slack-post" \
-H "Authorization: Bearer $JETTY_API_TOKEN" \
-F 'init_params={"vars":{"channel":"<your channel id>","theme":"autumn"}}'When the run finishes, its results include post.json with the message's ts, permalink.json with its link, and summary.md showing PASS. In the channel, the message appears under the app's name, for example:
Jetty Runbook Example APP
Maple leaves let go
Drifting down past cooling eaves
Fall fills the still yard
Posted by the slack-post Jetty runbook.To see a failure, pass a channel ID the bot can't use, such as C00000000: the run records FAIL with chat.postMessage and channel_not_found, and nothing is posted.
Reading from Slack
A runbook can also read a channel, for example to summarise yesterday's messages or pick up a request someone posted there. This needs one more scope: open the app's OAuth & Permissions > Scopes > Bot Token Scopes, add channels:history (groups:history for a private channel) and reinstall the app when Slack asks. The token keeps its value, so nothing changes in Jetty. The bot reads only channels it has been invited to.
Fetch the most recent messages, newest first:
curl -sS -G -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.history" \
--data-urlencode "channel={{channel}}" \
--data-urlencode "limit=200" > history.jsonThe reply is a messages list, each with its ts and text.
- More pages. When
has_moreistrue, sendresponse_metadata.next_cursorback ascursor. - One message. To fetch a message whose
tsyou know, sendoldest=<ts>,inclusive=trueandlimit=1. - Threads. History doesn't include replies inside threads. A parent message carries a
reply_count;conversations.replieswithchannelandts=<parent ts>returns the thread, under the same scope.
As with posting, give the agent these exact curl commands rather than "read the channel": left to improvise, a model tends to reach for a Slack SDK the sandbox doesn't have.
Running on a schedule
The runbook works unchanged on a Jetty schedule:
- Inputs. Scheduled runs pass
vars(channel,theme) into the runbook's{{…}}variables. If a schedule doesn't set them, the task's defaultvarsapply. - Token lifetime. A bot token without rotation doesn't expire. It stops working only when someone revokes it or removes the app.
- Concurrency. The token doesn't rotate when used, so overlapping runs are fine.
chat.postMessageallows about one message per second per channel; past that, Slack answers HTTP429withratelimitedand aRetry-Afterheader. - One message per run. A daily schedule posts a daily message. To keep the channel quiet, have each run reply in one thread (
"thread_ts": "<parent ts>"in the JSON body) or update one living message withchat.updateand itsts. Both work withchat:writealone.
Before relying on a schedule, check two things:
- Rotation is off.
token_rotation_enabled: falsein the app's manifest (Step 1). - Watch the result, not just the run status. A run can show as completed even when the runbook recorded a
FAILinsummary.md, for example because Slack answeredchannel_not_found. For unattended schedules, checksummary.md, or have a later step alert you when it saysFAIL.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
summary.md says MISSING SLACK_BOT_TOKEN | The variable isn't listed in the runbook's secrets: frontmatter, or isn't set on the collection | Add the secrets: block (Step 5) and check the collection environment (Step 4) |
not_authed | No token reached Slack: the Authorization header is missing or the variable is empty | Same as above; the token goes in an Authorization: Bearer header |
invalid_auth | The token is wrong, or was sent as a ?token= query parameter, which Slack rejects | Copy the token again (Step 2) and update it (Step 4); send it as a header |
token_revoked or account_inactive | The token was revoked, or the app was removed from the workspace | Reinstall the app (Step 2) and store the new token (Step 4) |
channel_not_found | The ID is wrong, or the bot can't see the channel. Posting by name to a channel the bot isn't in, such as #general, fails this way too | Copy the ID again (Step 6) and invite the bot (Step 3) |
not_in_channel | The bot hasn't been invited to the channel | Invite it (Step 3), or add the chat:write.public scope to let it post in any public channel |
missing_scope, with needed and provided fields | The call needs a scope the token doesn't have; needed lists the ones that would do | Add one of them under OAuth & Permissions > Scopes and reinstall the app |
is_archived | The channel was archived | Unarchive it, or point channel at another channel |
HTTP 429, ratelimited | More than about one message per second to one channel | Wait the seconds in Retry-After and post again; the example runbook does this once |
no_text | The message body had no text and no blocks | Check that the file Step 2 of the runbook wrote isn't empty |
Security checklist
- Keep the app to
chat:write. Each extra scope widens what a leaked token can do:chat:write.publiclets it post in every public channel, and the history scopes let it read every channel the bot is in. - Invite the bot only into the channels it needs; removing it from a channel takes that channel away.
- Keep the token only in the Jetty collection environment. Never put it in the runbook text, the task's
varsor a URL. - The runbook tells the agent never to print the token; keep that line if you adapt it.
- Messages appear under the app's name, visible to everyone in the channel. Don't have a runbook post secrets, customer data or raw logs there.
- To cut off access immediately, call auth.revoke with the token, or remove the app in Slack: click your workspace name, then Tools & settings > Manage apps > Installed Apps, select the app, App Details > Configuration > Remove App. A run in progress fails at its next Slack call.