Source document copied on 20 October 2026. Its dated evidence and limitations remain as written. Feedback and decisions can be given in chat.
This is a source review and planning checkpoint, not a live integration pass. It is aligned to Astra's draft spec and tickets, which I read without editing. I made no Slack call, read no token, changed no scope, install or membership, ran no test and made no commit. Astra owns the spec, the tickets, the visual and the commit of this file. The sources and their revisions are in the source index, and the 340-method inventory is in the TSV.
Run by claude-haiku-5-5. The launching process's arguments read --model claude-haiku-5-5 --effort xhigh. That shows the requested model and effort flags. It does not show the effort applied to each call.
The slack-plugin-vs-yours doc is not in this worktree, the JA repo, the agent-setup repo or the orca workspace, so I could not compare against it.
lists:read or lists:write on the calling token, and neither token in the supplied readback has them. A later scope or access grant is a permission change. It needs exact approval after app inspection.conversations.replies token rule is not stated on its page. The published rule sits on conversations.history. Treating the replies page as the same is an inference, and the checkpoint should test it.SLACK_JA_OPS_BOT_TOKEN is the Keychain service label for the bot token. Astra's auth.test on that token resolved to justanotheragent (U0BRM7JT8F4) in T0AUYDT2V2B. So the bot identity is one observed token, and this note does not treat it as a second app. The bot app's ID and its event and interactivity settings are still unread. That is app inspection at implementation time.
The table below uses the readback supplied on 2026-10-20. "Not in readback" means the full scope header did not list that scope. No service call has tested it, so nothing here shows a scope is rejected.
| Token | Present in readback | Not in readback |
|---|---|---|
Bot (justanotheragent) |
channels:join, chat:write.public, users:read, users:read.email. The earlier readback's history, read, chat:write, file and canvas scopes still stand |
lists:read, lists:write, app_mentions:read, reactions:read |
User (toberton) |
users:read. The earlier readback's history, read, chat:write, file and canvas scopes still stand |
lists:read, lists:write, channels:write |
Socket Mode is the native transport this review recommends. Here is the contract as the docs state it.
apps.connections.open with the app-level token (xapp-, scope connections:write), sent in the Authorization header. The page says a POST parameter errors. Slack returns a WebSocket URL.hello envelope, then pushes events_api envelopes and interactive envelopes (block_actions, view_submission and similar).envelope_id. The app acknowledges by replying with that ID. The Socket Mode page says the ack is how Slack decides whether to retry.warning (about 10 seconds' notice), refresh_requested, or link_disabled when Socket Mode is switched off in app settings.settings.interactivity.is_enabled.The starter's entry point shows the split between the two tokens:
app = App(token=os.environ.get("SLACK_BOT_TOKEN"))
...
SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start()
The bot token identifies the app for every Web API call the App makes. The app-level token only opens the socket. Without connections:write on the app-level token, the socket fails to open, and the bot's Web API calls still work.
The starter is the reference for that shape, not something to run. Its sample scopes and events are replaced by JR's set, and the work happens in the isolated WobOS slice. Running it unchanged in a live workspace is not part of this plan.
HTTP is the alternative that was not chosen. The comparison shows why.
| Socket Mode | HTTP Request URL | |
|---|---|---|
| Inbound exposure | None; outbound WebSocket | Public HTTPS endpoint |
| Event authenticity | Pre-authenticated socket; no per-event validation needed (the page says so) | Signature check on each request |
| Acknowledgment | Reply with the envelope_id |
HTTP 2xx within 3 seconds |
| Retries | Ack drives retries; the page gives no schedule | Three retries (immediate, 1 minute, 5 minutes) with x-slack-retry-num; Delayed Events adds hourly retries for 24 hours when enabled |
| Limits | Up to 10 connections; refresh every few hours | Subscriptions disabled if over 95% of attempts fail within 60 minutes |
| Fit for a Mac-local app | Good | Needs a tunnel or hosted endpoint |
The 3-second deadline is written for HTTP. The Socket Mode page states no deadline.
This comes from Bolt for Python at 649cfc8632254559d4ad133c99b02d60f1b0d15f, read as source. Astra's subsequent source check found no Slack/Bolt dependency in the candidate's pyproject.toml or uv.lock. Adoption and qualification of a concrete version belong to the later implementation.
The Socket Mode handler calls run_bolt_app, which calls App.dispatch. Then send_response acks the envelope, but only when the response status is 200. Any other status gets logged and nothing is sent. The Socket Mode adapter also copies Slack's retry_attempt into the x-slack-retry-num header, so listeners see retries the same way they do over HTTP.
Listener registration sets one of two acknowledgment paths.
event() and message()) register with auto-ack on (app.py lines 855 and 916). The thread runner calls ack() before the handler starts, and then runs the handler on an executor. So the envelope is acked before the handler runs. An exception inside the handler is logged after that ack. There is no option on event() to change this.command, shortcut, action, block_action, view and the rest) register with auto-ack off. The handler must call ack(). Dispatch waits up to ack_timeout, which defaults to 3 seconds. If no ack arrives, Bolt logs didn't call ack(), dispatch falls through to its initial 200 with an empty body, and send_response acks that. The handler may still be running. So a slow button handler gets an empty ack at 3 seconds, and any reply it meant to return is lost.Global middleware runs before either path, so it runs before any ack. If middleware raises, dispatch returns 500, and send_response sends no ack. Slack then treats the delivery as unacknowledged.
Two consequences follow from the source. Both are inferences from the code and the Socket Mode page, and neither has been run.
WobOS's Work module at 8a986ec already provides most of the mechanism:
task_id (path_for). start takes a lock and refuses to create a record that already exists (work record already exists).claim_session gives one run ownership of a turn, and mark_turn_accepted records the provider accepting it.work:{task_id}:{turn_number}, with :ask appended for the input. No Slack identifier appears in them today.So a native event reaches the Work by no existing identifier. The recommended binding, for WobOS to confirm at implementation:
task_id from the team, channel and thread_ts. start creates the record exclusively and raises ValueError when the path exists. The middleware must handle that refusal in two ways. A replay, where the event ID is already in the accepted list, is acknowledged and adds nothing. A different first event that lost the create race reloads the record and appends itself under the same lock, because it is new input.event_id against it and append it under the same lock start and claim_session use. Do this in global middleware, before the ack. A replay finds its ID and does nothing new.linked_threads list on the Work. An unknown thread is found by a bounded scan of the permitted open Work records in the same workspace and channel. No separate index is added.author stays fixed to captain: plus the task ID.pending_inputs list. The claim takes the oldest queued input, and restart returns an unfinished in-turn input to the queue. The single pending_ask value cannot hold an order.The existing lock and restart path carry this. It adds fields and a list to the record, but no table and no timer. Astra's subsequent Git comparison found no WobOS/work.py difference between the spec's c15b3c4 and the reviewed 8a986ec. Replay behavior remains untested.
On the retry primitive itself, the Events docs say event_id is globally unique. They do not say whether a retry carries the same event_id, and the Socket Mode page does not describe the retry schedule. The design above holds only if retries reuse the ID, so that is the first thing to confirm when the slice runs.
conversations.replies)conversations.replies returns a thread, cursor-paginated (tier 3). Its ts argument names the parent message or any message in the thread. Its frontmatter lists the same history scopes for a bot and a user. Scope names alone do not settle access, though.
The replies page has no access section and no not_in_channel error. The retrieving-messages guide says the method covers public and private channels and direct messages, without naming a token type. The rule that is published sits on conversations.history:
not_in_channel: "Only user tokens can access public channels they are not in."The tokens guide agrees on the user side. A user token with channels:history gets access to any public channel.
Working assumption, inferred and to be tested: conversations.replies follows the same rule. Under that assumption, a bot cannot read a thread in a channel it has not joined, a user token is what reads a public thread the bot is not in, and a private thread needs membership on either token. The replies page does not say this, so the checkpoint decides it.
Rate limits: conversations.replies is tier 3 for Marketplace and internal customer-built apps. For new, commercially distributed, non-Marketplace apps it is 1 request per minute with limit capped at 15, from 29 May 2025. Whether JA's app counts as internal customer-built is not established here.
The actor key in each payload is a Slack user ID. The socket is pre-authenticated, so the app does not re-verify each envelope, but it still has to decide which person each ID belongs to. The installation binding Astra described does that. The fields below are what it binds to.
| Input | Actor and location fields (from the page examples) | What the app must do |
|---|---|---|
Message event (message.*) |
user (the human who posted), channel, ts, event_ts, channel_type |
Use user as the speaker of this turn |
| Bot-posted message | bot_id in place of a human user (the bot_message page) |
Ignore the app's own posts by bot_id, or it can answer itself. Whether Slack delivers the app's own posts as events is unverified |
| Threaded reply | thread_ts (the thread_broadcast example) |
Treat the parent's ts as the thread key |
| Block action (button) | user.id, team.id, container.message_ts, channel |
Bind the click to that user and that message |
| Event wrapper | authorizations[].user_id names one installer who can see the event |
One event per workspace, even when several installers can see it, so visibility is not uniform |
The spec says a stale click cannot approve newer content. message_ts identifies the message, not its text. So an approval has to be bound to a content hash that JR stored, not to the timestamp alone.
A thread's identity is the team, the channel and the parent message's ts. Each reply carries thread_ts pointing at its parent. A resumed Work does not depend on thread history: it resumes from its stored pending inputs and source references. conversations.replies may supply a backfill or a scoped source read, and each message still names its own user, so a backfilled speaker comes from Slack, not from the model's memory. Its token rule and rate tier are the unverified items listed below.
The spec's rule is that a later speaker does not inherit an earlier speaker's access. Each turn therefore needs its own actor check. Reading the thread gives the app every actor's identity. It does not give the app permission to use what each actor could see. That check stays in JR.
member_joined_channel and member_left_channel fire for public and private channels and group DMs. The join event excludes one-to-one DMs. Both carry user, channel, channel_type and team. The join event lists channels:read, groups:read and mpim:read. The leave event lists channels:read and groups:read.
An event is a change notice, not an audience proof. The spec wants a read before each context injection and each delivery:
conversations.members (tier 4, *:read) for current membership, including group DMs.conversations.info for shared and external status. It returns is_private, is_mpim, is_shared, is_ext_shared, is_org_shared, shared_team_ids and pending_shared. How these behave for Slack Connect members is unverified.The membership read and the send are separate calls. chat.postMessage has no precondition tied to the read, so a member who joins between them can receive the message. The join event arrives only after the fact. The spec's response is a hold on further sends in that thread and a context rebuild. The window itself is not closed.
WOB-72 is closed, with its accepted scope recorded on 9 October 2026. The rules the spec applies are these:
The recorded answers were "Use that scope" for finance and "Allow those edits to their own tasks" for task edits. WOB-72 also states that recording these decisions changed no app scopes, Slack membership, sharing, bot identity or finance access.
This review keeps two layers apart. The audience rules decide who may see an answer, and JR enforces them in its own logic. A physical grant (a scope, bot membership in a channel, or a List access entry) is a separate permission change, and it needs its exact effect approved after app inspection. WOB-72 settled the audience. It granted no Slack permission. The membership read is what makes the "every member" rule enforceable.
slackLists.access.set grants read or write to users or channels, and owner to users only. Only the current owner can set another owner, and owner upgrades across teams are refused. access.delete removes an entry.assignee, user and todo_assignee hold a row's people as cell values, and they grant nothing. For a teammate-task change, which column JR reads as "the owner" is a schema decision for the owning List.items.update takes list_id and cells and no prior-revision argument. items.list and items.info return updated_by and updated_timestamp, and items.update's response carries neither. So the write-time check is read, compare, write and read back, with a race window in between. The spec's action-revision design must accept that window or narrow it.views.update takes an optional hash. A mismatch returns hash_conflict. For modal approvals, Slack enforces compare-and-set.chat.update addresses a message by channel and ts, replaces the whole text, and has no precondition. The approved content is stored as a hash on JR's side, and the app compares it before sending.Every event carries an event_id, which the Events page describes as globally unique. The dedupe rule and its limits are in the Bolt and WobOS sections above. A handler that acts on every delivery can act twice when a retry arrives, and the receipt on the Work record is what stops that.
Edits and deletions follow the same channel and ts identity. message_changed and message_deleted need channels:history and groups:history, as those pages state. chat.update edits only ordinary messages and returns cant_update_message for others. Its error table lists edit_window_closed. chat.delete depends on the token type. A user token may delete messages that user could delete in Slack. A bot token may delete only messages that bot posted.
For mentions, app_mention needs app_mentions:read. The event page's frontmatter writes app_mentions.read with a dot, while the scope index and the assistant manifest use the colon form. I treat the colon form as correct, and the event page as the outlier, unverified against the API. channel_joined lists no scope in its frontmatter, so its requirement is unverified.
No List change event appears in the Events index I read. Stated as a source finding, that means JR learns about row changes by reading Lists when a request or an authorized continuation needs them. It does not call for a timer, and it is not a claim that the API cannot emit such an event.
The method index lists 340 methods. I read each method page's frontmatter for token types and scopes. 104 accept both token types, 45 are bot-only, 147 are user-only and 44 list no scope. A method with no listed scope is not necessarily open. apps.connections.open, for example, takes an app-level token.
| Family | Methods | Disposition for JR |
|---|---|---|
conversations |
28 | Core: history, replies, join, members, info, invite |
chat |
13 | Core: postMessage, update, delete, postEphemeral, scheduleMessage. Streaming methods are bot-only; use only if JR streams a reply |
slackLists |
12 | Core, blocked on scopes and access |
apps |
23 | apps.connections.open for Socket Mode; the rest is installation tooling |
users |
13 | users.info and users.list for name resolution |
views |
4 | Modals for approvals. views.open opens a modal from a trigger_id; the page marks that argument optional, and an expired one returns expired_trigger_id |
assistant |
5 | Only if JR uses assistant threads |
files |
15 | Defer until JR sends files. Use the getUploadURLExternal flow, not files.upload |
| 21 other families | 92 | Adjacent or outside the spec. Defer |
admin |
121 | Out of scope. Needs an Enterprise Grid org-level token, and the JA capabilities doc says JA is not on Grid |
| Deprecated | 14 | reminders (5), stars (3), search (3), rtm (2), dialog (1). Their pages list deprecated_endpoint or say so. Avoid |
"Readback" is the scope list supplied on 2026-10-20. "Not in readback" means the header did not show the scope, not that a call was rejected.
| Capability | Native route | Token and scope (docs) | Readback | Disposition |
|---|---|---|---|---|
| Socket Mode transport | apps.connections.open, envelopes |
App-level token, connections:write |
App token not inspected | Use the native shape in the WobOS slice |
| HTTP request URL | Events API request URL | Public endpoint, signed requests | n/a | Defer |
| Thread reads | conversations.replies (t3), keyed by parent ts |
History scopes; per-token membership rule not stated on this page | History present | Test bot in and out of channel; user token for public threads the bot has not joined |
| Thread and DM history | conversations.history (t3) |
Bot: conversations it is a member of. User: all public and its private | History present | Use |
| New-message events | message.channels, .im, .groups, .mpim |
*:history |
History present | Use |
| Bot-posted messages | bot_id on bot posts |
n/a | n/a | Filter the app's own posts |
| Edit and delete events | message_changed, message_deleted |
channels:history, groups:history |
History present | Use; confirm subscription |
| Mentions | app_mention |
app_mentions:read |
Not in readback | Use only if JR is mention-triggered |
| Membership events | member_joined_channel, member_left_channel |
channels:read, groups:read, mpim:read (join) |
Read present | Use |
| Current membership | conversations.members (t4) |
*:read |
Read present | Use before each injection and delivery |
| Shared and external flags | conversations.info (t3) |
*:read |
Read present | Use; Slack Connect behavior unverified |
| Join a public channel | conversations.join (t3) |
Bot channels:join; user channels:write |
Bot channels:join present; user channels:write not in readback |
Only if JR joins channels |
| Post as the app | chat.postMessage (t5, special: about 1 message per second per channel) |
chat:write |
Present | Use |
| Post where the bot is not a member | chat.postMessage |
chat:write.public |
Bot present | The API permits posting; JR still requires its audience read before delivery |
| Edit and delete own posts | chat.update, chat.delete (t3) |
chat:write |
Present | Use, within the authorship rules above |
| Message revision | chat.update by channel and ts; no precondition |
chat:write |
Present | Content hash on JR's side |
| Private prompt | chat.postEphemeral (t4) |
chat:write |
Present | Use for approval prompts |
| Interactive approvals | block_actions over the socket |
No scope; interactivity enabled in app settings | Interactivity not inspected | Use, with the 3-second ack rule |
| Modal revision | views.update with hash; hash_conflict on mismatch |
None listed | n/a | Use for modal approvals |
| Name lookup | users.info (t4), users.list (t2) |
users:read |
Present (bot and user); users:read.email present (bot) |
Use |
| Lists read | slackLists.items.list, items.info (t2) |
lists:read |
Not in readback | Blocked on scope |
| Lists write | items.create, items.update (no precondition), items.delete, items.deleteMultiple, create, update |
lists:write |
Not in readback | Blocked on scope |
| List access entries | slackLists.access.set, access.delete (t3); read, write, owner; user or channel targets |
lists:write |
Not in readback | Permission change; exact approval after app inspection |
| List change events | None in the Events index read | n/a | n/a | Read Lists on relevant requests and continuations |
| Event dedupe | event_id (globally unique per docs) |
n/a | n/a | Receipt on the Work record, before the Bolt ack |
| Admin | admin.* (121) |
Org-level token on Enterprise Grid | n/a | Out of scope |
| Deprecated | reminders, stars, search, rtm, dialog, files.upload |
n/a | n/a | Avoid |
Nothing here has run, and nothing runs now. All checks happen at implementation time, inside the isolated WobOS slice, against Toberton's own DM or a labeled disposable test channel or List, under the standing live-testing permission. A simulated check, such as feeding the same envelope twice through a fake client, proves the receipt logic and not Slack's redelivery, and it must be labeled that way. Anything that changes a permission needs Toberton's approval of its exact effect first.
| # | Scenario | What it proves | Blocked by |
|---|---|---|---|
| 1 | Slice connects over Socket Mode; one self-DM event arrives and is acked | The native transport shape works in WobOS | App-level token and app settings not inspected |
| 2 | Timing log for the event path and an interactive button | Ack order: events before the handler, interactive handlers within 3 seconds | Slice built |
| 3 | Withhold an ack on a test event and watch for a redelivery | Whether retries reuse event_id |
Hard to provoke; if no redelivery occurs, the receipt logic stays a labeled simulation |
| 4 | Bot and user threads in a labeled test channel the bot has not joined; one public, one private | The conversations.replies rule per token |
Test channel; Toberton's approval for any membership change |
| 5 | Add and remove the bot in a labeled test channel with no other members | Membership event payloads | Subscriptions not inspected |
| 6 | items.list on a disposable test List in an isolated test area, then removal |
Lists read path and cleanup | lists:read granted after inspection |
| 7 | Write and read back one row on that test List | Lists write path and the revision race | lists:write granted after inspection |
| 8 | Grant and revoke a write access entry for the bot on that test List |
Whether access entries apply to bot writes | Permission change; exact approval first |
The scope gap is a fact about tokens. The audience rules in WOB-72 are a fact about who may see what. Neither decides the other. A later grant to any token will be presented with its exact effect after app inspection, and it is not a prompt in the current build.
conversations.replies applies the history rule, and whether a bot can read a thread in a channel it has not joined.event_id, and Socket Mode's retry schedule.649cfc8.owner or write entry, and whether channel-level entries apply to bot writes.channel_joined, which the frontmatter does not list.is_shared, is_ext_shared and shared_team_ids behave for Slack Connect members.8a986ec. Its uncommitted changes were not touched.