Tutorial: Build a Slack Bot
Build a persistent Slack bot that responds to DMs and @mentions, gates dangerous tools with trust approval in the thread, and assigns different permissions based on who's talking. By the end you'll understand how Socket Mode connects without inbound ports, how trust prompts appear in Slack threads, how roles map identity to permissions, and how $global shares config across authority boundaries.
What You'll Build
A Slack bot that:
- Connects via Socket Mode (outbound only — no public URL needed)
- Responds to DMs and @mentions with AI-powered answers
- Prompts for approval in the thread before running dangerous tools
- Gives admins full access and restricts everyone else to read-only tools
Prerequisites
- Node.js 22+
- AWS credentials with Bedrock access — see AWS & Bedrock Setup
- A Slack workspace where you can create apps
Step 1: Create the Slack App
- Go to api.slack.com/apps → Create New App → From scratch
- Name it (e.g., "My Agent") and select your workspace
Enable Socket Mode
Socket Mode lets your bot connect to Slack without exposing a public URL. The connection is outbound — your bot calls Slack, not the other way around. This means it works behind firewalls, on laptops, in containers — anywhere with internet access.
- Settings → Socket Mode → Enable
- Create an app-level token with
connections:writescope - Save the token (starts with
xapp-)
Add Bot Scopes
Features → OAuth & Permissions → Bot Token Scopes:
app_mentions:read— see @mentionschat:write— send messagesim:history— read DM historyim:read— see DMsim:write— send DMs
Enable Events
Features → Event Subscriptions → Enable, subscribe to:
message.im— DM messagesapp_mention— @mentions in channels
Install to Workspace
Settings → Install App → Install. Save the Bot User OAuth Token (starts with xoxb-).
Amazon workspaces: Before installing, go to Settings → Collaborators and add
opus-amazon-prodas a collaborator. This is required for Slack apps in Amazon workspaces.
Step 2: Create the Project
mkdir slack-bot && cd slack-bot
agent-apps init
Update main.skill.md:
---
name: slack-bot
description: Slack bot with trust and roles
allowed-tools: "*"
metadata:
role: main
paths: [./skills]
model:
id: us.anthropic.claude-sonnet-4-6
region: us-east-1
persistent: true
slack:
app-token: xapp-1-YOUR-APP-TOKEN
bot-token: xoxb-YOUR-BOT-TOKEN
routes:
- ref: slack-bot
type: "*"
trust:
rules: "file-write code shell"
store: file
---
You are a helpful assistant. Respond to messages from your team.
Don't hardcode tokens. Use environment variables instead:
export AGENT_APPS_LOCAL_SLACK__APP_TOKEN=xapp-1-...
export AGENT_APPS_LOCAL_SLACK__BOT_TOKEN=xoxb-...
The double underscores (__) create nesting: SLACK__APP_TOKEN → { slack: { "app-token": "..." } }. Single underscores become hyphens.
Step 3: Run It
agent-apps
You'll see:
Slack connected: team
DM the bot or @mention it in a channel. The persistent: true model config keeps the agent running indefinitely.
You: What files are in the current directory?
Bot: ⠋ Thinking...
✓ file-list — Completed in 0.1s
Here's what's in the project directory:
- main.skill.md
- skills/
- package.json
How Socket Mode Works
Unlike webhooks (which require a public URL), Socket Mode establishes an outbound WebSocket connection from your bot to Slack's servers. Messages arrive over this connection in real time.
When a message arrives:
- The
slackmiddleware receives the event over the WebSocket. - It checks
allowed-users(if configured) to filter who can talk to the bot. - It sets up a gateway on
nonlocals.gatewaywith Slack-specific transport:{ name: "team", description: "Slack", send: "team-send-a1b2c3d4", // callback ref → posts to thread receive: "team-receive-e5f6g7h8", // callback ref → waits for reply id: "U01ABC123" } - It invokes the main skill (or a route-matched skill) with the message.
The send callback ref has the channel ID and thread timestamp baked in, so gateway-send replies in the same thread automatically. The agent doesn't know it's talking to Slack — it just uses the gateway.
Step 4: Trust in Action
When the agent tries to use file-write, code, or shell, the trust middleware intercepts:
You: Create a file called notes.txt with my meeting notes
Bot: 🔒 Tool requires approval: file-write
Args: {"path":"notes.txt","content":"Meeting notes..."}
Allow? [y/n/a/v]
You: a
Bot: ✓ file-write — Completed in 0.1s
Done! I've created notes.txt with your meeting notes.
The trust prompt appears as a message in the Slack thread. The user replies in the thread, and gateway-receive picks up the response. Trust decisions with store: file persist in .agent-apps/trust.json, so once you say "always" for a tool, it won't ask again.
This works because trust uses gateway-receive for the approval prompt, and the Slack middleware has already set up a gateway with the receive callback ref. The trust middleware doesn't know it's in Slack — it just asks the gateway.
Step 5: Add Roles
Different users should have different permissions. Admins get full tool access; everyone else gets read-only.
Update main.skill.md metadata to add roles:
metadata:
roles:
key: "nonlocals.gateway.id"
rules:
- match: "U01ABC*"
name: admins
metadata:
frontmatter: { allowed-tools: "*" }
trust: { rules: "#meta-internal($policy=allow) *", store: file }
- match: "*"
name: everyone
metadata:
frontmatter: { allowed-tools: "file-read file-list skill-list" }
How Roles Work
When a message arrives, the roles middleware:
- Reads the identity key:
pathGet(ctx, "nonlocals.gateway.id")→"U01ABC123"(the Slack user ID) - Matches against each entry's
matchpattern in order.U01ABC*is a glob — it matches any user ID starting withU01ABC. - For the first match, it injects all metadata keys from that entry as middleware. This means:
frontmatter: { allowed-tools: "*" }overrides the skill'sallowed-toolsfor this invocationtrust: { rules: "..." }overrides the trust rules for this invocation
For admins (U01ABC*): Full tool access. Internal tools (#meta-internal) are auto-approved, everything else prompts for trust.
For everyone else (*): Only file-read, file-list, and skill-list. They can ask the bot to read files and list skills, but can't write files, run shell commands, or use other tools.
The identity comes from nonlocals.gateway.id, which the Slack middleware sets to the Slack user ID. For email, it would be the sender address. For web, it could be a header or session value. The roles middleware doesn't care about the transport — it just reads the key.
Step 6: Route by Message Type
Instead of one skill handling everything, route DMs and @mentions to different handlers:
Create skills/support.skill.md:
---
name: support
description: Handle DM support questions
allowed-tools: "file-read skill-introspect"
---
You are a support agent. Answer the user's question helpfully. Use skill-introspect to look up framework documentation if needed.
Create skills/code-review.skill.md:
---
name: code-review
description: Review code shared in channels
allowed-tools: "file-read file-write"
---
Review the code the user shared. Provide constructive feedback on:
1. Correctness — does it do what it claims?
2. Style — does it follow conventions?
3. Edge cases — what could go wrong?
Keep feedback concise and actionable.
Then update the main skill's slack: config to route by message type:
metadata:
slack:
app-token: xapp-1-...
bot-token: xoxb-...
routes:
- ref: support
type: dm
- ref: code-review
type: thread
How Slack Routing Works
The slack middleware's routes: config dispatches messages to skills:
type: dm— matches direct messages onlytype: thread— matches threaded messages (including @mentions in channels)type: *— matches everything (or omittype)from: "U01ABC*"— optional sender filter (glob pattern on user ID)
DMs go to support. @mentions go to code-review. If neither matches, the main skill handles it.
The transport middleware enriches the skill's prompt with context about the message:
This skill handles Slack messages on profile "team".
Message type: dm
From: U01ABC123
Reply via gateway-send (appears as a thread reply).
What You Built
slack-bot/
main.skill.md ← Main skill: Slack + trust + roles config
skills/
support.skill.md ← DM handler
code-review.skill.md ← @mention handler
.agent-apps/
trust.json ← Persistent trust decisions
What You Learned
- Socket Mode connects outbound. No public URL needed. Works behind firewalls, on laptops, in containers.
- Trust prompts appear in the Slack thread. The trust middleware uses
gateway-receive, which the Slack middleware routes to thread replies. Users approve or deny by replying in the thread. - Roles map identity to permissions. The
rolesmiddleware readsnonlocals.gateway.id(Slack user ID), matches against glob patterns, and injects metadata overrides — differentallowed-toolsandtrustrules per user. $globalcrosses authority boundaries. Without it,model,trust, andmcpwouldn't reach the agent provider or hub packages.$globalmakes them universal.- Slack
routes:dispatches by message type. DMs, @mentions, and specific senders can each go to a different skill. First match wins. - Environment variables avoid hardcoded tokens.
AGENT_APPS_LOCAL_SLACK__APP_TOKEN→{ slack: { "app-token": "..." } }. - The agent code is transport-agnostic. The same
gateway-send/gateway-receivecalls work in CLI, Slack, and email. Only the channel config changes.
Next Steps
- Email Agent Tutorial — Same patterns with email transport
- Automation Tutorial — Webhooks, subagents, events, scheduling
- Cookbook — Trust — Trust rule syntax and patterns
- Cookbook — Roles — Role configuration details