Skip to content

feat(agent): open, watch and resume Slack channels from agent runs - #203

Open
github-actions[bot] wants to merge 56 commits into
mainfrom
feat/agent-run-visibility
Open

feat(agent): open, watch and resume Slack channels from agent runs#203
github-actions[bot] wants to merge 56 commits into
mainfrom
feat/agent-run-visibility

Conversation

@github-actions

@github-actions github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Opened automatically when feat/agent-run-visibility was pushed.

The title is written from the diff and rewritten as you push, because this is squashed onto main and the title becomes the commit subject and the changelog line. Retitle it yourself and it is yours — the automation stops touching it.


Summary by cubic

Implements CRM-1: an agent run can now open its own Slack channel, invite people, and post into it, and gets woken up when messages or joins arrive in that channel. Previously Slack actions could only target a destination chosen when the agent was saved; now a run can create and claim a channel, and inbound Slack events resume that same parked run instead of starting a second one.

Behavior

  • New open_slack_channel and invite_to_slack_channel tools are manifest-approved actions, with the same dependency checks and idempotency as existing actions; reopening a name already in use joins the existing channel, and the run keeps the channel it already owns.
  • Reusing a channel refuses an unavailable one (archived, kicked bot, or a private channel the bot can no longer see) and rejoins before handing it back; invitations stop when a run ends.
  • Slack message posts can use resolution: "run-channel", which resolves to the channel the run opened.
  • The new /webhooks/slack/events endpoint verifies signatures and stores events in slackEventInbox; the agent drains them on the cron, in order, retrying undelivered resumes for five minutes.
  • Inbound Slack is off by default: without SLACK_SIGNING_SECRET, the endpoint rejects every request, and answers 200 to bodies it cannot parse so Slack stops retrying.
  • invite_to_slack_channel returns invited and refused arrays, and throws only when Slack refuses every address; a paused agent's Slack message is kept for replay.
  • Run action results and the agent's final summary now appear in run history.

Setup / migration

  • Requires SLACK_SIGNING_SECRET and the new app_mentions:read Slack scope; existing Slack connections must be re-authorized.
  • Migrations add agentRun.slackChannelId, slackEventInbox, and agentAction.result.
  • Slack destinations now require a resolution field (chosen or run-channel); saved agents with older Slack destinations need that field set.
  • bun run tunnel:slack provides a stable local hostname for Slack event delivery; it follows PORT unless SLACK_TUNNEL_PORT overrides it.

Written for commit 900b1ef. Summary will update on new commits.

Review in cubic

ripgrim added 28 commits August 26, 2026 11:36
Proves the load-bearing assumption behind the customer-onboarding flow:
an event that arrives from outside the CRM can wake a run that is already
parked, rather than starting a second one.

The mechanism already existed and was not being used this way.
dispatchAgentRun sends with `continuationToken: runToken(run.id)`, and
per docs/agent.md eve hands a continuation token back only when the
session is parked and will accept another turn. So resuming is the same
send with the same token.

resumeAgentRun is that send, with the guards a webhook needs, because a
Slack event arrives whenever Slack feels like it:

- a finished run is never restarted by a late event
- a run with no session yet is left alone
- an agent that is no longer LIVE is refused
- an unknown run is ignored, not thrown
- a refused send is an outcome, not an exception
- the run's own status is never touched; the runner owns that

Nine integration tests cover each. It does not decide which run an event
belongs to: AgentRun has no slackChannelId, and adding one is a schema
decision rather than spike material.

Not wired to anything yet. The Slack Events endpoint, the channel-to-run
lookup and the new action types are the next steps, and they are ordinary
work now that this holds.
Closes the gap the spike left open. An inbound event knows a channel id;
it needs a run id.

- AgentRun gains slackChannelId, indexed with status, so the lookup is
  one query rather than a scan.
- runOnSlackChannel returns only a live run, so a finished run's channel
  stops routing and a late event lands nowhere.
- claimSlackChannel writes the channel once. A run cannot be reassigned,
  so two channels cannot both point at the same run.
- The newest live run wins when a channel is genuinely reused.

Adds verifySlackSignature in @crm/auth. Slack's events endpoint is a
public POST, so the signature is the only thing standing between a
stranger and resuming somebody's run. It fails closed with no secret,
refuses a body changed by one byte, refuses another secret's signature,
and refuses a replay outside the five-minute window in either direction.
timingSafeEqual, not ===.

Migration written by hand and verified against a throwaway Postgres:
every migration applied, then `migrate diff` reports no difference. The
local database could not author it because it carries the HubSpot
migration from another branch.

25 tests. Still not wired to an HTTP route.
Adds the inbound half. AgentRun gains slackChannelId so an event that
knows a channel can find the run that owns it; slackEventInbox is the
landing table, keyed on Slack's event_id so a redelivery is a no-op
rather than a second resume.

@crm/validation/slack-events parses the envelope and answers the only
two questions the ingest needs: is this from us, and is it worth waking
an agent for. Both matter — a bot_message that woke the agent would have
it answering its own post, forever.

Migration generated by `prisma migrate dev` against a scratch database,
not written by hand. The local crm database carries another branch's
migration, which is why migrate dev refused to author against it; a
throwaway database is the way round that, not a hand-rolled file.

crm_test was rebuilt: it held a failed record from the hand-written
migration this replaces.

17 validation tests, 14 resume tests. Still no HTTP route.
POST /webhooks/slack/events. Answers Slack's setup handshake, verifies
every other request, writes an inbox row and pokes the agent.

The API decides nothing, per the rule in AGENTS.md: it stores the event
and lets the agent work out which run it belongs to and what it means.
That also happens to be what keeps the handler inside Slack's three
second budget.

Refuses by default. With no SLACK_SIGNING_SECRET the endpoint rejects
everything rather than trusting the caller, because it is a public POST
and the signature is the only thing between a stranger and resuming
somebody's run.

Answers 200 to a payload it cannot parse, to an event type we do not act
on, and to a redelivery, so Slack stops retrying instead of hammering a
shape we will never handle. Ignores anything from our own bot; without
that the agent answers its own posts forever.

The raw body is collected by a small middleware on that path alone. The
app runs with bodyParser false and express is not one of its declared
dependencies, so importing express.raw would have broken createApp at
runtime — as it did, until the tracking-collector spec caught it.

10 endpoint tests, driven by signed fixtures. No Slack workspace needed
to run them.
Closes the loop. A stored event finds the run that owns its channel and
resumes it on that run's own continuation token, so the agent carries on
from where it parked rather than starting again.

Every event settles, including the ones that go nowhere. An event whose
channel owns no live run is marked processed with a reason, not left to
be retried forever. A row already processed is skipped, so a second drain
after a redelivery does nothing. Both are the difference between an inbox
and a backlog.

The drain hangs off POST /internal/crm/dispatch, which the API already
pokes on every stored event. It is deliberately not routed through the
channel's receive: receive must return a session, and an event that
resumes nothing has none to give.

app_mention joins the actionable set. It arrives alongside
message.channels when the bot is in the channel, so it changes nothing
today, but a mention in a channel the bot has not joined is exactly how
a customer asks for help.

describe() is what the agent actually reads. It names the channel, the
user and the text, and truncates at 2000 characters so one pasted log
cannot fill a turn.

9 integration tests.
open_slack_channel is the first half of the onboarding flow. A deployed
agent names the channel in plain words, gets a tidied Slack name, and the
run claims the channel so every later message and join wakes that same
run. Without this nothing ever sets AgentRun.slackChannelId and the inbox
resumes nobody.

A name already in use gives back the existing channel instead of an
error, so a retried run lands in the channel it made the first time.

claimSlackChannel now says which channel the run watches. The claim is
write-once, so a run that opens a second channel used to be told it
succeeded while its events went elsewhere. The tool reports the truth.

app_mention is deduplicated against message. Slack sends both for one
human sentence when the bot is in the channel, which resumed the run
twice for one thing said once. A unique index on (channelId, messageTs)
stops the second at the door, and the agent is told it was mentioned
rather than spoken to.

19 tests across validation, the API and the agent.
Closes the standing gap. conversations.connect:write has been requested
from every Slack workspace since the connection shipped and nothing used
it, so a customer could never reach the channel the agent made.

One tool covers both kinds of person. An address Slack already knows is
added with conversations.invite. An address it does not know gets a Slack
Connect invitation, and the tool hands back the invitation link. The
agent does not have to know which somebody is.

already_in_channel counts as invited, so re-running the flow is quiet
rather than an error. A lookup failure that is not a missing person stops
the invitation instead of falling through to Connect, because
"reconnect Slack" and "this person is external" need different answers.

Every Slack call now goes through one caller in slack-api.ts, which owns
the timeout, the rate-limit retry and the parse. Four hand-rolled fetches
in slack-membership.ts went with it.

6 tests.
Slack stores its request URL once. A quick cloudflared tunnel invents a
new hostname every restart, so the stored URL goes stale and delivery
stops with no error anywhere: the endpoint is simply never called again.

tunnel:slack runs a named tunnel. It creates the tunnel if it is missing,
points the DNS record at it, prints the request URL and runs it.
Re-running is safe, and the hostname never changes, so Slack is
configured once.

The script refuses clearly rather than half-working: no hostname, no
cloudflared, and not signed in each say what to do next.

docs/setup.md also records that Socket Mode swallows event delivery while
still showing the request URL as Verified. That cost an afternoon.
The tools to open a channel and invite people existed with nothing
telling an agent the order to use them in, so the one ordering that
matters was left to chance: the channel must be opened with
open_slack_channel first, because a channel opened any other way is a
channel the run does not watch, and every later reply is lost.

The skill also says that waiting is the work. An agent that treats a
parked run as an unfinished job polls, reschedules, or reports success
that has not happened. A Slack Connect invitation takes a person a day to
accept, and the run is supposed to sit there.

retireExhausted now joins a once-evaluated subquery, the same shape
claimDue already used, instead of IN (SELECT ... LIMIT ...). The planner
is free to re-execute a sublink, so the row cap was a request rather than
a guarantee. Two queries doing the same job now read the same way.
…ustomer

The first live test found the real gap: a deployed run executes inside
the agent_runner subagent, which has its own sandboxed tool list, so the
root-level tools were never reachable. The run identified the deal and
the buyer, then wrote a summary because it had nothing to act with.

Copying the tools down would have skipped the discipline every other
external action follows. Opening a channel and inviting people are now
manifest-approved actions with AgentAction rows, claimed and settled by
idempotency key, so a retried run rejoins the channel it already made
instead of making a second one and inviting the customer twice.

Three guards move failure earlier, where somebody can fix it:

- A manifest with a Slack action but no slack:workspace resource no
  longer parses. It used to deploy and then fail on every single run.
- AGENT_ACTION_EXECUTORS and AGENT_ACTION_DEPENDENCIES are exhaustive
  over AgentActionType, so a new action cannot ship without a tool and a
  connection requirement. Both refused to compile until they were filled
  in, and the builder's draft schema had to learn the actions too.
- DraftAction was a hand-written twin of its own Zod schema and had
  already drifted. It is now inferred from the schema.

The event chain is verified end to end against the running agent: a
closed deal queues the task, the EVENT trigger matches, and the run is
created. The second run stops at the dependency preflight with "Connect
Slack in Settings → Connections", which is correct — Slack has never been
connected in this workspace, and the guard refuses before spending a
model call.

Test cleanup deletes AgentAction rows before runs. Without that the agent
definition survived, the user delete failed on its restricted foreign
key, and six orphaned users broke an unrelated auth spec.

7 tests.
A customer channel with only the customer in it is not a channel anybody
uses. The person who closed the deal has to be there from the start, and
asking the model to remember that would make it optional.

The run's input already names the record the event fired for, so the deal
is known without asking the agent for it. SlackMemberMatch turns the CRM
owner into a Slack user id, and the owner is invited with
conversations.invite, which works on a free workspace. Slack Connect does
not, so the customer half still needs a paid plan.

An owner Slack cannot match is reported, not thrown. The channel is
already open by then, and losing it to a missing account would leave a
real Slack channel with no run watching it.

Run input is parsed with a schema rather than read out of the Json column
by hand.
…rive

Slack refuses to save an app manifest that subscribes to app_mention
without this scope: "app_mention event is missing scope(s)". The event
was already in the actionable set, so a mention in a channel was meant to
wake a parked run and silently could not.

Found by creating the staging app from our own scope list. The manifest
would not validate.
The history contract and the run store both parse the same shape.
Naming it in two places lets them drift. One export, used on write
and every read.
conversations.inviteShared defaults external_limited to true, so Slack omits url. The request now sets external_limited false. The timeline shows invite_id and the url Slack returned.
… an event

A public Slack POST was unbounded, a failed inbox write looked like a duplicate, and two drains could resume the same row.
The events path now uses Express raw middleware. Without Express as a direct dependency, createApp cannot start in tests.
CRM-1. A run-channel destination resolves to AgentRun.slackChannelId at post time.
CRM-1. Capabilities and the API switch on resolution instead of probing id.
CRM-1. Tests that stored a Slack destination now match slackDestination.
(cherry picked from commit d1687f04a45d682abeec32e4fadc738ca55ee9d7)
(cherry picked from commit e506f9f61c2e317b772a94624e311b89ce61398d)
(cherry picked from commit 5f1e19c82cb079b01aee096442cc1c4c8b9c1561)
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
crm-agent Ready Ready Preview Sep 2, 2026 11:23am UTC
crm-api Ready Ready Preview Sep 2, 2026 11:23am UTC
1 Skipped Deployment
Project Deployment Actions Updated
crm-app Skipped Skipped Sep 2, 2026 11:23am UTC

Request Review

SLACK_TUNNEL_PORT was the only source, so PORT=4000 left the tunnel forwarding to 3001 and every Slack event came back 502. The port now follows PORT, with SLACK_TUNNEL_PORT still the explicit override, and the .env fallback is shared with the hostname read.
invite_to_slack_channel never returns a top-level invited: false. It returns invited and refused arrays, and it throws when Slack refuses every address. The skill named a field that does not exist, so the agent could not act on a partial refusal.
The skill called every outright failure a Slack refusal. A missing channel and a stopped run reach the same throw with different text. It also told the agent to read refused after a replay, which carries result only.
An exported PORT outranked SLACK_TUNNEL_PORT in .env, so the tunnel followed the generic variable and pointed at the wrong port. The tunnel-specific variable now resolves from the environment and .env before PORT is read.
Reusing the channel a run owns skipped every Slack check, so an archived channel or a kicked bot still settled the open action SUCCEEDED and every later run-channel post failed. The reuse path now rejects an unavailable channel and rejoins before it hands the channel back.
A throw inside the paused window left the shared agent PAUSED, which breaks every later test that expects a live resume.
An unavailable row also covers a private channel the bot can no longer see, so the message asserted something Slack never said.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant