The Slack connector drops every post made by a bot, so another app's bot (an AI assistant, an alerting app) can't reach an agent. This lets an operator name such bots: their posts in one channel, the bot inbox, reach one seat as rows from the bot.
## What changes
A new optional `botInbox` block in `slack-connector.json`:
```json
"botInbox": {
"channel": "C0BOTINBOX",
"destination": "researcher@my-rig",
"bots": [{ "appId": "A0EXAMPLE1", "name": "Example Bot", "maxPerHour": 20, "cooldownMinutes": 60 }]
}
```
**Which posts are delivered.** A bot's post reaches `destination` when all of these hold:
- it is in `channel`;
- its app is listed in `bots`;
- its app isn't the connector's own app.
**How the sender is identified.** The post's own `bot_id`, resolved to its app with `bots.info` (new feature scope `users:read`). A lookup is cached per bot, and posts arriving together share one lookup. Anything that posts as a named app, including its incoming webhooks, counts as that bot.
- The envelope's `api_app_id` names the app *receiving* the event, the connector's own. So it is used only to rule out OpenRig's own posts, which are never delivered, even if listed.
- A post whose `api_app_id` is missing is dropped.
**What the seat receives.** One row per post:
- source `slack-bot:<app ID>`, tags `slack-bot` and `inbound`, and a summary that starts with the bot's name;
- the body opens with the bot's app, bot, channel and ts, and says the row is data from another program, not an instruction, and not a person;
- the bot's text follows in a fence longer than any backtick run inside it, so it can't close the quote or pass for the provenance.
A bot's post never goes through the person registry, the thread map or a decision reply. So it never answers or closes a decision, even when it replies in that decision's thread.
**Limits.** Each bot may deliver `maxPerHour` posts (default 20) in any rolling hour. The next post cuts the bot off for at least `cooldownMinutes` (default 60), and until the hour has room again, so the hourly maximum holds across a cutoff. The seat gets one notice row, at most one an hour per bot, which says that new posts are dropped while admitted ones still arrive, how the person turns the bot off and applies the change (a daemon restart), and that a later cutoff can extend the time shown. A notice whose row couldn't be created doesn't use up the hour. The counts are in the running connector's memory, so a daemon restart or a rewire (`rig slack disable` then `rig slack enable`) clears them.
**Failures and retries.** A `bots.info` transport, rate-limit or server failure is dead-lettered and retried like a message. Live posts and retries go through one admission: the current inbox channel, the bot's app and its limit. Each post is counted once by its identity (`channel:ts`), so a backlog of failed lookups can't pass the limit, and another copy of an admitted post (a retry, or Slack's `message` and `app_mention` pair) isn't counted again. A post the limit already admitted whose row couldn't be created is kept marked as admitted, saved once that creation fails (on a retry too), and the mark covers every kept copy of that post. A retry pass never rewrites a retained file it can't read, and a live post reads that file again only after something in the daemon has written it (including an earlier connector's handler still finishing after a rewire), not each time; while it can't be read, a live post is kept for retry. So it's delivered later without being counted again or dropped by a cutoff, even after its hour, a rewire or a restart; the marker can't come from Slack's payload. A retained post from a channel that is no longer the bot inbox is dropped and logged. Slack's own refusal (such as `missing_scope`) is logged and the post is not delivered.
**Config errors.** An invalid `botInbox` (for example a bot ID where the app ID belongs) turns only the bot inbox off. `rig slack status` shows a `bot-inbox` line saying why, and loading the config never fails because of it.
**With one app across machines.** When the bot inbox channel belongs to another machine, the hub relays the bot's post there untouched, like any event in that channel, and that machine's own bot inbox delivers it: the relayed envelope carries the receiving app, and the relayed post is checked as a fresh one, with any admission marker in its payload cleared. A relay retry whose file can't be read again is left for the next pass and logged, as the event and click retries are.
## Result changes against main
- **A named bot's post in the bot inbox:** main drops it (`bot_id`); now it lands on `destination`.
- **The app manifest** asks for one more feature scope, `users:read`.
- **Readiness:** `rig slack status` gains a `bot-inbox` line when a `botInbox` block is set.
- **Unchanged:**
- without `botInbox`, every bot post is dropped exactly as before;
- other bots, and named bots outside the inbox channel, are dropped as before;
- people's messages, including people writing in the bot inbox channel, take the existing path.
## Limits
- **Edits and deletions are not delivered.** A bot that writes its answer by editing one message delivers only that message's first version.
- **Attachments on a bot's post** are named in the row, not copied.
- **`rig slack verify`** checks neither `users:read` nor membership in the bot inbox channel.
- **Missed-message recovery** doesn't cover the bot inbox.
- **No reply route yet:** the seat can't answer the bot; the person asks it in Slack.
## Tests
`packages/daemon/test/slack-named-bots.test.ts`, committed before the change. CI is linked in the PR conversation.
- **Through `handleEnvelope` to the queue row:**
- a named bot delivered as the bot;
- a bot reply in a decision's thread doesn't call the decision resolver, while a person's same reply does;
- an unnamed bot dropped and its app ID logged;
- OpenRig's own app dropped even when listed;
- a missing receiving app dropped;
- `bot_profile` and the envelope's app ignored as identity;
- named bots outside the inbox dropped without a lookup;
- no `botInbox` means every bot is dropped;
- edits dropped;
- a `message` and `app_mention` pair landing once, with one lookup;
- a refused lookup not retried;
- a transport failure dead-lettered and landed on retry;
- the cut-off, its single notice and the return after the cooldown;
- an admitted post kept past its hour through a cutoff, and across a rewire with the new hour already used, then delivered;
- an admission granted on a retry that stops before the pass ends, and an unadmitted plus an admitted copy of one post across a rewire;
- an unreadable final re-read after the marked copy is saved, a live post while the retained file can't be read, and one file read for several live posts. These run on an in-memory file adapter, not a daemon kill or a real disk failure;
- a notice that couldn't be created leaving the hour's notice free;
- a person's message in the inbox channel staying on the person path.
- **Also:** the rolling hour, the config checks and readiness row, loading an invalid block, the `users:read` scope, and the `bots.info` request and its retry classification.
## Also changed against the shared-app relay
- A relay retry pass whose final re-read of its retained file fails now leaves that file untouched, through the same shared batch replacement the bot and event passes use, rather than replacing it from a lenient read. Envelopes already sent may be retried and rely on the receiver's dedupe: a possible duplicate, not a loss.
- The named-bot and relay suites are separate; no single test composes a named bot through a two-machine relay, and real Slack across two machines was not run.
23 KiB
Create your own Slack app for OpenRig (experimental)
Experimental in 0.6.0. The manifest (
rig slack manifest) and this setup guide are new and have not been confirmed against a real app creation. The Slack connector itself and itssetup,verify,enable,disableandstatuscommands are not experimental. If a step here does not match what Slack shows you, please open an issue or send a pull request (CONTRIBUTING.md).
OpenRig's Slack connector talks to a Slack app that you create in your own workspace. OpenRig ships the app's manifest; it does not host an app, run an install endpoint, or publish anything to the Slack Marketplace. The app is a Socket Mode app, so it lives in the one workspace you create it in.
rig slack manifest prints the manifest. It works offline, before any daemon or token exists:
rig slack manifest # the manifest as YAML
rig slack manifest --url # Slack's create-app link with the manifest prefilled
rig slack manifest --json # the manifest, its scopes and events, and why each scope is requested
The TUI shows the same link on the Connections page while Slack is not configured. Neither the CLI nor the TUI opens a browser, accepts tokens, or creates an app.
Steps
These are the expected steps, based on Slack's app-manifest documentation. They have not yet been confirmed against a real creation run. Slack's form may ask for something the prefill did not fill in; if so, follow the form.
-
Open the link from
rig slack manifest --urlin a browser.Note on Slack's manifest dialog: If opening the link results in an empty manifest
{}or Slack reports an error translating the manifest, copy the output ofrig slack manifest(run without flags) and paste it directly into the YAML tab in Slack's "Create an app from an app manifest" dialog. -
Sign in to Slack if asked, pick the workspace, review the prefilled manifest, and click Next, then Create. (If Create stays disabled, go Back and check that a development workspace is selected and the manifest step shows no error).
-
Under Socket Mode, confirm it is enabled. Enable it if it is not.
-
Under Basic Information → App-Level Tokens, generate a token with the
connections:writescope and copy it (it starts withxapp-). -
Install the app to the workspace and approve the requested scopes.
-
Under OAuth & Permissions, copy the Bot User OAuth Token (it starts with
xoxb-). -
Put both tokens in a private env file readable only by you (
chmod 600):SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... -
Invite the bot to the channel you will use (
/invite @OpenRigin that channel).rig slack verifyreports NOT ready until the app is a member. -
Register yourself as the human the connector delivers to, with a Slack binding that names your Slack user ID (
rig gateway human add; its--helpshows the binding format). Messages from Slack users who are not registered are not delivered.rig slack enableneeds a readable human registry: on a fresh install with no registry, or with an invalid one, it is refused. -
Run
rig slack setup --channel <channel-id> --secrets-env-file <path>, thenrig slack verify, thenrig slack enable.
What the app asks for
Run rig slack manifest --json for the exact list and the reason for each scope. There are two
groups:
- Baseline scopes: posting messages, reading message history in public channels the app is
a member of, and reading channel details.
rig slack verifychecks these. - Feature scopes:
files:read(download attachments people send),files:write(upload attachments to Slack),app_mentions:read(receive @-mentions of the app),reactions:read(receive emoji reactions), andusers:read(identify a named bot, below).rig slack verifywarns when one of these is missing (if Slack returns the granted scopes) but does not require them, so a READY from verify does not prove attachments, mentions, reactions or named bots will work.
If a feature scope was not granted, the effect differs by feature:
- Attachments (
files:read,files:write): the file download or upload call fails. A message with an attachment that could not be downloaded is still delivered, with the failed file named in it. A post whose attachment could not be uploaded still delivers its text, and the failure appears only in the daemon log (rig daemon logs). - Mentions (
app_mentions:read): Slack does not deliverapp_mentionevents to the app. Onlyrig slack verifywarns that the scope is missing; nothing reports the missing events. - Reactions (
reactions:read): Slack does not deliverreaction_addedevents, so a reaction on an ask doesn't reach the asking agent. A reply in the ask's thread still does. - Named bots (
users:read): Slack refuses the bot lookup, so a named bot's posts are not delivered, and the daemon log names the refusal. Nothing else changes.
So after installing, compare the granted scopes Slack shows for the app with all eight scopes that
rig slack manifest --json lists.
The app subscribes to messages in public channels it is a member of (message.channels), to
mentions of the app (app_mention), and to emoji reactions (reaction_added). It does not request
direct-message or private-channel access.
A reaction added to an ask reaches the agent that asked, as a note naming who reacted and with
which emoji; the agent decides what it means. Every message OpenRig posted for the ask counts:
its first message, a long ask's numbered parts, and an ask posted as a reply in a thread. A
reaction on any other message, including a person's reply in the thread, is ignored. An app created from an older manifest has neither the reactions:read scope nor the
reaction_added event: add both under OAuth & Permissions and Event Subscriptions, then
reinstall the app to the workspace.
The manifest also turns on Interactivity, so the human can answer a decision's structured
questions by clicking a button (rig queue create --human-questions-file). In Socket Mode the
clicks arrive over the same socket, so no request URL is needed. An app created from an older
manifest has Interactivity off: turn it on under Interactivity & Shortcuts, or the buttons
will do nothing. A typed reply in the thread still answers the decision either way.
Each click gets one short reply in the decision's thread: what was recorded, or, when OpenRig
refuses the click or can't record it, why and what to do instead (the sender isn't registered, the
decision is no longer waiting for answers or was sent to someone else, the button no longer
matches its questions, or the message can't be matched to a decision). Slack's redelivery of a
click OpenRig already handled gets no second reply. The reply is best effort: while Slack is
rate-limiting OpenRig's posts it may not be posted, and the click is handled the same either way.
In the decision's humanAnswers, clicked answers remain option-id strings. A whole typed reply
is stored as {kind: "typed-reply", text: "…", placement: "first-unanswered", unansweredCount: N}
under the first unanswered question. That placement is automatic; it does not mean the person
selected that question. N counts the other question slots still empty in humanAnswers after
that placement. Earlier button answers are preserved; no other questions are filled in.
Read text as the person's words,
even when it equals an option id. The reply still closes the decision, so done is not approval.
A file-only reply closes the decision without recording a typed answer. If all button answers were
already recorded, they stay final and the typed reply remains in the correlated reply row.
What the connector does with the tokens
The tokens stay in the env file you created. The connector reads them from that file and uses them to authenticate to Slack: it opens an outbound Socket Mode connection with the app-level token and calls Slack's Web API with the bot token. This connector has no OpenRig-hosted component and makes outbound connections only. That statement is about this Slack connector, not about every part of OpenRig.
Next
rig slack statusshows what is still missing, without contacting Slack.rig slack verifychecks the granted baseline scopes and channel membership with Slack.
One channel per rig or seat (optional)
By default every human-bound item posts to the one channel from rig slack setup --channel.
To follow each rig separately, post a rig's items, or a single seat's, to its own channel:
rig slack channel-map set my-rig C0EXAMPLE1
rig slack channel-map set pr@my-rig C0EXAMPLE2
rig slack channel-map list
rig slack channel-map remove pr@my-rig
- Which channel an item posts to. The item's seat is matched exactly first (
pr@my-rig), then its rig (my-rig). Anything unmapped posts to the default channel, so a config without a map behaves exactly as before. Aggregate delivery digests have no single seat and always post to the default channel. - Threads. Updates,
--reply-toand later notifications for an item stay in its thread. With a map configured, if a seat's channel has changed since the thread was opened, the next post opens a new thread in the seat's current channel, and--reply-torecords the fallback asroot-other-channel. With no map, threads behave exactly as before: an item's next notification reuses its open thread in whatever channel that thread was posted in. - Changing the map. A change applies to new posts. A post already in flight (one being retried after a failure or an unconfirmed send) finishes in the channel and thread it started in, and its retry checks that channel first so it is not posted twice. This includes adding the first map: a post first tried before any map existed finishes in the default channel. Without a map, OpenRig records nothing about a post's channel, so if the default channel changes while a post is being retried, the retry goes to the new default channel (as it always has); that also applies to a post first tried before the map was added.
- Rate limits on posts. When Slack rate-limits a post and asks for a pause of 10 seconds or less, OpenRig waits that long and retries the post once. A longer pause, or a second refusal, is left to the retry of retained posts.
- Replies and new messages. A reply in an item's thread reaches that item's seat, in any
channel. To answer it in the same thread, the seat sends
rig queue create --human-intent update --summary "<subject>" --reply-to <the inbound reply's row>.- A new message a person starts in a rig's channel goes to that rig's representative, the
seat the rest of its rig reports to (its one
delegates_toroot). To send it to another seat, name it:rig slack channel-map set my-rig C0EXAMPLE1 --inbound lead@my-rig. A rig entry on the connector's default channel only sends that rig's posts there: new messages in the default channel still go to the inbound destination, unless the entry names--inbound. - Every other new message, in the default channel or a seat's channel, goes to the connector's inbound destination, as does a reply in a thread OpenRig doesn't know, or a rig's channel whose representative can't be found.
- Answers come back to where the person asked. A person's new message is the root of its
answer: when the seat it reached replies with
--reply-tothat message's row, the answer and later updates land in the person's thread, in the channel they used. - Missed-message recovery (below) covers the default channel only.
- A new message a person starts in a rig's channel goes to that rig's representative, the
seat the rest of its rig reports to (its one
Invite the app to every mapped channel (/invite @OpenRig). rig slack verify checks
membership in the default channel and every mapped channel, and reports NOT ready until the
app is in all of them. A saved change reaches a running connector when it next rewires, which a
daemon restart does (rig daemon stop, then rig daemon start). rig slack disable --reason "<why>" then rig slack enable rewires too, but alerts still waiting to be posted when it's
re-enabled are treated as already seen and aren't posted. Only an earlier post that failed or
didn't finish still posts: an alert whose row is still open, a decision-resolved notice, or a digest. The TUI's Connections page says whether the running
configuration matches the file.
The map is stored as channelMap in slack-connector.json, as a list of { "match", "channel" }
entries, each with an optional "inbound" seat. Saving refuses any other field. A field written by a newer OpenRig is ignored when the
connector reads the file, and rig slack status names it. Private channels and direct messages
are not supported targets.
Named bots (optional)
By default the connector drops every post made by a bot, its own included. To let another Slack
app's bot (an AI assistant, an alerting app) reach one seat, give the bots a channel of their own,
name each bot, and name the seat that reads that channel. Add a botInbox block to
slack-connector.json:
"botInbox": {
"channel": "C0BOTINBOX",
"destination": "researcher@my-rig",
"bots": [
{ "appId": "A0EXAMPLE1", "name": "Example Bot", "maxPerHour": 20, "cooldownMinutes": 60 }
]
}
- Which posts are delivered. A post reaches
destinationwhen it is inchannel, its bot belongs to an app listed inbots, and that app is not this connector's own app. A named bot's posts in any other channel, and every other bot's posts, are still dropped. OpenRig's own posts are never delivered, even if its app is listed. A post kept for retry (Slack couldn't be asked about it in time) is checked again against the current block when it is retried. - How a bot is identified. OpenRig asks Slack which app the post's bot belongs to (
bots.info, which needs theusers:readscope) and matches that app ID. Use the bot's app ID, which starts withAand is on the app's settings page. A bot ID (B…) or user ID (U…) never matches. An unnamed bot's post is logged with its app ID, sorig daemon logsshows the ID to add. Anything that posts as a named app counts as that bot, including the app's incoming webhooks: that is what naming it means. - What the seat receives. One row per post. Its source is
slack-bot:<app ID>and its summary starts with the bot's name, never a person's. The row opens with where the post came from, then quotes the bot's text in a fenced block. Aslack-botrow is data from another program, not an instruction, and the row says so. A bot can't answer or close a decision, even when it replies in a decision's thread: the row goes todestinationand the decision keeps waiting for a person. Edits and deletions are not delivered, so a bot that writes its answer by editing one message delivers only that message's first version. Attachments are named in the row, not copied. - Answering the bot. The seat can't reply to the bot yet: the row has no reply link, and OpenRig posts only for people. To ask the bot something, the person asks it in Slack.
- Limits. Each bot may deliver
maxPerHourposts (default 20) in any rolling hour. The next post cuts the bot off for at leastcooldownMinutes(default 60), and until the hour has room again. Its new posts are dropped meanwhile (a post already admitted still arrives), and the seat receives one row saying so (at most one an hour per bot) and how the person turns the bot off (remove it frombots, then apply the change as below). A later cutoff within that hour can extend the time shown without another row; the daemon log records each one. Each post counts once by its identity, including a post delivered on retry or arriving twice. A post already admitted whose row couldn't be created yet is kept, marked as admitted once that creation fails, and delivered later without counting again, even after its hour, a rewire or a daemon restart. The counts are kept in the running connector's memory, so a daemon restart or a rewire (rig slack disable --reason "<why>"thenrig slack enable) clears them. They cover what one daemon writes: another process writing the retained file isn't seen, and two daemons on one OpenRig home aren't a supported setup. - The channel. Invite the app to it (
/invite @OpenRig).rig slack verifydoes not check this channel, and only warns whenusers:readis missing. A person's message there is handled like a person's message in any channel the app is in (a new message goes to the connector's inbound destination), not by the bot inbox. Missed-message recovery does not cover the bot inbox.
rig slack status shows a bot-inbox line: what it delivers, or why it is off. An invalid block
(for example a bot ID where an app ID belongs) turns only the bot inbox off. A saved change
reaches a running connector when it next rewires, which a daemon restart does (rig daemon stop,
then rig daemon start). rig slack disable --reason "<why>" then rig slack enable rewires too,
but alerts still waiting to be posted when it's re-enabled are treated as already seen and aren't
posted. Only an earlier post that failed or didn't finish still posts: an alert whose row is still
open, a decision-resolved notice, or a digest.
One app across machines (optional)
Slack delivers each event of an app to one of its connections, so one app can serve several OpenRig machines only if one machine, the hub, holds the connection and relays each other machine's channels to it. Each machine still decides who its messages reach, keeps its own threads and decisions, and posts its own replies.
-
On the hub: the bot token and the app-level token, as usual, plus
forwardChannelsinslack-connector.json, one entry per channel another machine owns:"forwardChannels": [ { "channel": "C0EXAMPLE3", "host": "vps04" }, { "channel": "C0EXAMPLE4", "host": "vps04" } ]hostis that machine's id in the hub's host registry (rig host list). It needs an http entry, whichrig host pairwrites with that machine's token; the hub sends it with each relay, and the machine checks it when it hasOPENRIG_AUTH_BEARER_TOKENset. -
What the receiving machine trusts: a relayed event comes from whoever holds that machine's daemon token (or, with no token set, anything that can reach its daemon), not from Slack directly, and nothing checks Slack's own signature on it. The machine still looks up the sender in its own list of people, but on a relayed event that's no proof a person just typed or clicked it in Slack: it trusts the caller for where the event came from.
-
On every other machine: the same app's bot token only, with no app-level token, so it opens no connection of its own. Set its default
channelto its own channel and, for a rig's channel, a channel map entry. Its connector is enabled as usual. Itsrig slack statusreportsrelayedInbound: that it can take relayed events, and how many have arrived since it started. It also retries its own kept events every five minutes, including any it kept before it stopped holding a connection of its own, so those still land. -
What happens to a message: the hub relays the event, a reaction or a button click untouched to the machine that owns the channel. That machine handles it exactly as if it had received it: who may send, where it goes, its thread and its decisions. If the relay fails (the machine is down or its registry entry is wrong), the hub keeps it and retries every five minutes, after its own kept events and clicks;
rig slack statuscounts it in the dead-letter backlog. A machine that's down costs one attempt per retry: its other relays wait for the next one. A relay the machine keeps refusing is kept and retried with no limit, because dropping it would lose a person's message. -
Who answered: every post ends
from <seat> on <machine>, using the machine's host id (set it withhost.name). -
The hub's own channels are handled on the hub, as before. A channel can be relayed to one machine only, and never the hub's own default channel.
-
Not covered yet:
rig slack verifyon the hub doesn't check the app's membership in relayed channels, and missed-message recovery covers the hub's default channel only.
Reconnect recovery and status
The daemon scans available top-level messages and replies broadcast with Also send to channel in the configured default channel after a Socket Mode connection and on its existing five-minute retry cadence. Broadcast replies keep their thread's routing. Recovered tasks say Recovered after a gap and show the original Slack posting time. They use the same sender admission, routing, attachment handling, fixed identity and dead-letter path as live messages. A durable dead letter is custody of a failed delivery, not successful delivery.
Recovery keeps a per-channel checkpoint across reconnects, restarts and channel switches. On upgrade it initializes once from the newest retained accepted channel landing; without such evidence it starts at feature adoption. Older history is unknown. New live messages never move the recovery checkpoint. A partial scan resumes its saved interval; only an exhausted interval advances the covered boundary. Each new interval ends five seconds before the local clock to allow recent messages to become visible; larger clock skew or visibility lag is not covered by that margin. A corrupt or unreadable checkpoint leaves recovery unavailable and preserves the existing file; live inbound continues.
Each pass admits at most four pages, 100 entries and 15 seconds of work, with a five-second history-request ceiling. Work already started on a message, such as an attachment transfer, is not cancelled by these limits or by a stop, and a new pass waits for it to finish. Rate limits retain Slack's Retry-After across restart. Transport and server failures use a five-second backoff unless Slack supplies Retry-After. When Slack reports a plan history limit, recovery still lands the available page and advances normally, retaining an older-history limitation in status across restarts. Missing bot credentials, history scope, membership, retention limits and API failures appear as recovery limitations. No additional scope is required to keep live delivery working. Non-broadcast thread replies and other channels remain outside this recovery scope; global chronological order is not promised. This change does not replay intervals already checkpointed before broadcast-reply recovery was added.
rig slack status retains local configuration checks and adds a bounded daemon
snapshot: socket state/generation, last event, recovery interval/state/reason,
retry time and accepted/dead-lettered recovery counts since the connector last
started (they reset when it is enabled or disabled, and when the daemon restarts).
It also reports the durable dead-letter backlog: the retained records for inbound messages,
reactions and click answers that await retry, counted from both dead-letter files so the number
survives a restart, and shown as unknown with a reason (and no count) when a file cannot be read.
Human-readable coverage and pending bounds use ISO timestamps; JSON keeps Slack timestamps.
The status read calls no Slack API and starts no scan. If the daemon cannot be
observed, local configuration remains visible and live state is unknown. A
connected socket or valid configuration alone does not prove end-to-end delivery.