feat(agentex): Slack gateway — invoke agents from Slack - #388
Merged
Conversation
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>
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>
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
✱ Stainless preview buildsThis PR will update the openapi python typescript
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 + theurl_verificationhandshake) andPOST /slack/commands(slash commands)./slackis 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 Redisevent_iddedup (the Events API is at-least-once).Dispatch: normalize → resolve the target →
task/create(get-or-create keyed on the Slack threadslack:{thread_ts}) +event/send→ poll for the settled reply → post it back. Mirrors the Scheduled Agent Runs pattern.Selector resolution cascade (
@agent <selector>):config_id;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 whateverconfig_idit's handed into the full turn config (prompt/model/harness/tools) — raw prompt inputs are no longer passed./agentsslash 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_principalnow acceptsrequest_headers, so the acting-user key is forwarded downstream asx-acting-user-api-key(delegation), leaving the default scheduled-run behavior unchanged.Robustness
task/createrace 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
agent_api_keysas a throwaway; a proper secrets home is a follow-up.Testing
event_iddedup, reply collection, delivery, and DB-first credential/acting-identity reads — all green, lint clean.app_mention→ dispatch → golden-agent turn → reply posted back into the Slack thread.🤖 Generated with Claude Code