Goal: a first-class Slack section in the desktop app so the operator can read and send in channels they already have, as themselves, and so Locus can do work from that context and reply as the same person — without publishing port 7420.
Identity rule: Locus assumes the signed-in Slack user for the client (channel list, history, default send). Teammates see the operator’s name and avatar unless the operator chooses as Locus. The Slack app is OAuth + Socket Mode plumbing. Bot posting is opt-in (chat.postMessage with the bot token).
Locus stays the local control plane. Slack.com stays the host. Tokens stay in ~/.locus/secrets.enc. This is not a Slack clone and not a hosted bot SaaS.
Related: webhook-automations.md (today’s inbound POST path), codebase.md (the UI pattern this copies), SECURITY.md (loopback, untrusted fences, approval gates), IntegrationsClient in apps/server/src/integrations/integrations.ts, slackSignatureValid in apps/server/src/slack/slack.ts.
Today Slack is a tool plus a webhook. Settings → Integrations holds a Bearer token (usually a bot token); agents may call slack_channels / slack_post (writes wait for Approve / Deny). Automations → On webhook can ingest Slack Events / slash commands if something can POST to loopback or to a Tailscale Funnel on only /api/hooks.
That is enough to fire an unattended job. It is not enough to live in Slack as you:
| Need | Today | Gap |
|---|---|---|
| See channels I can already open | slack_channels lists what the bot token can see |
No UI, no DMs / private channels the user is in, no history |
| Send / reply as me | slack_post posts as whatever identity that token is |
Bot-shaped posts, no composer, no threads |
| Ask Locus to do something | Webhook automation: one prompt, Funnel required | No thread-bound local agent that writes back as you |
| Stay local-first | Funnel is the only public path, scoped to /api/hooks |
A client needs outbound Slack (Socket Mode), not inbound HTTP |
Copy Codebase, not the Slack desktop app.
Icon rail: Agents | Design | Codebase | Slack | Docker | Automations
│
▼
┌─────────────┬──────────────────────────────────────────┬─────────────┐
│ workspace │ #eng ⋯ Ask Locus │ thread │
│ Starred │ ───────────────────────────────────────── │ (optional) │
│ Channels │ 10:02 alice can you look at checkout? │ replies │
│ #eng │ 10:04 you on it │ + composer │
│ #ops │ 10:11 you patch is up, PR linked │ │
│ DMs │ (that second line was the agent, │ Agent chip │
│ @bob │ posted as you after Approve) │ in Locus │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ message composer (you, immediately) │ │ │
│ │ └───────────────────────────────────────┘ │ │
└─────────────┴──────────────────────────────────────────┴─────────────┘
In Slack.app those two “you” lines are indistinguishable. In Locus, agent-authored messages get a small chip so the operator can tell what they typed vs what the model sent.
Empty state (same voice as Codebase): Sign in with Slack in Settings → Integrations; Locus is the local client, using your user token; Slack.com is still Slack.
- Browse public channels, private channels, DMs, and MPIMs they can already see (
users.conversationswith the user token). - Read history and threads (
conversations.history/conversations.replies, user token). - Send as themselves from the composer (
chat.postMessage, user token), including GIFs, or send as the Locus bot when a bot token is stored (composer as Locus). Operator-typed sends are an operator action, like a guest-pane click — they do not go through chat Approve / Deny. GIF search uses Giphy whenLOCUS_GIPHY_API_KEYis set; otherwise a tagged local catalog (common reactions still match). Live hits prefer downsized GIF URLs so Slack image blocks can load them. Typing@suggests people from DMs and the open timeline and posts a real Slack mention. Thread replies can Also send to channel. Drag the thread drawer’s left edge to resize it. Hover Edit / Delete on your own messages (chat.update/chat.delete); Up-arrow in an empty composer edits your last message. Opening a conversation marks it read (conversations.mark). Open in Slack jumps to Slack.app. - See GIFs, images, HTML, CSV, video, and PDF files teammates posted (Giphy unfurls, image blocks, Slack file GIFs, a sandboxed inline preview of
text/htmluploads, an inline table for CSV/TSV, an inline player for video, and an inline PDF viewer). Slack-hosted files are proxied through Locus with the user token; public Giphy URLs render directly. HTML, CSV, and PDF media still download as an attachment unless the Slack pane requestspreview=1. Video is served inline so the player can load it. - Ask Locus from a message, a thread, the composer,
/locus, or the “Run with Locus” message shortcut. That creates or continues a normal agent session on this machine and binds it to the Slack thread. From Ask Locus, Review this PR, Design this screen, and Ops loop start that named recipe with the thread already attached (a new session — they do not reuse a generic Ask Locus chat)./locus stop//locus stop nowabort that bound run instead of starting another. The Slack pane has Search messages, Catch me up, and a Stop button while the bound session is running. - Let the agent reply into that thread via
slack_post(user token by default, oras="bot"for the Locus app). Interactive sessions wait for Approve / Deny. When aslack_postis pending, the thread drawer shows a Draft reply you can edit, then one Send (or Deny). Status (running / waiting / error) stays in the Locus UI — do not post “Locus is thinking” into the channel. Slack Copilot dumps into the channel; Locus stays operator-gated. - Catch me up, locally. A per-channel digest of messages since the last catch-up (capped excerpt). The run stays on this machine; it does not
slack_postunless you ask. History never leaves for Slack’s cloud. - Subscribe from the Slack pane. Pin a channel or thread onto an agent from the conversation row (or the thread header). The row shows Listening. Same
subscribepath as a coding chat — Locus does not post a “now listening” notice. - Cite and jump. When a Slack message mentions a
/workspace/…file, a GitHub/GitLab/Bitbucket PR URL, or a Locus session id, those become chips that open Files, Codebase, or the bound chat. - Jump from the Locus agent chip to the Agents section. Slack action buttons may Approve / Deny
slack_postandslack_uploadonly. Do not approvegit_pushfrom Slack.
There is no @Locus bot to invite, mention, or DM. Teammates ask you; you run Locus. /locus is something you type.
- A bot that joins channels, gets @mentioned, or is the only way to talk to Locus. Optional composer /
slack_postas-bot is fine. - Huddles, Slack Connect admin, canvases, lists, workflows, custom emoji packs, DND.
- A multi-tenant bot that runs agents for teammates’ laptops. One
~/.locus, one operator. - Replacing webhook automations. Unattended “POST → run this prompt” stays.
- Approving gated tools from Slack.
- Publishing 7420, widening Funnel past
/api/hooks, or taking Events API HTTP as the primary live path. - Indexing or uploading Slack history off this machine. Cache under
~/.locus/slack/only. - Posting agent internals (tool chips, “waiting for approval”) into Slack as the operator.
Slack apps still have a bot user because Slack apps do. The Slack section signs in as the operator. Posting as the Locus bot is optional.
| Token | Role |
|---|---|
User (xoxp-) |
The product default. Channel list, history, composer send/upload, agent slack_post / slack_upload, unread. Required for the Slack section. |
App (xapp-) |
Socket Mode (apps.connections.open) so /locus and shortcuts arrive without Funnel. Never sent to the model. Never used to post. |
Bot (xoxb-) |
Optional posting identity. OAuth stores the bot token when bot chat:write is granted. Composer toggle as Locus and slack_post as="bot" use it. A pasted bot token in Settings still powers webhook-era slack_post until Sign in with Slack succeeds. Reads always prefer the user token. |
Request bot commands, chat:write, chat:write.public, and files:write so the app can post and upload to public channels without joining. Do not request channels:join. Do not call conversations.join as the bot. Do not chat.postMessage with the bot token unless the operator chose bot identity (as="bot" / composer toggle).
Socket Mode delivers app events (slash commands, shortcuts, interactivity). It does not give a firehose of every channel the user can open — that would require a bot member, which we are not.
So:
- Opening a conversation loads history with the user token and starts a server poll (~15s) of new messages only; the hub pushes those into the Slack UI. The UI does not poll history itself.
- Leaving Slack or hiding the window unwatches, so Slack.com is not polled in the background.
- Conversation list refreshes when the Slack section is visible (on open, every 60s, and when returning to the tab after 30s). Do not poll every channel’s messages.
- Activity (
search.messages@meplus unread conversations) refreshes every 60s from the app shell so mentions and unread DMs can badge Slack and fire a desktop notification. That is one search, not a poll of every channel. Mentions on a watched channel also land in Activity from the hubslack.messageevent. /locusand “Run with Locus” still arrive on Socket Mode (interactivity), then Locus posts any reply as the user.
That is honest. Do not pretend Socket Mode is a user-token Slack client protocol.
Slack.com
▲ HTTPS Web API (user token for read / default write; bot token when as=bot)
│ WSS Socket Mode (app token; slash + shortcuts only)
│
Laptop Locus (127.0.0.1:7420)
SlackGateway Hub WS UI
- user OAuth slack.message Slack section
- Web API as you slack.channel composer (you or Locus)
- Socket Mode slack.status Ask Locus
- poll open channel │
▼
SessionService.create / chat.send
slack_post → user token by default, bot token if as="bot", Approve / Deny
Socket Mode is outbound. Slack.com still cannot reach loopback. Funnel stays the public path for webhook automations and forge hooks; the Slack client must not depend on it.
Fallback when Socket Mode is off: polling still reads/sends as you. /locus will not work until the app token is back; Ask Locus in the UI still will.
Existing POST /api/hooks/automations/:id is unchanged. Slash commands aimed at a webhook automation keep hitting Funnel. /locus on the Locus app arrives on Socket Mode and goes to SlackGateway, not AutomationService.handleWebhook.
All of this binds to 7420 as today. The UI talks to:
| Method | Path | Role |
|---|---|---|
| POST | /api/slack/oauth/start |
Open Slack OAuth (browser). Same loopback callback family as Bitbucket (/api/oauth/callback) with a state that names Slack, not a forge. Request user scopes. |
| GET | /api/slack/oauth/status |
Pending / complete / error |
| POST | /api/slack/oauth/disconnect |
Drop user + app tokens; stop Socket Mode |
| GET | /api/slack/status |
{ connected, teamId, teamName, userId, socket, canPostAsBot } |
| GET | /api/slack/conversations |
Sidebar list (cached, user token) |
| GET | /api/slack/conversations/:id/history |
?oldest=&latest=&limit= |
| GET | /api/slack/conversations/:id/replies |
?ts= |
| GET | /api/slack/search |
?q= message search (search:read; allowlist filtered) |
| POST | /api/slack/conversations/:id/messages |
Operator send (as: user default or bot; replyBroadcast for also-send-to-channel) |
| PATCH | /api/slack/conversations/:id/messages |
Edit your message (ts, text) |
| DELETE | /api/slack/conversations/:id/messages?ts= |
Delete your message |
| POST | /api/slack/conversations/:id/read |
Mark the conversation read (conversations.mark) |
| POST | /api/slack/run |
Start or continue an agent from a Slack payload (channel, thread_ts, text, message_ts) |
No /join route. Hub events (extend ClientEvent in packages/shared/src/protocol/events.ts):
slack.status { connected, socket, teamId, userId }
slack.channel conversation summary (unread, last ts)
slack.message one message (channel, ts, thread_ts, user, text, subtype)
slack.run { sessionId, channel, thread_ts, status } // Locus UI only
Do not put token material or raw Socket Mode envelopes on the hub. Size-cap message text the same way webhook events are capped.
~/.locus/slack/<teamId>/ — not in git, not in the prompt. Files are AES-GCM encrypted with the vault master key (same locus1. envelope as secrets.enc).
conversations.json summaries (id, name, isIm, isPrivate, unread, lastTs)
messages/<id>.json last N messages per conversation (cap, e.g. 200)
stars.json Locus-local starred channel ids
emoji.json workspace custom emoji map
Opening Slack after a reboot serves this snapshot immediately (stale-while-revalidate). Conversations / history / emoji still refresh in the background when older than the TTL (conversations 60s, messages 2 minutes, emoji 1 hour). Reset all data wipes this tree with the rest of ~/.locus.
Sessions that were started from Slack store a binding, not a copy of the channel:
session.slack?: {
teamId: string
channelId: string
threadTs: string // parent ts; top-level messages use their own ts
lastPostTs?: string // last message Locus posted as the user (operator or agent)
}
Continue-in-thread uses that binding. Do not dump Slack JSON into session.json beyond ids and the already-wrapped user message.
One function, used by Socket Mode (/locus, shortcut) and by POST /api/slack/run (Ask Locus in the UI):
dispatchSlackRun({ channelId, threadTs, userId, text, source })
reject unless userId is the signed-in user
(slash/shortcut are always the invoking user; Ask Locus is the operator)
find session with matching slack.channelId+threadTs that is still idle/running
→ chat.send(follow-up), wrap Slack text as untrusted
(recipeId always creates a new named job instead of reusing)
else SessionService.create({ ...defaults, optional recipe kind/tools/playbook, slack binding })
→ first user message = operator instruction + untrusted Slack thread excerpt
do not post a status line to Slack
on approved slack_post (or Autonomous), post the work product as the user
First-turn prompt shape:
The operator asked Locus to work from Slack. You are acting as the operator
in that workspace. slack_post sends as them; wait for Approve unless Autonomous.
Instruction:
<the slash text / Ask Locus text>
<untrusted-data kind="slack-message" source="C0123/1234.5678" nonce>
…thread excerpt, size-capped, secrets stripped…
</untrusted-data>
Only the operator’s Ask Locus text and /locus / shortcut text (they typed it) are trusted instructions. Everyone else’s Slack text in the excerpt is data.
Defaults for a new session: the Slack account defaultProviderId / defaultModel if set, otherwise the first enabled provider. Ask Locus includes a provider and model picker; that pick is sent with the run and remembered in this browser like New agent. Follow-ups in a bound thread apply a changed pick to that session. Runtime and repo still come from defaultRuntime / defaultRepoId.
Thread follow-ups from the signed-in user in a bound thread: treat as chat.send (including Steer if a turn is in flight) only when they used /locus again or Ask Locus — not every message they type in that thread (those are just them talking to the channel). /locus stop maps to chat.abort. Do not map Slack reactions to approvals.
Do not post running/waiting/error copy into Slack as the user. The Locus UI shows that. After a successful turn, the model may slack_post a human reply (capped like SLACK_NOTIFY_MAX, 3_500). That call still hits Approve / Deny in interactive sessions.
Keep slack_post / slack_upload / slack_channels. Reads use the user token once Sign in has one. Writes default to the user token; as="bot" uses the bot token.
| Tool | When | Approval | Identity |
|---|---|---|---|
slack_channels |
already exists | no | user token (users.conversations) |
slack_post |
already exists | yes (mutating) | user token by default; bot token when as="bot" |
slack_upload |
files | yes (mutating) | user token by default; bot token when as="bot" |
slack_history |
PR 3 | no (hostile output, wrap) | user token |
slack_replies |
PR 3 | no (hostile output, wrap) | user token |
slack_post description: post as the signed-in operator by default; pass as="bot" for the Locus app. Interactive sessions wait for Approve / Deny. Automations keep today’s allowlist opt-in. Offer Slack tools when a Slack account has a user token (integrationReady); a bot-only leftover token does not unlock the client. After OAuth, user-identity slack_post must still prefer the user token.
Mark slack_history / slack_replies hostile in packages/shared/src/untrusted.ts next to slack_channels. Add UntrustedKind "slack-message" (same fence family as pr-text).
Locus cannot ship a single Slack client id that works for every operator’s workspace without Locus Inc. hosting OAuth. Match GitHub / Bitbucket: the operator pastes an app’s client id / secret (or env LOCUS_SLACK_OAUTH_CLIENT_ID / LOCUS_SLACK_OAUTH_CLIENT_SECRET), enables Socket Mode, and stores the app-level token (Socket Mode still needs it). OAuth is for user scopes.
Redirect URL: http://127.0.0.1:7420/api/oauth/callback (reuse forgeOauthCallbackUrl; document that the Slack app’s redirect must match the bind port).
User scopes (the product): channels:history, channels:read, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, chat:write, users:read, files:read, files:write (composer and slack_upload), emoji:read (workspace custom emoji), reactions:write (emoji reacts on messages), search:read (Slack pane search). users.conversations needs im:read when listing DMs; without it Slack returns missing_scope for the whole list. Reinstall the Slack app after adding search:read.
Bot scopes: commands, chat:write, chat:write.public, files:write. No app_mentions:read. No channels:join.
Socket Mode: on. Subscribe to slash command / shortcut interactivity, not message.channels bot events (those need a bot in the room).
Slash command: /locus → {text} is the instruction. Exact stop or stop now (/locus stop, /locus stop now) aborts the bound thread. The slash line already appears in Slack as the operator. Message shortcut: Run with Locus. Replies go through slack_post after approval (user token by default, or as="bot").
Ship a docs/slack-app-manifest.yaml in PR 1 so the operator can create the app in one paste. Do not commit client secrets. The manifest includes bot chat:write and chat:write.public for optional app-identity posts.
Assuming the operator’s identity is the sensitive part. Treat agent Slack posts like git_push.
- Loopback. No new bind. Socket Mode is outbound WSS to Slack. Funnel remains
/api/hooksonly. - Vault. User and app tokens in
secrets.enc(SecretBundle: keepintegrationTokensfor any leftover bot paste; addslackUserTokens/slackAppTokenskeyed by integration id). Redact inGET /api/config. Never log raw Slack payloads or Socket Mode envelopes. - Untrusted Slack text. Thread excerpts and tool history are nonce-fenced. Prompt-injection from
#engmust not becomegit_pushorslack_post. - Who can start an agent. The signed-in user only (
/locus, shortcut, Ask Locus). No teammate mention path. - Writes. Operator composer = operator, immediate. Model
slack_post= Approve / Deny (posts as the operator). No control-plane status posts into the channel. - Approvals. Stay in Locus. A leaked Slack session must not Approve a push. Approving
slack_postis allowing Locus to speak as you. - Egress. Slack API calls run on the host like forge PR tools. Guest jail unchanged.
- Cache.
~/.locus/slack/is workspace message data. Do not send it to providers except the capped excerpt on a dispatch.
Land the operator-facing rules in SECURITY.md in the PR that first dispatches agents (PR 3), including: Slack messages Locus sends are you.
Extend IntegrationAccount for kind: "slack" only (Jira/Grafana stay token + base URL):
authMethod?: "token" | "oauth"
oauthClientId?: string
oauthClientSecret?: string
teamId?: string
teamName?: string
userId?: string
userToken?: string // vaulted; required for the client
appToken?: string // vaulted; Socket Mode
allowedSlackChannelIds?: string[] // optional; empty = any conversation the user can open
defaultRuntime?: AgentRuntime
defaultRepoId?: string
defaultProviderId?: string
defaultModel?: string
token (bot) remains for leftover pasted tokens and webhook-era tools. integrationHasToken for offering the client and user-identity slack_post is the user token. Settings copy: Sign in with Slack so Locus can act as you.
Settings → Integrations Slack row: Sign in with Slack, app token field, client id/secret (like Git), optional channel allowlist (comma-separated IDs), disconnect. Settings → Advanced Slack signing secret stays for webhook automations; Socket Mode does not replace it.
Drop botUserId / allowedSlackUserIds from this design (no bot, no teammate triggers).
| Piece | Path | Change |
|---|---|---|
| Rail section | apps/desktop/src/state/store.ts UiSection |
add "slack" |
| Rail icon | apps/desktop/src/components/layout/Sidebar.tsx, Icons.tsx |
Slack hash/chat icon; channel list when section is slack |
| Main pane | new apps/desktop/src/components/slack/Slack.tsx |
empty state, timeline, composer, thread drawer |
| Topbar title | apps/desktop/src/components/layout/Topbar.tsx |
conversation name, else "Slack" |
| App shell | apps/desktop/src/App.tsx |
render SlackPane like CodebasePane |
| Palette | apps/desktop/src/lib/palette.ts |
group "Slack": jump to channel, Ask Locus |
| API client | apps/desktop/src/lib/api.ts |
slack.* methods |
| Settings | apps/desktop/src/components/settings/Settings.tsx |
Sign in with Slack on the Slack integration row |
| Tests | Sidebar.test.tsx, Settings.test.tsx, new Slack.test.tsx |
rail order, empty state, send as you, Ask Locus |
Sidebar channels: Starred (Locus-local, persisted under ~/.locus/slack/), Channels, Direct messages. Each group is collapsible; expand/collapse is remembered in this browser. Search filters the list (existing SearchField) and temporarily expands groups so matches stay visible. Unread dot matches agent unread styling.
Composer: Enter sends, Shift+Enter newline, no slash-menu of guest commands (those belong in Agents). Secondary button Ask Locus on the channel header and on message hover. Hover React (or an existing chip / +) adds or removes an emoji reaction as you (reactions.add / reactions.remove). Do not map Slack reactions to Locus approvals.
Own messages: user === status.userId render as “You”. If session.slack.lastPostTs matches an agent-approved post, show an agent chip in Locus only.
Markdown: Slack mrkdwn (*bold*, <@U123>, <#C123>) converted before formatMarkdown. Unknown tokens stay as text. Images/files: files.upload (or the current files API) plus Slack permalinks; slack_upload waits for Approve / Deny (Slack buttons allowed for that tool).
Stop after the slice that proves the seam. Do not build a Slack-complete client in the first PR.
Prove: Sign in with Slack stores a user token and the bot token when Slack returns xoxb-; SlackClient lists private channels + IMs as that user against fixtures; chat.postMessage in the client helper sends the user token by default and the bot token when as="bot"; Socket Mode connects with a fake WSS and acks slash-command envelopes; GET /api/slack/status has userId and canPostAsBot when a bot token exists; OAuth scopes in the start URL include bot chat:write.
| Piece | Path |
|---|---|
| Types | packages/shared/src/models/integration.ts, config parse |
| Vault | apps/server/src/core/vault.ts user + app tokens |
| OAuth | new apps/server/src/slack/slackOauth.ts (mirror forgeOauth.ts redirect flow, not device flow) |
| Web API | expand apps/server/src/integrations/integrations.ts or new apps/server/src/slack/slackApi.ts — user token first |
| Socket Mode | new apps/server/src/slack/slackSocket.ts |
| Gateway | new apps/server/src/slack/slackGateway.ts (start/stop with the server, reconnect backoff) |
| HTTP | apps/server/src/app.ts oauth + status |
| Settings | Sign in / disconnect / app token |
| Manifest | docs/slack-app-manifest.yaml (user scopes + commands only) |
| Tests | oauth state, vault redaction, no bot post when user token exists, socket ack, list conversations fixture |
Do not add UiSection yet. Do not dispatch agents. Do not change Funnel.
Prove: with a mock gateway (no real Slack), the rail opens Slack, lists conversations, shows history, posts a message as the signed-in userId, and polling appends new messages. No join/invite control.
| Piece | Path |
|---|---|
| Types | packages/shared/src/models/slack.ts (conversation, message) |
| Cache | apps/server/src/slack/slackCache.ts under dataDir/slack/ |
| Routes | history, replies, post, conversations |
| Hub | slack.message / slack.channel / slack.status |
| UI | section, sidebar, Slack.tsx, palette, topbar |
| Tests | cache TTL, post uses user token, poll path, Sidebar rail includes Slack, empty state copy |
Manual (when a workspace is available): Sign in, open a private channel, send a message, confirm it appears in Slack.app as you (your avatar), not an app bot.
Prove: POST /api/slack/run creates a Docker (or last-runtime) session with session.slack set, wraps the thread as untrusted, does not post a bot/status line, and an approved slack_post uses the user token. /locus from a different Slack user is ignored (should not happen if only you installed the app; still assert). slack_history / slack_replies are hostile-wrapped.
| Piece | Path |
|---|---|
| Session | AgentSession.slack on packages/shared/src/models/session.ts |
| Dispatch | slackGateway.dispatchRun → SessionService |
| Tools | slack_history, slack_replies; slack_post defaults to user token, as="bot" uses bot token |
| Untrusted | untrusted.ts kind slack-message; HOSTILE_TOOLS |
| UI | Ask Locus, message hover, agent chip on your agent-sent messages, jump to session |
| SECURITY.md / README | operator rules + screenshots after the UI exists |
| Tests | untrusted fence, reuse thread session, no Slack approval of git_push, no bot token on post, automation webhook path unchanged |
Throttled UI status, optional channel allowlist UI, custom emoji as :name:, file permalinks, notify-webhook automations posting as you instead of hooks.slack.com. /locus stop and message search are in. Not a blocker for v1.
PR 1
- Vault strips
userToken/appTokenfromconfig.json;GET /api/configredacts them. - OAuth callback with a Slack
statedoes not go through Bitbucket token exchange. SlackClient.conversations.listas user returns private + IM fixtures.- Helper
postMessagewith both tokens present calls Slack with the user token; bot token is unused. - OAuth authorize URL includes
chat:writeas a user scope and does not include botchat:write. - Socket Mode test double: slash-command envelope is acked; invalid app token does not throw out of
start(). - Existing
slackSignatureValidand webhook automation tests still pass.
PR 2
GET /api/slack/conversationsempty without a user token; 200 with fixture cache.- Post message calls
chat.postMessagewith the user token. - Hub
slack.messageupdates the open conversation in the desktop test (same style asCodebase.test.tsx). - Sidebar labels are
Agents, Design, Codebase, Slack, Docker, Automations. - Cache cap: 201st message drops the oldest.
- No
conversations.joinclient or route.
PR 3
- Dispatch from signed-in user creates a session with
slack.channelId/threadTs. - Second
/locussame thread callschat.send, does not create a second session. - Dispatch does not call
chat.postMessageuntilslack_postis approved. - Approved
slack_postuses the user token; fixture assertsAuthorization: Bearer xoxp-…. - Thread excerpt is inside
<untrusted-data kind="slack-message". toolNeedsApproval("slack_post")still true.POST /api/hooks/automations/:idSlack challenge / mention still queues an automation, not a Slack-section run.
Verify:
npm test --workspace=@locus/shared --workspace=@locus/server --workspace=@locus/desktop
npm run typecheckDo not rewrite these; extend them.
packages/shared/src/models/integration.ts— Slack as an integration kind;slack_post/slack_channelspackages/shared/src/models/webhook.ts— Event parse, skip bot loops, notify bodypackages/shared/src/models/config.ts—advanced.slackSigningSecretapps/server/src/integrations/integrations.ts— Web API helper (today GETconversations.list, POSTchat.postMessagewith whatever token is stored)apps/server/src/slack/slack.ts— Signing-secret HMAC onlyapps/server/src/automations/automations.tshandleWebhook— Funnel/Events pathapps/server/src/git/forgeOauth.ts— Loopback OAuth callback to reuse for Slack’s redirectapps/server/src/core/vault.ts/apps/server/src/core/redact.ts— Secret strip/maskapps/desktop/src/components/codebase/Codebase.tsx— Split list + detail UI to copydocs/webhook-automations.md— Operator notes that stay true
- Post, join, or @mention as a Locus bot. No “Invite Locus to this channel.”
- Inbound Events API as the way the client receives messages (Funnel is for automations).
- Slack RTM, or a second process/daemon the operator must start.
- Hosting a Locus Slack app that all workspaces install (no Locus-operated OAuth).
- Embedding Slack’s JS SDK in the renderer; all Slack HTTP stays on the server.
- Feeding full channel history into the model (capped excerpt only).
- Mapping emoji to autonomy. Slack buttons already Approve / Deny
slack_post/slack_uploadonly. - Status / tool-chip messages into the channel as the operator.
- Replacing first-party
jira_*/grafana_*with a generic “chat integrations” framework in this work.
Failing tests: vault redacts a Slack user token; GET /api/slack/status is { connected: false } with no account; postMessage prefers xoxp-. Then slackOauth + slackApi fixtures + Settings Sign in button. That is a reviewable PR without a new rail icon.