Skip to content

feat(agentex): Slack gateway — invoke agents from Slack - #388

Merged
michael-chou359 merged 10 commits into
mainfrom
mc/event-driven-agents
Aug 4, 2026
Merged

feat(agentex): Slack gateway — invoke agents from Slack#388
michael-chou359 merged 10 commits into
mainfrom
mc/event-driven-agents

Conversation

@michael-chou359

@michael-chou359 michael-chou359 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

A platform-side Slack gateway so agents can be invoked from Slack: @agent <selector> … in a channel (or DM) runs a turn against the resolved agent/config and posts the reply back in-thread. A message with no matching selector goes to a default golden-agent config.

How it works

Ingress — HTTP (Events API): POST /slack/events (events + the url_verification handshake) and POST /slack/commands (slash commands). /slack is whitelisted from SGP auth; the Slack signature is the auth (HMAC-SHA256 over the raw body against the app's signing secret). Requests are ack'd fast (2xx within Slack's ~3s budget) and the turn runs out-of-band. Duplicate deliveries are dropped via Redis event_id dedup (the Events API is at-least-once).

Dispatch: normalize → resolve the target → task/create (get-or-create keyed on the Slack thread slack:{thread_ts}) + event/send → poll for the settled reply → post it back. Mirrors the Scheduled Agent Runs pattern.

Selector resolution cascade (@agent <selector>):

  1. an SGP agent_config by that name → golden-agent + that config_id;
  2. a registered agentex agent by that name → route to that runtime;
  3. neither (or no selector) → golden-agent + the default config_id.

Config names are resolved to ids at first-turn time via SGP's directory (GET {SLACK_GATEWAY_SGP_BASE_URL}/v5/agent_configs?name=), authenticated with the gateway's acting identity and cached per (account, name). golden-agent resolves whatever config_id it's handed into the full turn config (prompt/model/harness/tools) — raw prompt inputs are no longer passed.

/agents slash command lists the READY agents that can be invoked.

Credentials & identity: the Slack bot token + signing secret and a shared acting-user identity (API key + account) are read from agent_api_keys (DB-first, env fallback). The acting identity is resolved once per turn and threaded into both the SGP config lookup and dispatch. build_acp_use_case_for_principal now accepts request_headers, so the acting-user key is forwarded downstream as x-acting-user-api-key (delegation), leaving the default scheduled-run behavior unchanged.

Robustness

  • Reply polling reads the newest page (DESC) so a long thread (>200 messages) still sees the current turn's reply.
  • A concurrent first-turn task/create race on the globally-unique thread name is caught (DuplicateItemError) and falls back to the winner's task instead of dropping the turn.

Scope / v1 notes

  • Every turn runs as one shared SGP identity; per-user Slack→SGP linking is deferred (design sketched in the module docstring).
  • The credential store reuses agent_api_keys as a throwaway; a proper secrets home is a follow-up.
  • HTTP ingress requires a publicly-reachable deployment; the dev / pubsec-dev environments are internet-facing, so webhooks reach them.

Testing

  • Unit tests for signature verification, event normalization, the selector cascade, SGP name→config_id resolution (+ cache), dispatch (create/resume, config passing, create-race), event_id dedup, reply collection, delivery, and DB-first credential/acting-identity reads — all green, lint clean.
  • Validated end to end locally: a signed app_mention → dispatch → golden-agent turn → reply posted back into the Slack thread.

🤖 Generated with Claude Code

Adds a platform-side Slack gateway that fronts one Slack app and routes each
turn to the resolved agent runtime (`@agent <name>`, else a default agent).

- SlackGatewayUseCase: verify signature (HTTP) or trust the socket (Socket Mode)
  -> normalize -> resolve target -> dispatch (task/create-or-resume + event/send,
  acting as a shared v1 identity) -> collect the reply -> deliver it back.
- Ingress, two forms:
  - HTTP: POST /slack/events (Events API + url_verification) and
    POST /slack/commands (slash commands).
  - Socket Mode worker (src/slack/socket_worker.py): an always-on outbound
    WebSocket, so it works from a network-restricted deployment with no inbound
    endpoint. Hardened with event_id dedup (at-least-once), a /healthz liveness
    endpoint, and graceful shutdown. Dev bridge: scripts/slack_socket_dev.py.
- /agents slash command lists the READY agents that can be invoked.
- Credentials (bot/app tokens, signing secret) and the shared acting-user
  identity are read from agent_api_keys (DB-first), with env fallback.
- build_acp_use_case_for_principal accepts request_headers so the gateway's
  shared identity is forwarded downstream as x-acting-user-api-key.
- /slack whitelisted from auth (the Slack signature is the auth). Adds slack_sdk.

v1 runs every turn as one shared SGP identity; per-user Slack->SGP linking is
deferred. Unit tests cover the gateway and the socket worker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@michael-chou359
michael-chou359 requested a review from a team as a code owner July 31, 2026 08:05
Comment thread agentex/src/slack/socket_worker.py Outdated
Comment thread agentex/src/domain/use_cases/slack_gateway_use_case.py Outdated
Comment thread agentex/src/domain/use_cases/slack_gateway_use_case.py Outdated
michael-chou359 and others added 2 commits July 31, 2026 01:26
Socket Mode is the only viable ingress for the network-restricted
deployment (no public inbound endpoint), so remove the HTTP path
entirely: the POST /slack/events + /slack/commands routes, the Slack
signature verification, the signing-secret DB lookup, and the
handle_slack_event use-case method. Slash commands now dispatch straight
from the socket payload (the socket itself is authenticated).

Slack ingress is now Socket Mode only (src/slack/socket_worker.py in
prod, scripts/slack_socket_dev.py locally). Regenerated openapi.yaml and
pruned the corresponding tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te race

Two dispatch-path bugs surfaced in review:

- Reply polling fetched page 1 ascending, so once a thread's task passed
  the page size it only ever read the OLDEST messages and never saw the
  current turn's reply (120s wait then a spurious "no reply"). Fetch the
  newest page DESC and reverse to chronological before joining, via a
  shared _recent_messages helper used by both the pre-turn snapshot and
  the poll so their windows always align.

- Two concurrent first events for the same thread both saw the task
  absent and raced TASK_CREATE on the globally-unique name; the loser's
  DuplicateItemError dropped its turn. Catch it and fall back to the
  winner's task (the DB insert fails before any workflow starts), then
  send the event as a follow-up would.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread agentex/src/domain/use_cases/slack_gateway_use_case.py
michael-chou359 and others added 3 commits August 3, 2026 09:33
This reverts commit d9de71f, restoring the HTTP webhook ingress
(POST /slack/events + /slack/commands, Slack signature verification, the
signing-secret DB lookup, and the handle_slack_event use-case method).

The dev and pubsec-dev deployments are publicly reachable (internet-facing
ELBs, confirmed via public DNS + an external fetch), so an inbound Slack
webhook can reach them and pass URL verification — the HTTP path is viable
after all. The dispatch-core fixes from the follow-up commit (reply polling
DESC + the first-turn DuplicateItemError create-race fallback) are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HTTP webhooks reach the public dev / pubsec-dev deployments, so Socket
Mode is no longer needed as an ingress. Remove the production Socket Mode
worker, the local dev bridge, their tests, and the slack_sdk dependency
they pulled in. The Slack gateway is now HTTP-only (POST /slack/events +
/slack/commands); the shared dispatch core is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	agentex/src/api/routes/slack.py
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

✱ Stainless preview builds

This PR will update the agentex-sdk SDKs with the following commit messages.

openapi

feat(api): add commands endpoint to slack

python

chore(internal): regenerate SDK with no functional changes

typescript

chore(internal): regenerate SDK with no functional changes
agentex-sdk-openapi studio · code

Your SDK build had at least one "note" diagnostic.
generate ✅

⚠️ agentex-sdk-typescript studio · code

Your SDK build had at least one "warning" diagnostic.
generate ⚠️build ⏭️lint ⏭️test ✅

⚠️ agentex-sdk-python studio · code

Your SDK build had at least one "warning" diagnostic.
generate ⚠️build ⏭️lint ⏭️test ✅


This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-08-04 00:30:04 UTC

Comment thread agentex/src/domain/use_cases/slack_gateway_use_case.py
michael-chou359 and others added 4 commits August 3, 2026 10:26
Slack's Events API is at-least-once — it retries a delivery (up to ~3x,
X-Slack-Retry-Num) if we don't 200 within ~3s. handle_slack_event now
skips an event_id it has already seen, via a Redis SET NX with a short
TTL (fail-open: no id / no Redis / any Redis error still processes the
turn). Ports the dedup the removed Socket Mode worker had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
golden-agent now resolves its full turn config (system prompt / model /
harness / tools) from an agent_config id rather than a raw system_prompt.
Pass a default config_id as the first-turn task param (overridable via
SLACK_GATEWAY_DEFAULT_CONFIG_ID; a routed agent's own target.config_id
takes precedence), replacing the baked-in _DEFAULT_SYSTEM_PROMPT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… SGP

Reference the default agent_config by NAME (SLACK_GATEWAY_DEFAULT_CONFIG_NAME)
and resolve it to a config_id at first-turn time against SGP's directory
(GET {SLACK_GATEWAY_SGP_BASE_URL}/v5/agent_configs?name=), authenticated with
the gateway's acting identity. Names are stable across config re-creation,
unlike the UUID.

Precedence: a routed agent's own target.config_id > the default resolved by
name > the fixed SLACK_GATEWAY_DEFAULT_CONFIG_ID fallback (used when SGP
resolution isn't configured/reachable, e.g. local dev with no acting identity).
Resolved ids are cached per (account, name) for the process lifetime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructure _resolve_target into a single cascade on the @agent <selector>
token:
  1. an SGP agent_config with that name -> golden-agent + that config_id
  2. a registered agentex agent with that name -> route to that runtime
  3. neither (or no selector) -> golden-agent + the default config_id

The acting identity is now resolved once per turn in _run_turn and threaded
into both target resolution (to authenticate the SGP lookup) and dispatch
(principal + delegation), replacing the separate _effective_config_id step.
Target.label() no longer renders the config_id, so the raw UUID stays out of
Slack attribution (it's still recorded in task_metadata).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@michael-chou359
michael-chou359 merged commit 8043a3b into main Aug 4, 2026
48 checks passed
@michael-chou359
michael-chou359 deleted the mc/event-driven-agents branch August 4, 2026 00:27
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