Skip to content

feat(agent): route a Slack event to the run that owns the channel - #193

Open
github-actions[bot] wants to merge 29 commits into
mainfrom
feat/slack-event-resume
Open

feat(agent): route a Slack event to the run that owns the channel#193
github-actions[bot] wants to merge 29 commits into
mainfrom
feat/slack-event-resume

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

A Slack Connect invitation is stored as a typed AgentAction result, so replay, history, and the timeline keep invite_id and url.

Problem

The invite existed only as a Slack side-effect. A retried call could not prove Slack already sent the invitation. History returned an untyped JSON blob. The timeline had no invite link.

Solution

AgentAction.result is a Prisma JSON column. @crm/validation/agent-action is the only read and write path.

The invite result is flat: type, invite_id, url, email, kind. externalId is Slack's invite_id when Slack returns one, otherwise the channel id.

Other actions keep the fields they already had:

  • slack.message.postchannel, ts
  • slack.channel.openchannelId
  • crm.activity.createactivityId

Files

  • packages/validation/src/agent-action.ts
  • packages/validation/src/index.ts
  • packages/validation/package.json
  • packages/validation/test/agent-action.spec.ts
  • packages/db/prisma/schema.prisma
  • packages/db/prisma/migrations/20260830083000_agent_action_result/migration.sql
  • apps/agent/agent/lib/run-runtime.ts
  • apps/agent/agent/lib/slack-invite.ts
  • apps/agent/agent/lib/slack-api.ts
  • apps/api/src/agent/agents.contracts.ts
  • apps/api/src/agent/agent-runs.service.ts
  • apps/app/components/agent-builder/agent-history.tsx
  • apps/agent/test/slack-invite.integration.spec.ts
  • apps/agent/test/slack-channel-actions.integration.spec.ts

How to verify

  1. Run bun run db:test so crm_test has agentAction.result.
  2. Run the three specs below. The channel-actions spec stores invite_id I1 and url, then replays the same call and keeps both.
  3. Run bun run lint:slop. Oxlint must report nothing.
  4. Open a run history row for a Connect invite. The timeline shows the invite URL.

Commands and results

CRM_TELEMETRY_DISABLED=1 bun test test/agent-action.spec.ts

Ran in packages/validation. 7 pass, 0 fail. 20.00ms.

CRM_TELEMETRY_DISABLED=1 bun test apps/agent/test/slack-invite.integration.spec.ts

6 pass, 0 fail. Includes invite_id: "I1". 285.00ms.

CRM_TELEMETRY_DISABLED=1 bun test apps/agent/test/slack-channel-actions.integration.spec.ts

8 pass, 0 fail. AgentAction row and replay keep invite_id I1 and url. 349.00ms.

bun run lint:slop

$ oxlint — exit 0, no findings.

Pre-push on 70da103:

  • bun run check-types — 13 successful
  • bun run lint — turbo lint successful
  • bun run lint:slop — oxlint, no findings
  • bun run test — 10 successful, 10 total

ripgrim added 16 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.
@vercel

vercel Bot commented Aug 30, 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 10:04am UTC
crm-api Ready Ready Preview Sep 2, 2026 10:04am UTC
crm-app Ready Ready Preview Sep 2, 2026 10:04am UTC

Request Review

@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

CRM-13

Persist the provider reply on AgentAction so a Slack Connect invite can be verified after the fact.

CRM-13 commits: 8387329, a12b23a. Branch HEAD: d1549da.

Files

  • packages/validation/src/agent-action.ts
  • packages/validation/test/agent-action.spec.ts
  • packages/validation/src/index.ts
  • packages/validation/package.json
  • packages/db/prisma/schema.prisma
  • packages/db/prisma/migrations/20260830083000_agent_action_result/migration.sql
  • apps/agent/agent/lib/slack-invite.ts
  • apps/agent/agent/lib/run-runtime.ts
  • apps/agent/test/slack-invite.integration.spec.ts
  • apps/agent/test/slack-channel-actions.integration.spec.ts
  • apps/api/src/agent/agents.contracts.ts
  • apps/api/src/agent/agent-runs.service.ts
  • apps/app/components/agent-builder/agent-history.tsx

How to verify

  1. Apply migrations (bun run db:deploy / bun run db:test).
  2. Invite an address Slack does not know. The AgentAction row stores result.invite_id and result.url. externalId is the invite id.
  3. Replay the same tool call. The tool result still includes invite_id and url.
  4. Open the run timeline. The invite URL is the receipt, as a link.

Tests (actual)

cd packages/validation && bun test test/agent-action.spec.ts
7 pass, 0 fail
cd apps/agent && CRM_TELEMETRY_DISABLED=1 bun test test/slack-invite.integration.spec.ts test/slack-channel-actions.integration.spec.ts
14 pass, 0 fail, 30 expect() calls
bun run lint:slop
oxlint exit 0

check-types passed for @crm/validation, agent, api, app, and @crm/db.

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/api/src/agent/agent-trigger.service.ts Outdated
Comment thread apps/agent/agent/lib/slack-events.ts Outdated
Comment thread apps/agent/agent/lib/slack-membership.ts
Comment thread apps/agent/test/custom-agent-runtime.spec.ts Outdated
Comment thread apps/agent/agent/subagents/agent_builder/lib/draft-input.ts
Comment thread apps/agent/agent/subagents/agent_builder/lib/draft-input.ts
Comment thread apps/api/src/agent/agent-runs.service.ts Outdated
Comment thread apps/api/src/slack/slack-events.controller.ts Outdated
Comment thread packages/auth/src/slack-scopes.ts
Comment thread apps/agent/test/slack-events.integration.spec.ts
@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Tex first pass fail (localhost :3000 slug crm)

Drilled Customer Onboarding on local, not trycrm.ai.

What I ran

  • Connect invite through inviteToRunSlackChannel on channel C0BT779MMJT (address Slack did not know).
  • Same callId again (replay).
  • Member invite of a workspace user.

Result

Case persisted invite_id url replay
Connect yes, SUCCEEDED yes (I0BT7FVJPFH) missing same invite_id
Member yes, SUCCEEDED none (expected) none n/a

Slack conversations.inviteShared defaults external_limited=true, which omits url. inviteGuest posts { channel, emails } and never sets external_limited: false. So the provider reply has no url to persist. Unit tests mock a url Slack did not send.

UI

apps/app/components/agent-builder/agent-history.tsx actionReceipt:

  • links only when result.url exists
  • otherwise dumps externalId

Live SSR/history for the new run: invite id in the payload, no join.slack.com / result.url. Row cannot show invite_id and a clickable url.

Old Tripwire invite rows still have result: null (pre-persist). Receipt is the channel id.

Done looks like

  1. Connect invite stores invite_id and a url (likely external_limited: false on inviteShared, unless Slack still withholds it).
  2. Timeline row shows both: invite_id text and a clickable url.
  3. Member invite still succeeds with no url.
  4. Replay returns the same ids.

Mac Chrome screenshot of the live page was blocked from this environment (localhost is on the laptop; box desktop cannot see it). Evidence is live REST + SSR, not a pixel shot. Put before/after on this PR once the row actually renders both.

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.
@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Tex retest pass (bc1a4b3)

Inbox-lease migration already applied locally.

CRM-13 path still holds on a new Connect invite: invite_id I0BTQS5M00H + join.slack.com url, replay matched, member invite no url. History REST 200 with both rows.

Reed list, as drilled:

  • fire-deal-closed.ts gone
  • raw Slack POST over 64kb: PayloadTooLargeError (Nest surfaced 500, body was not kept)
  • small Slack POST: 401 verify, as expected
  • drain claims via leasedUntil before resume (code + migration)
  • inbox insert rethrows unless P2002
  • bad result maps to null in history

:3000 was down this pass, so no live app screenshot. API was started only for the webhook/history checks, then stopped.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 16 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread apps/api/test/slack-events.spec.ts Outdated
Comment thread apps/api/src/agent/agent-runs.service.ts Outdated
@ripgrim

ripgrim commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Tex post-copy pass (40d08e8)

New Connect invite stored summary Invited 1 person, invite_id I0BTM2FKCVC, and a join.slack.com url.

Timeline receipt in actionReceipt: invite id, then a Link whose text is Invite link and whose href is still the url. No url still shows just the invite id.

History REST 200. Agent page SSR includes Invited 1 person. Old rows still have stored Invite 1 to C0… summaries (not rewritten). Invite link is client-rendered on expand, so it is not in the initial HTML.

Cal strings for errors, transcript, and builder grants are in the diff.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 16 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread apps/agent/agent/lib/run-runtime.ts
Comment thread apps/api/test/slack-events.spec.ts
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/agent/agent/skills/customer-onboarding.md">

<violation number="1" location="apps/agent/agent/skills/customer-onboarding.md:56">
P2: On a replay, `invite_to_slack_channel` omits `invited` and `refused`, so this instruction to read every refused reason cannot be followed after a mixed-outcome call. Persist the full outcomes or qualify this guidance for fresh calls.</violation>

<violation number="2" location="apps/agent/agent/skills/customer-onboarding.md:62">
P2: When Slack is disconnected, no Slack refusal occurred, yet this says every outright failure means Slack refused every address. Describe the failure as no address being invited and direct the agent to read the error reason, since channel, input, and connection failures need different handling.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread apps/agent/agent/skills/customer-onboarding.md Outdated
Comment thread apps/agent/agent/skills/customer-onboarding.md
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and no new issues found across 3 files (changes from recent commits).

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread scripts/slack-tunnel.sh Outdated
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.
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