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
1 change: 1 addition & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ updates:
- package-ecosystem: "pip"
directories:
- "/"
- "/claude-agent-sdk"
- "/openai-agents-sdk"
- "/pydantic-ai"
schedule:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
strategy:
matrix:
directory:
- claude-agent-sdk
- openai-agents-sdk
- pydantic-ai
defaults:
Expand Down Expand Up @@ -43,6 +44,7 @@ jobs:
strategy:
matrix:
directory:
- claude-agent-sdk
- openai-agents-sdk
- pydantic-ai
defaults:
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Guidance for coding agents working in this repository.

A monorepo for a **Coding Agent** sample for Slack, built with Bolt for Python. Each framework lives in its own directory as a standalone sample:

- `claude-agent-sdk/` — built with the **Claude Agent SDK**
- `openai-agents-sdk/` — built with the **OpenAI Agents SDK**
- `pydantic-ai/` — built with **Pydantic AI**

Expand All @@ -32,6 +33,7 @@ pytest

```
.github/ # Shared CI workflows and dependabot config
claude-agent-sdk/ # Claude Agent SDK implementation
openai-agents-sdk/ # OpenAI Agents SDK implementation
pydantic-ai/ # Pydantic AI implementation
```
Expand All @@ -53,8 +55,8 @@ The flow: a mention or direct message outside a code channel runs a small routin
Implementations share structure, not code, so these parts are kept the same by copying them and changing them everywhere at once:

- **The seam between `listeners/` and `agent/`.** Listeners import only from the `agent` package, never from the agent framework, so `listeners/` is the same in every implementation. `agent/__init__.py` provides `answer_request`, `find_session`, `run_turn`, `resume_turn`, `stop_turn`, and `end_session`.
- **Framework-free files.** `agent/turns.py`, `agent/context.py`, `agent/approvals.py`, `agent/sessions.py`, `agent/workspace.py`, `agent/outcome.py`, and `agent/prompts.py` don't import the agent framework. The framework lives in `agent/agent.py`, `agent/guardrails.py`, and `agent/tools/`. `turns.py` calls only `run_request(prompt, deps)`, which returns the reply text, and `start_run(history, text, deps)` and `resume_run(paused, approved, deps)`, which return the `RunOutcome` in `agent/outcome.py`. The paused run is a handle `turns.py` passes back without looking inside, so each framework's way of pausing fits behind it.
- **What the framework side keeps true.** `turns.py` relies on these, so every implementation keeps them: `create_code_channel` sets `deps.code_channel_id`, and `archive_channel` sets `session.archived`. Every code channel tool skips its call once `session.stopped` is set. A paused run's history already records the pending command as not run, so `turns.py` can drop a paused run without resuming it. Beyond that, `turns.py` only checks whether `history` is empty, to know a turn is the first.
- **Framework-free files.** `agent/turns.py`, `agent/context.py`, `agent/approvals.py`, `agent/sessions.py`, `agent/workspace.py`, `agent/outcome.py`, and `agent/prompts.py` don't import the agent framework. The framework lives in `agent/agent.py`, `agent/guardrails.py`, and `agent/tools/`, and the Claude Agent SDK adds `agent/runtime.py` for the deps its tools can't be passed. `turns.py` calls only `run_request(prompt, deps)`, which returns the reply text, and `start_run(history, text, deps)` and `resume_run(paused, approved, deps)`, which return the `RunOutcome` in `agent/outcome.py`. The paused run is a handle `turns.py` passes back without looking inside, so each framework's way of pausing fits behind it.
- **What the framework side keeps true.** `turns.py` relies on these, so every implementation keeps them: `create_code_channel` sets `deps.code_channel_id`, and `archive_channel` sets `session.archived`. Every code channel tool skips its call once `session.stopped` is set. `turns.py` can drop a paused run without resuming it, since the session's history then shows its pending command as not run: the history already records it that way, or the next run answers it that way before anything else. Beyond that, `turns.py` only checks whether `history` is empty, to know a turn is the first.
- **Prompts.** `agent/prompts.py` is the same word for word in every implementation.
- **Tools.** Every implementation offers the same tools with the same names, arguments, and descriptions, written with its framework's tool decorator. File tools stay inside the clone and can't write into `.git`.
- **Command approvals.** `command_needs_approval(command)` is plain Python and the same in every implementation, wrapped in the framework's own approval hook. Read-only commands and local git changes run without asking. Anything that runs the repository's code or reaches outside the clone asks first. Commands run without a shell, so the check and the run split a command the same way. On Windows, `.cmd` and `.bat` programs like `npm` are the exception, since Windows runs them through `cmd.exe`.
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ A sample Slack agent that showcases [Slack Code](https://docs.slack.dev/ai/slack

## Choose your framework

| App | Directory | Get started | Framework |
| --------------------- | -------------------- | -------------------------------------------- | --------------------------------------------------------------- |
| **OpenAI Agents SDK** | `openai-agents-sdk/` | [View README](./openai-agents-sdk/README.md) | [openai-agents](https://openai.github.io/openai-agents-python/) |
| **Pydantic AI** | `pydantic-ai/` | [View README](./pydantic-ai/README.md) | [pydantic-ai](https://pydantic.dev/docs/ai/overview/) |
| App | Directory | Get started | Framework |
| --------------------- | -------------------- | -------------------------------------------- | -------------------------------------------------------------------------- |
| **Claude Agent SDK** | `claude-agent-sdk/` | [View README](./claude-agent-sdk/README.md) | [claude-agent-sdk](https://platform.claude.com/docs/en/agent-sdk/overview) |
| **OpenAI Agents SDK** | `openai-agents-sdk/` | [View README](./openai-agents-sdk/README.md) | [openai-agents](https://openai.github.io/openai-agents-python/) |
| **Pydantic AI** | `pydantic-ai/` | [View README](./pydantic-ai/README.md) | [pydantic-ai](https://pydantic.dev/docs/ai/overview/) |

## What it can do

Expand Down
14 changes: 14 additions & 0 deletions claude-agent-sdk/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Required, set your Anthropic API key.
ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY

# Optional, uncomment and set when running without the Slack CLI (python3 app.py).
# SLACK_APP_TOKEN=YOUR_SLACK_APP_TOKEN
# SLACK_BOT_TOKEN=YOUR_SLACK_BOT_TOKEN

# Required for OAuth (app_oauth.py). Set your app's OAuth credentials.
# SLACK_CLIENT_ID=YOUR_SLACK_CLIENT_ID
# SLACK_CLIENT_SECRET=YOUR_SLACK_CLIENT_SECRET
# SLACK_SIGNING_SECRET=YOUR_SLACK_SIGNING_SECRET

# Optional, uncomment and set when using a custom Slack instance.
# SLACK_API_URL=YOUR_SLACK_API_URL
52 changes: 52 additions & 0 deletions claude-agent-sdk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Build
build/
dist/
docs/_sources/
docs/.doctrees

# Claude Code
.claude/*.local.json

# Coverage
.coverage
cov_*
coverage.xml

# Editors
.idea/

# Environment
.env*
!.env.sample
env*/
venv/
.venv*

# Logs
logs/

# macOS
.DS_Store

# OAuth
data/

# Python
__pycache__/
*.db
*.egg
*.egg-info/
*.eggs/
*.py[cod]
*.so
.mypy_cache/
.pytype/
.python-version
*~
pip

# Testing
.cache
.pytest_cache/
.tox
tmp.txt
2 changes: 2 additions & 0 deletions claude-agent-sdk/.slack/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
apps.dev.json
cache/
6 changes: 6 additions & 0 deletions claude-agent-sdk/.slack/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"manifest": {
"source": "local"
},
"project_id": "e609f4b0-5cdd-4023-8d3a-a5bd0de0f817"
}
5 changes: 5 additions & 0 deletions claude-agent-sdk/.slack/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"hooks": {
"get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks"
}
}
23 changes: 23 additions & 0 deletions claude-agent-sdk/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# AGENTS.md

Guidance for coding agents working in this directory. Refer to the root `../AGENTS.md` for repository-wide structure and commands.

## Claude Agent SDK specifics

**Agents (`agent/agent.py`)** are two sets of `ClaudeAgentOptions` with pinned models: a routing agent (`claude-haiku-4-5`) whose only tool is `create_code_channel`, and the coding agent (`claude-sonnet-5-5`) with every other tool. Tools are async `@tool` functions, one per file under `agent/tools/`, each with a JSON Schema for its arguments. They're served in-process by `create_sdk_mcp_server()`, so the model sees each as `mcp__coding-agent__<name>`. Both agents set `tools=[]`, `setting_sources=[]`, and `strict_mcp_config=True`, so they get none of Claude Code's built-in tools and none of the settings, `CLAUDE.md` files, or MCP servers on the machine. `verbatim_prompts=True` keeps an `@path` in a Slack message from reading a local file.

**Deps** reach the tools and hooks through `current_deps` in `agent/runtime.py`, a `ContextVar`, since the SDK calls a tool with only its arguments. `_run` in `agent/agent.py` sets it before the client starts, so the tasks the client starts see it.

**Runs** use a `ClaudeSDKClient` per run from async Bolt listeners, which runs the Claude Code CLI bundled with the SDK. Coding turns allow `MAX_TURNS` model calls, and a turn that uses them all (`error_max_turns`) ends with a message, keeping its history. A `PostToolUse` hook returns `continue_: False` to end the run early once there's nothing left to do: after `create_code_channel` succeeds for the routing agent, and after `archive_channel` for the coding agent. A result with `is_error` raises, so the turn posts a warning.

**Turns** go through `agent/turns.py`, which doesn't import the SDK. A turn that raises posts a warning and moves the session back to `active`, keeping the history from the last finished turn. Model-written replies use `markdown_text`, since the model writes standard markdown.

**Stopping** sets `session.stopped`, and so does archiving the channel from Slack. The `PreToolUse` hook from `gate_tool_calls` (`agent/guardrails.py`) then denies every tool call and tells the model to wrap up, and the turn posts nothing when it finishes.

**Conversation history** lives in the Claude Code session transcript. `session.history` is the list of each run's `ResultMessage`, and the next run resumes from the last one's `session_id`.

**Command approvals.** The `PreToolUse` hook wraps `command_needs_approval()` from `agent/approvals.py` and returns `defer` for a `run_command` that needs approval, which ends the run with `deferred_tool_use` on its result. A paused run is a `PausedRun` (its history, ending with that result), parked in `pending_store` with the ts of its Approve/Deny message, so a click only resumes the run its buttons were posted for. `resume_run` resumes the session without a prompt: the CLI asks the hook about the waiting call again, and the hook answers with the decision. A newer message in the channel retires a pending approval, so a late click can't resume an older run over it. The next run then finds the waiting call on the last result, and the hook hands it to `answer_unanswered`, the `can_use_tool` callback, which denies it as not run and interrupts that old turn, so no model call is spent on it before the new message.

## Testing

Tests are async (`pytest-asyncio` in auto mode) and mock the Slack client with `Mock(AsyncWebClient)`. When mocking SDK objects, use `Mock(spec=...)` or `MagicMock(spec=...)` with the real class (`ClaudeSDKClient`), or build the real dataclass (`ResultMessage`), so a call to a method that doesn't exist fails the test. `tests/test_runs.py` runs the real agents and the bundled CLI against `tests/fake_model.py`, a local stand-in for the Anthropic Messages API that returns scripted tool calls and replies, so the SDK seams are tested without calling Anthropic.
21 changes: 21 additions & 0 deletions claude-agent-sdk/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Slack Technologies, LLC

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading