Documentation menu

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:

VariableDescription
SLACK_BOT_TOKENThe 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 with chat:write is 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.

  1. Open api.slack.com/apps and click Create New App > From a manifest.
  2. Pick the workspace the runbook will post to and click Next.
  3. Switch the editor to YAML, replace its contents with the manifest below, then click Next > Create.
App manifest
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: false

The 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_enabled at false. 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

  1. 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.
  2. 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:

Prompt
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.

Shell
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 no SLACK_BOT_TOKEN and the run stops at preflight.

RUNBOOK.md frontmatter
---
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.

RUNBOOK.md
---
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` value

To 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:

JSON
"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:

Shell
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:

In the channel
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:

Shell
curl -sS -G -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.history" \
  --data-urlencode "channel={{channel}}" \
  --data-urlencode "limit=200" > history.json

The reply is a messages list, each with its ts and text.

  • More pages. When has_more is true, send response_metadata.next_cursor back as cursor.
  • One message. To fetch a message whose ts you know, send oldest=<ts>, inclusive=true and limit=1.
  • Threads. History doesn't include replies inside threads. A parent message carries a reply_count; conversations.replies with channel and ts=<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 default vars apply.
  • 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.postMessage allows about one message per second per channel; past that, Slack answers HTTP 429 with ratelimited and a Retry-After header.
  • 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 with chat.update and its ts. Both work with chat:write alone.

Before relying on a schedule, check two things:

  1. Rotation is off. token_rotation_enabled: false in the app's manifest (Step 1).
  2. Watch the result, not just the run status. A run can show as completed even when the runbook recorded a FAIL in summary.md, for example because Slack answered channel_not_found. For unattended schedules, check summary.md, or have a later step alert you when it says FAIL.

Troubleshooting

SymptomCauseFix
summary.md says MISSING SLACK_BOT_TOKENThe variable isn't listed in the runbook's secrets: frontmatter, or isn't set on the collectionAdd the secrets: block (Step 5) and check the collection environment (Step 4)
not_authedNo token reached Slack: the Authorization header is missing or the variable is emptySame as above; the token goes in an Authorization: Bearer header
invalid_authThe token is wrong, or was sent as a ?token= query parameter, which Slack rejectsCopy the token again (Step 2) and update it (Step 4); send it as a header
token_revoked or account_inactiveThe token was revoked, or the app was removed from the workspaceReinstall the app (Step 2) and store the new token (Step 4)
channel_not_foundThe 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 tooCopy the ID again (Step 6) and invite the bot (Step 3)
not_in_channelThe bot hasn't been invited to the channelInvite it (Step 3), or add the chat:write.public scope to let it post in any public channel
missing_scope, with needed and provided fieldsThe call needs a scope the token doesn't have; needed lists the ones that would doAdd one of them under OAuth & Permissions > Scopes and reinstall the app
is_archivedThe channel was archivedUnarchive it, or point channel at another channel
HTTP 429, ratelimitedMore than about one message per second to one channelWait the seconds in Retry-After and post again; the example runbook does this once
no_textThe message body had no text and no blocksCheck 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.public lets 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 vars or 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.