Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions managed_agents/slack/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Anthropic
ANTHROPIC_API_KEY=sk-ant-...

# From `bun run setup`
CLAUDE_AGENT_ID=agent_...
CLAUDE_ENVIRONMENT_ID=env_...

# From Anthropic Console → Manage → Webhooks → your endpoint
ANTHROPIC_WEBHOOK_SIGNING_KEY=whsec_...

# From Slack app → Basic Information → App Credentials
SLACK_SIGNING_SECRET=...

# From Slack app → OAuth & Permissions → Bot User OAuth Token
SLACK_BOT_TOKEN=xoxb-...

# Public HTTPS URL for this server (ngrok/cloudflared locally, real host in prod)
PORT=3000
BASE_URL=https://your-tunnel.example.com
4 changes: 4 additions & 0 deletions managed_agents/slack/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.env
.env.local
bun.lock
19 changes: 19 additions & 0 deletions managed_agents/slack/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Slack × Claude Managed Agents bridge

Stateless webhook bridge: Slack `app_mention` → CMA session (with routing metadata) → `session.status_idled` webhook → `chat.postMessage` in-thread.

## When the user asks to set this up, get it working, or debug it

1. **Invoke `/claude-api` first.** That skill loads the full Managed Agents API reference (agents, sessions, environments, events, webhooks, outcomes, multiagent, vaults, memory stores). Use it as the source of truth for any SDK call you write or edit — don't guess field names.
2. **Read `./skill.md`** and walk the user through it step by step. It has the ordered checklist, every gotcha (scope vs event-subscription, `xoxb` vs `xapp`, workspace-scoped webhooks, retrieve-then-filter), and the debugging table.
3. **After the base bridge works, offer extensions.** Ask the user which (if any) they want, then edit `setup/create-agent.ts` and/or `src/agent.ts` accordingly:
- **GitHub repo** — mount a repo into the session container (`resources: [{type: "github_repository", ...}]` on `sessions.create`)
- **MCP tools** — e.g. Slack or GitHub MCP so the agent can act, not just reply (`mcp_servers` + `mcp_toolset` on the agent; credentials via a vault, `vault_ids` on the session)
- **Outcomes** — rubric-graded iterate loop (`user.define_outcome` event instead of `user.message`)
- **Multiagent** — coordinator + subagent roster (`multiagent: {type: "coordinator", agents: [...]}` on the agent)
- **Memory store** — cross-session persistence (`resources: [{type: "memory_store", ...}]`)
- **Custom tools** — host-side execution via `agent.custom_tool_use` / `user.custom_tool_result`

Pull exact shapes from the `/claude-api` skill's `shared/managed-agents-*.md` docs.

Run the server with `bun run dev`. One-time agent provisioning is `bun run setup`.
38 changes: 38 additions & 0 deletions managed_agents/slack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Slack × Claude Managed Agents

`@mention` a Claude [Managed Agent](https://platform.claude.com/docs/en/managed-agents/overview) in Slack and get the reply in-thread.

```
Slack @mention ──▶ /slack/events ──▶ sessions.create (+ metadata) ──▶ 200
Claude runs to idle on Anthropic infra
/cma-webhook ◀── session.status_idled ◀───────┘
└──▶ sessions.retrieve → read metadata → chat.postMessage
```

The CMA session's `metadata` (`slack_channel`, `slack_thread_ts`) is the entire routing state.

## Quickstart

```bash
cd managed_agents/slack
bun install
claude
```

Then ask: **"walk me through setting this up."** Claude reads [`skill.md`](./skill.md) and drives the config — Slack app, Anthropic agent + webhook, env vars, `bun run dev` — in the order that actually works.

## Files

| | |
|---|---|
| `setup/create-agent.ts` | One-time: `agents.create` + `environments.create` |
| `src/main.ts` | Bun server, routes |
| `src/slack-events.ts` | Verify Slack sig, `url_verification`, fire-and-forget kickoff |
| `src/agent.ts` | `sessions.create` + `user.message` with routing metadata |
| `src/cma-webhook.ts` | `beta.webhooks.unwrap` → filter by metadata → `chat.postMessage` |
| `skill.md` | Setup walkthrough, gotchas, debugging |

Requires `@anthropic-ai/sdk` ≥ 0.95.1.
19 changes: 19 additions & 0 deletions managed_agents/slack/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "slack-cma-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/main.ts",
"start": "bun run src/main.ts",
"setup": "bun run setup/create-agent.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.95.1",
"@slack/web-api": "^7.14.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5"
}
}
21 changes: 21 additions & 0 deletions managed_agents/slack/setup/create-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// One-time: create the Claude agent + environment. Copy the printed IDs into .env.local.
import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic();

const env = await anthropic.beta.environments.create({
name: `slack-bridge-${Date.now()}`,
config: { type: "cloud", networking: { type: "unrestricted" } },
});

const agent = await anthropic.beta.agents.create({
name: "Slack Assistant",
model: "claude-opus-4-7",
system:
"You are a helpful assistant embedded in Slack. Keep replies concise and conversational — they are posted as thread replies. Use plain text or Slack mrkdwn (e.g. *bold*, `code`); avoid Markdown headers.",
tools: [{ type: "agent_toolset_20260401", default_config: { enabled: true } }],
});

console.log("\nAdd to .env.local:");
console.log(`CLAUDE_ENVIRONMENT_ID=${env.id}`);
console.log(`CLAUDE_AGENT_ID=${agent.id}`);
105 changes: 105 additions & 0 deletions managed_agents/slack/skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Setup tips & tricks — Slack × CMA webhook bridge

Things that aren't obvious from the docs and tend to cost debugging time.

---

## Mental model

### Two webhooks, one bridge

- **Slack → bridge** (`/slack/events`) fires on @mention. Payload carries `channel`, `ts`, `thread_ts`, `user`, `text`.
- **Anthropic → bridge** (`/cma-webhook`) fires on session idle. Payload carries **only** the CMA session ID.

Neither carries the agent's output. Both are just *signals* with IDs.

### The webhook is a doorbell, not a delivery

Anthropic's `session.status_idled` payload is deliberately thin — `{type, id}`. You follow up with `sessions.retrieve(id)` (metadata) and `sessions.events.list(id)` (output). Push the signal, pull the data.

### `metadata` is the entire routing state

On kickoff, set `metadata: {slack_channel, slack_thread_ts, slack_team}`. When the idle webhook arrives later with only a session ID, retrieve the session, read those keys back, and `chat.postMessage` to exactly the right thread — with nothing stored in the bridge itself. This is what makes it stateless.

### Slack's 3-second ack window

Slack retries any event that doesn't get a 2xx within 3 seconds. The bridge **must** return before the CMA session finishes. So: verify the signature, dedupe on `event_id`, fire `kickoffAgentSession()` without awaiting it, and return 204 immediately.

---

## Gotchas

### Use a developer sandbox, not your company workspace

Slack's [Developer Program sandboxes](https://api.slack.com/developer-program/sandboxes) give you a free Enterprise Grid org to test in — admin rights, fake users/channels, no risk of installing a half-built bot into production. This is **not** the "agent quickstart" — it's **Developer Program → Sandboxes**:

1. Join the [Slack Developer Program](https://api.slack.com/developer-program) → activate via email → accept ToS.
2. From the program dashboard: **Provision Sandbox** → pick empty or pre-loaded with fake users/channels → click again.
3. Finish setup from the email invite — you become Primary Org Owner; name the org and create at least one workspace inside it.
4. Create your app against that sandbox workspace and develop normally.

### OAuth scope ≠ event subscription

Adding `app_mentions:read` under **OAuth & Permissions → Bot Token Scopes** grants *permission* to see mentions. It does **not** cause Slack to deliver them. You must *also* go to **Event Subscriptions**, toggle it **On**, set the Request URL, and add `app_mention` under "Subscribe to bot events." Two separate pages. Miss the second one and you get zero deliveries with no error anywhere.

### `xoxb-` vs `xapp-` — two tokens, two pages, easy to grab the wrong one

| Token | Prefix | Page | Used for |
|---|---|---|---|
| Bot User OAuth Token | `xoxb-` | OAuth & Permissions (after install) | `chat.postMessage` — **this is the one you want** |
| App-Level Token | `xapp-` | Basic Information → App-Level Tokens | Socket Mode WebSocket only |

`chat.postMessage` with an `xapp-` token fails with `invalid_auth`. The `xoxb-` token only exists after you've added at least one bot scope **and** clicked Install/Reinstall to Workspace.

### Bridge must be running before you save the Request URL

Saving the Event Subscriptions URL triggers an immediate `url_verification` POST. If nothing is listening on the tunnel you get "Your URL didn't respond" and the URL won't save. Start `bun run dev` first, *then* paste the URL.

### Anthropic webhooks are workspace-scoped

The endpoint you register in Console only receives events for sessions **in that same workspace**. If your `ANTHROPIC_API_KEY` is from workspace A but you registered the endpoint in workspace B: zero deliveries, silently. Match the workspace picker on the Webhooks page to the Workspace column on your API key.

### A workspace webhook fires for *every* session in the workspace

Not just yours. If the Anthropic workspace is shared with other agents, scripts, or teammates, **every** `session.status_idled` in that workspace hits your endpoint. Your handler has to filter:

- **Retrieve-then-filter, always first.** `sessions.retrieve(id)` → check for your `slack_channel` metadata key → bail with 204 if absent. Do this *before* `events.list()` or any other work; otherwise unrelated sessions throw 404 deeper in the handler.
- **Catch 404/403 on `sessions.retrieve`** — sessions created under other API keys in the same workspace aren't readable by yours.
- **For production, use a dedicated Anthropic workspace.** Each unrelated session costs one `retrieve()` call just to discard it; a workspace that only contains this agent's sessions avoids that entirely.

### `unwrap()` needs a plain header map

`client.beta.webhooks.unwrap(body, {headers})` (SDK ≥ 0.95.1) wants `Record<string, string>`, not a fetch `Headers` object. Pass `Object.fromEntries(req.headers)`.

### `event.id` is your idempotency key

Anthropic retries failed deliveries with the **same** top-level `event.id`. Slack retries with the same `event_id` inside the body. Dedupe on both. Return 2xx once you've either handled or ignored the event — anything else triggers a retry, and ~20 consecutive Anthropic failures auto-disables your endpoint.

---

## Local dev checklist

1. `ngrok http 3000` → note the public URL.
2. `bun run setup` → copy `CLAUDE_AGENT_ID` / `CLAUDE_ENVIRONMENT_ID` into `.env.local`. **Don't overwrite them later** when you paste in the Slack secrets.
3. Slack app → **OAuth & Permissions** → Bot Token Scopes: `app_mentions:read`, `chat:write` (+ `im:history` for DMs) → **Install to Workspace** → copy `xoxb-…` → `SLACK_BOT_TOKEN`.
4. Slack app → **Basic Information** → copy **Signing Secret** → `SLACK_SIGNING_SECRET`.
5. Anthropic Console → **Manage → Webhooks**: `<url>/cma-webhook`, subscribe `session.status_idled` + `session.status_terminated` → copy `whsec_…` → `ANTHROPIC_WEBHOOK_SIGNING_KEY`. **Same workspace as your API key.**
6. `bun run dev` — server must be up *before* step 7.
7. Slack app → **Event Subscriptions** → On → Request URL `<url>/slack/events` → Verified ✓ → add bot event `app_mention` → **Save Changes** → reinstall if prompted.
8. In Slack: `/invite @your-bot` to a channel, then `@your-bot hello`.

### Debugging a silent failure

- **Nothing in the bridge log at all** → Slack isn't reaching you. Check `curl localhost:4040/api/requests/http` (ngrok's request log). No `/slack/events` POSTs = Event Subscriptions not saved, or Socket Mode is on.
- **`[agent] kickoff` logged but no reply** → ngrok log shows `/cma-webhook` POSTs? If none: Anthropic workspace mismatch or endpoint not saved. If 401: `ANTHROPIC_WEBHOOK_SIGNING_KEY` mismatch. If 204 but no Slack post: `SLACK_BOT_TOKEN` is wrong (check for `xapp-`) or missing `chat:write` scope.
- **`400 Invalid agent ID`** → `.env.local` still has the `agent_...` placeholder. Re-paste the real ID from `bun run setup`.
- **`not_in_channel`** from `chat.postMessage` → `/invite @your-bot` to the channel first.

---

## Production notes

- Replace ngrok with a real deploy. Nothing else changes.
- Replace the in-memory `seenEventIds` Sets with Redis/DB for multi-instance idempotency.
- For a distributed (multi-workspace) Slack app, swap the static `SLACK_BOT_TOKEN` for a per-team store keyed on `metadata.slack_team`; add the Slack OAuth flow.
- Each @mention starts a fresh CMA session (no memory across turns). For threaded conversations, cache `thread_ts → session_id` and send follow-ups via `sessions.events.send` instead of `sessions.create`.
45 changes: 45 additions & 0 deletions managed_agents/slack/src/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic();

const CLAUDE_AGENT_ID = process.env.CLAUDE_AGENT_ID!;
const CLAUDE_ENVIRONMENT_ID = process.env.CLAUDE_ENVIRONMENT_ID!;

export interface SlackMention {
channel: string;
thread_ts: string;
user: string;
text: string;
team: string;
}

// Fire-and-forget: create the CMA session, attach routing metadata, send the
// prompt, return. The reply path is handled in cma-webhook.ts when Anthropic
// POSTs `session.status_idled`.
export async function kickoffAgentSession(m: SlackMention) {
// Stash the Slack routing info on the CMA session. The idle webhook later
// delivers only a session ID; we read this metadata back to know where to
// post the reply.
const session = await anthropic.beta.sessions.create({
agent: CLAUDE_AGENT_ID,
environment_id: CLAUDE_ENVIRONMENT_ID,
metadata: {
slack_channel: m.channel,
slack_thread_ts: m.thread_ts,
slack_team: m.team,
},
});

await anthropic.beta.sessions.events.send(session.id, {
events: [
{
type: "user.message",
content: [{ type: "text", text: m.text || "Hello! How can I help?" }],
},
],
});

console.log(
`[agent] kickoff slack=${m.channel}/${m.thread_ts} claude=${session.id}`,
);
}
78 changes: 78 additions & 0 deletions managed_agents/slack/src/cma-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Anthropic from "@anthropic-ai/sdk";
import { WebClient } from "@slack/web-api";

const anthropic = new Anthropic();
const slack = new WebClient(process.env.SLACK_BOT_TOKEN);

// Dedupe retries (same event.id across retries). Swap for Redis/DB in prod.
const seenEventIds = new Set<string>();

export async function handleCmaWebhook(req: Request): Promise<Response> {
const rawBody = await req.text();

// Verify HMAC + timestamp and parse. Reads ANTHROPIC_WEBHOOK_SIGNING_KEY
// from env. unwrap() needs a plain header map, not a fetch Headers object.
let event: Anthropic.Beta.BetaWebhookEvent;
try {
event = anthropic.beta.webhooks.unwrap(rawBody, {
headers: Object.fromEntries(req.headers),
});
} catch (err) {
console.warn("[cma-webhook] signature verification failed");
return new Response("bad signature", { status: 401 });
}

if (seenEventIds.has(event.id)) return new Response(null, { status: 204 });
seenEventIds.add(event.id);

if (
event.data.type !== "session.status_idled" &&
event.data.type !== "session.status_terminated"
) {
return new Response(null, { status: 204 });
}

const claudeSessionId = event.data.id;

// Workspace webhooks fire for EVERY session in the workspace. Fetch the
// session and filter by our metadata FIRST; ignore anything that isn't ours
// (including sessions our key can't read).
let session;
try {
session = await anthropic.beta.sessions.retrieve(claudeSessionId);
} catch {
return new Response(null, { status: 204 });
}

const channel = session.metadata?.slack_channel;
const thread_ts = session.metadata?.slack_thread_ts;
if (!channel || !thread_ts) {
return new Response(null, { status: 204 });
}

if (event.data.type === "session.status_terminated") {
await slack.chat.postMessage({
channel,
thread_ts,
text: ":warning: Agent session terminated unexpectedly.",
});
return new Response(null, { status: 204 });
}

// Pull the agent's reply text from the event history. Iterating the page
// object auto-paginates.
const parts: string[] = [];
for await (const e of anthropic.beta.sessions.events.list(claudeSessionId)) {
if (e.type === "agent.message") {
for (const block of e.content ?? []) {
if (block.type === "text") parts.push(block.text);
}
}
}
const responseText = parts.join("").trim();
if (!responseText) return new Response(null, { status: 204 });

await slack.chat.postMessage({ channel, thread_ts, text: responseText });
console.log(`[cma-webhook] posted reply slack=${channel}/${thread_ts} claude=${claudeSessionId}`);
return new Response(null, { status: 204 });
}
42 changes: 42 additions & 0 deletions managed_agents/slack/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { handleSlackEvents } from "./slack-events";
import { handleCmaWebhook } from "./cma-webhook";

const PORT = Number(process.env.PORT) || 3000;
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;

for (const v of [
"SLACK_SIGNING_SECRET",
"SLACK_BOT_TOKEN",
"ANTHROPIC_WEBHOOK_SIGNING_KEY",
"CLAUDE_AGENT_ID",
"CLAUDE_ENVIRONMENT_ID",
]) {
if (!process.env[v]) {
console.error(`FATAL: ${v} is required`);
process.exit(1);
}
}

Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);

if (url.pathname === "/" && req.method === "GET") {
return Response.json({ status: "ok" });
}
// Slack → us (user @mentioned or DM'd the bot)
if (url.pathname === "/slack/events" && req.method === "POST") {
return handleSlackEvents(req);
}
// Anthropic → us (Claude session idled / terminated)
if (url.pathname === "/cma-webhook" && req.method === "POST") {
return handleCmaWebhook(req);
}
return new Response("Not Found", { status: 404 });
},
});

console.log(`Bridge running at ${BASE_URL}`);
console.log(` Slack events: ${BASE_URL}/slack/events`);
console.log(` CMA webhook: ${BASE_URL}/cma-webhook`);
Loading
Loading