From 36b6c1cfe0a4bbc9e574bca16da8bc8a27c8e717 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Wed, 18 Feb 2026 22:29:23 -0800 Subject: [PATCH 1/3] feat: add Claude Agent SDK implementation Add a third parallel Casey implementation using the Claude Agent SDK with AsyncApp. Tools are registered via @tool decorator and create_sdk_mcp_server(). Conversation context is managed server-side via session IDs instead of local message history storage. Updates root README, CLAUDE.md, CI matrix, and dependabot config. --- .claude/CLAUDE.md | 35 +-- .github/dependabot.yml | 1 + .github/workflows/ruff.yml | 1 + README.md | 12 +- claude-agent-sdk/.claude/CLAUDE.md | 19 ++ claude-agent-sdk/.env.sample | 9 + claude-agent-sdk/.gitignore | 43 ++++ claude-agent-sdk/.slack/.gitignore | 2 + claude-agent-sdk/.slack/config.json | 6 + claude-agent-sdk/.slack/hooks.json | 5 + claude-agent-sdk/README.md | 208 ++++++++++++++++++ claude-agent-sdk/agent/__init__.py | 4 + claude-agent-sdk/agent/casey.py | 112 ++++++++++ claude-agent-sdk/agent/deps.py | 11 + claude-agent-sdk/agent/tools/__init__.py | 13 ++ .../agent/tools/knowledge_base.py | 132 +++++++++++ .../agent/tools/password_reset.py | 26 +++ claude-agent-sdk/agent/tools/system_status.py | 87 ++++++++ claude-agent-sdk/agent/tools/ticket.py | 57 +++++ .../agent/tools/user_permissions.py | 42 ++++ claude-agent-sdk/app.py | 33 +++ claude-agent-sdk/conversation/__init__.py | 5 + claude-agent-sdk/conversation/store.py | 62 ++++++ claude-agent-sdk/listeners/__init__.py | 9 + .../listeners/actions/__init__.py | 11 + .../listeners/actions/category_buttons.py | 21 ++ .../listeners/actions/feedback.py | 36 +++ claude-agent-sdk/listeners/events/__init__.py | 11 + .../listeners/events/app_home_opened.py | 15 ++ .../listeners/events/app_mentioned.py | 116 ++++++++++ .../listeners/events/message_im.py | 113 ++++++++++ claude-agent-sdk/listeners/views/__init__.py | 7 + .../listeners/views/app_home_builder.py | 83 +++++++ .../listeners/views/feedback_block.py | 29 +++ .../listeners/views/issue_modal.py | 82 +++++++ .../listeners/views/modal_builder.py | 60 +++++ claude-agent-sdk/manifest.json | 51 +++++ claude-agent-sdk/pyproject.toml | 32 +++ claude-agent-sdk/requirements.txt | 9 + claude-agent-sdk/tests/__init__.py | 0 40 files changed, 1588 insertions(+), 22 deletions(-) create mode 100644 claude-agent-sdk/.claude/CLAUDE.md create mode 100644 claude-agent-sdk/.env.sample create mode 100644 claude-agent-sdk/.gitignore create mode 100644 claude-agent-sdk/.slack/.gitignore create mode 100644 claude-agent-sdk/.slack/config.json create mode 100644 claude-agent-sdk/.slack/hooks.json create mode 100644 claude-agent-sdk/README.md create mode 100644 claude-agent-sdk/agent/__init__.py create mode 100644 claude-agent-sdk/agent/casey.py create mode 100644 claude-agent-sdk/agent/deps.py create mode 100644 claude-agent-sdk/agent/tools/__init__.py create mode 100644 claude-agent-sdk/agent/tools/knowledge_base.py create mode 100644 claude-agent-sdk/agent/tools/password_reset.py create mode 100644 claude-agent-sdk/agent/tools/system_status.py create mode 100644 claude-agent-sdk/agent/tools/ticket.py create mode 100644 claude-agent-sdk/agent/tools/user_permissions.py create mode 100644 claude-agent-sdk/app.py create mode 100644 claude-agent-sdk/conversation/__init__.py create mode 100644 claude-agent-sdk/conversation/store.py create mode 100644 claude-agent-sdk/listeners/__init__.py create mode 100644 claude-agent-sdk/listeners/actions/__init__.py create mode 100644 claude-agent-sdk/listeners/actions/category_buttons.py create mode 100644 claude-agent-sdk/listeners/actions/feedback.py create mode 100644 claude-agent-sdk/listeners/events/__init__.py create mode 100644 claude-agent-sdk/listeners/events/app_home_opened.py create mode 100644 claude-agent-sdk/listeners/events/app_mentioned.py create mode 100644 claude-agent-sdk/listeners/events/message_im.py create mode 100644 claude-agent-sdk/listeners/views/__init__.py create mode 100644 claude-agent-sdk/listeners/views/app_home_builder.py create mode 100644 claude-agent-sdk/listeners/views/feedback_block.py create mode 100644 claude-agent-sdk/listeners/views/issue_modal.py create mode 100644 claude-agent-sdk/listeners/views/modal_builder.py create mode 100644 claude-agent-sdk/manifest.json create mode 100644 claude-agent-sdk/pyproject.toml create mode 100644 claude-agent-sdk/requirements.txt create mode 100644 claude-agent-sdk/tests/__init__.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index aec90dc..db44334 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,19 +4,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What This Is -A monorepo containing two parallel implementations of **Casey**, an AI-powered IT helpdesk agent for Slack built with Bolt for Python. Both implementations are functionally identical from the Slack user's perspective but use different AI agent frameworks: +A monorepo containing three parallel implementations of **Casey**, an AI-powered IT helpdesk agent for Slack built with Bolt for Python. All implementations are functionally identical from the Slack user's perspective but use different AI agent frameworks: - `pydantic-ai/` — Built with **Pydantic AI** - `openai-agents-sdk/` — Built with **OpenAI Agents SDK** +- `claude-agent-sdk/` — Built with **Claude Agent SDK** All tool data (knowledge base, tickets, password resets, system status, permissions) is hardcoded for demo purposes. ## Commands -All commands must be run from within the respective project directory (`pydantic-ai/` or `openai-agents-sdk/`). +All commands must be run from within the respective project directory (`pydantic-ai/`, `openai-agents-sdk/`, or `claude-agent-sdk/`). ```sh -# Run the app (requires .env with OPENAI_API_KEY; Slack tokens optional with CLI) +# Run the app (requires .env with OPENAI_API_KEY or ANTHROPIC_API_KEY; Slack tokens optional with CLI) slack run # via Slack CLI python3 app.py # directly @@ -34,9 +35,10 @@ pytest .github/ # Shared CI workflows and dependabot config pydantic-ai/ # Pydantic AI implementation openai-agents-sdk/ # OpenAI Agents SDK implementation +claude-agent-sdk/ # Claude Agent SDK implementation ``` -CI runs ruff lint/format checks against both directories via a matrix strategy in `.github/workflows/ruff.yml`. Dependabot monitors `requirements.txt` in both directories independently. +CI runs ruff lint/format checks against all directories via a matrix strategy in `.github/workflows/ruff.yml`. Dependabot monitors `requirements.txt` in all directories independently. ## Architecture (shared across both implementations) @@ -59,15 +61,16 @@ Each sub-package has a `register(app)` function called from `listeners/__init__. ## Key Differences Between Implementations -| Aspect | Pydantic AI | OpenAI Agents SDK | -|--------|-------------|-------------------| -| Agent file | `agent/casey.py` | `agent/support_agent.py` | -| Agent definition | `Agent(deps_type=CaseyDeps)` | `Agent[CaseyDeps](model="gpt-4o-mini")` | -| Model config | Passed at runtime via `run_sync(model=DEFAULT_MODEL)` | Set directly on agent constructor | -| Tool definition | Plain async functions | `@function_tool` decorated functions | -| Tool context param | `RunContext[CaseyDeps]` | `RunContextWrapper[CaseyDeps]` | -| Execution | `casey_agent.run_sync(text, model=..., deps=..., message_history=...)` | `Runner.run_sync(casey_agent, input=..., context=...)` | -| Result output | `result.output` | `result.final_output` | -| Result messages | `result.all_messages()` | `result.to_input_list()` | -| History type | `list[ModelMessage]` (framework-native) | `list` (generic, manually constructed) | -| Feedback blocks | Native `FeedbackButtonsElement` | Native `FeedbackButtonsElement` | +| Aspect | Pydantic AI | OpenAI Agents SDK | Claude Agent SDK | +|--------|-------------|-------------------|-----------------| +| Agent file | `agent/casey.py` | `agent/support_agent.py` | `agent/casey.py` | +| App type | `App` (sync) | `App` (sync) | `AsyncApp` (fully async) | +| Agent definition | `Agent(deps_type=CaseyDeps)` | `Agent[CaseyDeps](model="gpt-4o-mini")` | `ClaudeSDKClient` with `ClaudeAgentOptions` | +| Model config | Passed at runtime via `run_sync(model=DEFAULT_MODEL)` | Set directly on agent constructor | Managed by SDK (Claude models) | +| Tool definition | Plain async functions | `@function_tool` decorated functions | `@tool` decorated functions via MCP server | +| Tool context param | `RunContext[CaseyDeps]` | `RunContextWrapper[CaseyDeps]` | `args` dict (no context param) | +| Execution | `casey_agent.run_sync(text, model=..., deps=..., message_history=...)` | `Runner.run_sync(casey_agent, input=..., context=...)` | `await run_casey_agent(text, session_id=...)` | +| Result output | `result.output` | `result.final_output` | `response_text` from collected `TextBlock.text` | +| Conversation history | `list[ModelMessage]` stored locally | `list` stored locally | Session-based via `resume` (server-side) | +| API key env var | `OPENAI_API_KEY` | `OPENAI_API_KEY` | `ANTHROPIC_API_KEY` | +| Feedback blocks | Native `FeedbackButtonsElement` | Native `FeedbackButtonsElement` | Native `FeedbackButtonsElement` | diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 30a971c..44b6c91 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,7 @@ updates: - "/" - "/openai-agents-sdk" - "/pydantic-ai" + - "/claude-agent-sdk" schedule: interval: "weekly" labels: diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 630c7e9..623d0a0 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -14,6 +14,7 @@ jobs: directory: - openai-agents-sdk - pydantic-ai + - claude-agent-sdk defaults: run: working-directory: ${{ matrix.directory }} diff --git a/README.md b/README.md index 11fa406..5d91b32 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ Built with [Bolt for Python](https://docs.slack.dev/tools/bolt-python/). ## Choose Your Framework -This repo contains the same app built with two different AI agent frameworks. Pick the one that fits your stack: +This repo contains the same app built with three different AI agent frameworks. Pick the one that fits your stack: -| | [Pydantic AI](./pydantic-ai/) | [OpenAI Agents SDK](./openai-agents-sdk/) | -|---|---|---| -| **Framework** | [pydantic-ai](https://ai.pydantic.dev/) | [openai-agents](https://openai.github.io/openai-agents-python/) | -| **Get started** | [View README](./pydantic-ai/README.md) | [View README](./openai-agents-sdk/README.md) | +| | [Pydantic AI](./pydantic-ai/) | [OpenAI Agents SDK](./openai-agents-sdk/) | [Claude Agent SDK](./claude-agent-sdk/) | +|---|---|---|---| +| **Framework** | [pydantic-ai](https://ai.pydantic.dev/) | [openai-agents](https://openai.github.io/openai-agents-python/) | [claude-agent-sdk](https://docs.anthropic.com/en/docs/agents/claude-agent-sdk) | +| **Get started** | [View README](./pydantic-ai/README.md) | [View README](./openai-agents-sdk/README.md) | [View README](./claude-agent-sdk/README.md) | -Both implementations share the same Slack listener layer, the same five simulated IT tools, and the same user experience. The only difference is how the agent is defined and executed under the hood. +All implementations share the same Slack listener layer, the same five simulated IT tools, and the same user experience. The only difference is how the agent is defined and executed under the hood. ## What Casey Can Do diff --git a/claude-agent-sdk/.claude/CLAUDE.md b/claude-agent-sdk/.claude/CLAUDE.md new file mode 100644 index 0000000..97423ca --- /dev/null +++ b/claude-agent-sdk/.claude/CLAUDE.md @@ -0,0 +1,19 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +See the root `../.claude/CLAUDE.md` for monorepo-wide architecture, commands, and a comparison of all implementations. + +## Claude Agent SDK Specifics + +**App (`app.py`)** uses `AsyncApp` from Bolt for Python. All listeners and Slack API calls are fully async (`await`). + +**Agent (`agent/casey.py`)** uses `ClaudeSDKClient` from the Claude Agent SDK. Tools are registered via `create_sdk_mcp_server()` and passed as `mcp_servers` in `ClaudeAgentOptions`. The `run_casey_agent()` function is async and returns `(response_text, session_id)`. + +**Tools (`agent/tools/`)** are defined with the `@tool` decorator from `claude_agent_sdk`. Each tool returns `{"content": [{"type": "text", "text": ...}]}`. Tools are registered into a single MCP server via `create_sdk_mcp_server()`. + +**Conversation history** is managed server-side by the Claude Agent SDK via sessions. The local `SessionStore` (`conversation/store.py`) only maps `(channel_id, thread_ts)` to session IDs. Sessions are resumed via `ClaudeAgentOptions(resume=session_id)`. + +**Feedback blocks** use the native `FeedbackButtonsElement` from `slack_sdk.models.blocks`. A single `feedback` action ID is registered. + +**Dependencies** (`agent/deps.py`) use `AsyncWebClient` from `slack_sdk.web.async_client` instead of the sync `WebClient`. diff --git a/claude-agent-sdk/.env.sample b/claude-agent-sdk/.env.sample new file mode 100644 index 0000000..f573a4b --- /dev/null +++ b/claude-agent-sdk/.env.sample @@ -0,0 +1,9 @@ +# 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 + +# Optional, uncomment and set when using a custom Slack instance. +# SLACK_API_URL=YOUR_SLACK_API_URL + +# Required, set your Anthropic API key. +ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY diff --git a/claude-agent-sdk/.gitignore b/claude-agent-sdk/.gitignore new file mode 100644 index 0000000..31f586e --- /dev/null +++ b/claude-agent-sdk/.gitignore @@ -0,0 +1,43 @@ +# general things to ignore +build/ +dist/ +docs/_sources/ +docs/.doctrees +.eggs/ +*.egg-info/ +*.egg +*.py[cod] +__pycache__/ +*.so +*~ + +# virtualenv +env*/ +venv/ +.venv* +.env* +!.env.sample + +# codecov / coverage +.coverage +cov_* +coverage.xml + +# due to using tox and pytest +.tox +.cache +.pytest_cache/ +.python-version +pip +.mypy_cache/ + +# misc +tmp.txt +.DS_Store +logs/ +*.db +.pytype/ +.idea/ + +# claude +.claude/*.local.json diff --git a/claude-agent-sdk/.slack/.gitignore b/claude-agent-sdk/.slack/.gitignore new file mode 100644 index 0000000..973ba60 --- /dev/null +++ b/claude-agent-sdk/.slack/.gitignore @@ -0,0 +1,2 @@ +apps.dev.json +cache/ diff --git a/claude-agent-sdk/.slack/config.json b/claude-agent-sdk/.slack/config.json new file mode 100644 index 0000000..dab53c6 --- /dev/null +++ b/claude-agent-sdk/.slack/config.json @@ -0,0 +1,6 @@ +{ + "manifest": { + "source": "local" + }, + "project_id": "c189134c-231e-4c96-b800-ea39c055aa77" +} diff --git a/claude-agent-sdk/.slack/hooks.json b/claude-agent-sdk/.slack/hooks.json new file mode 100644 index 0000000..ce474c9 --- /dev/null +++ b/claude-agent-sdk/.slack/hooks.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks" + } +} diff --git a/claude-agent-sdk/README.md b/claude-agent-sdk/README.md new file mode 100644 index 0000000..9b243c6 --- /dev/null +++ b/claude-agent-sdk/README.md @@ -0,0 +1,208 @@ +# Casey: IT Helpdesk Agent (Bolt for Python and Claude Agent SDK) + +Meet Casey (they/them) — an AI-powered IT helpdesk agent that lives in Slack. Casey can troubleshoot common issues, search knowledge base articles, reset passwords, check system status, and create support tickets, all without leaving the conversation. + +Built with [Bolt for Python](https://docs.slack.dev/tools/bolt-python/) and the [Claude Agent SDK](https://docs.anthropic.com/en/docs/agents/claude-agent-sdk) using models from [Anthropic](https://www.anthropic.com). + +## App Overview + +Casey gives your team instant IT support through three entry points: + +* **App Home** — Users open Casey's Home tab and choose from common issue categories (Password Reset, Access Request, Software Help, Network Issues, Something Else). A modal collects details, then Casey starts a DM thread with a resolution. +* **Direct Messages** — Users message Casey directly to describe any IT issue. Casey responds in-thread, maintaining context across follow-ups. +* **Channel @mentions** — Users mention `@Casey` in any channel to get help without leaving the conversation. + +Casey uses five simulated tools to assist users: + +* **Knowledge Base Search** — Finds relevant articles for common topics like VPN, email, Wi-Fi, printers, and more. +* **Support Ticket Creation** — Creates a tracked ticket when issues need human follow-up. +* **Password Reset** — Triggers a password reset and confirms the action. +* **System Status Check** — Reports the operational status of company systems. +* **User Permissions Lookup** — Shows access levels and group memberships. + +> **Note:** All tools return simulated data for demonstration purposes. In a production app, these would connect to your actual IT systems. + +## Setup + +Before getting started, make sure you have a development workspace where you have permissions to install apps. + +### Developer Program + +Join the [Slack Developer Program](https://api.slack.com/developer-program) for exclusive access to sandbox environments for building and testing your apps, tooling, and resources created to help you build and grow. + +### Create the Slack app + +
Using Slack CLI + +Install the latest version of the Slack CLI for your operating system: + +- [Slack CLI for macOS & Linux](https://docs.slack.dev/tools/slack-cli/guides/installing-the-slack-cli-for-mac-and-linux/) +- [Slack CLI for Windows](https://docs.slack.dev/tools/slack-cli/guides/installing-the-slack-cli-for-windows/) + +You'll also need to log in if this is your first time using the Slack CLI. + +```sh +slack login +``` + +#### Initializing the project + +```sh +slack create my-casey-agent --template slack-samples/bolt-python-support-agent +cd my-casey-agent +``` + +
+ +
Using App Settings + +#### Create Your Slack App + +1. Open [https://api.slack.com/apps/new](https://api.slack.com/apps/new) and choose "From an app manifest" +2. Choose the workspace you want to install the application to +3. Copy the contents of [manifest.json](./manifest.json) into the text box that says `*Paste your manifest code here*` (within the JSON tab) and click _Next_ +4. Review the configuration and click _Create_ +5. Click _Install to Workspace_ and _Allow_ on the screen that follows. You'll then be redirected to the App Configuration dashboard. + +#### Environment Variables + +Before you can run the app, you'll need to store some environment variables. + +1. Rename `.env.sample` to `.env`. +2. Open your apps setting page from [this list](https://api.slack.com/apps), click _OAuth & Permissions_ in the left hand menu, then copy the _Bot User OAuth Token_ into your `.env` file under `SLACK_BOT_TOKEN`. + +```sh +SLACK_BOT_TOKEN=YOUR_SLACK_BOT_TOKEN +``` + +3. Click _Basic Information_ from the left hand menu and follow the steps in the _App-Level Tokens_ section to create an app-level token with the `connections:write` scope. Copy that token into your `.env` as `SLACK_APP_TOKEN`. + +```sh +SLACK_APP_TOKEN=YOUR_SLACK_APP_TOKEN +``` + +#### Initializing the project + +```sh +git clone https://github.com/slack-samples/bolt-python-support-agent.git my-casey-agent +cd my-casey-agent +``` + +
+ +### Setup your python virtual environment + +```sh +python3 -m venv .venv +source .venv/bin/activate # for Windows OS, .\.venv\Scripts\Activate instead should work +``` + +#### Install dependencies + +```sh +pip install -r requirements.txt +# or pip install -e . +``` + +## Providers + +### Anthropic Setup + +This app uses Claude through the Claude Agent SDK. + +1. Create an API key from your [Anthropic dashboard](https://console.anthropic.com/settings/keys). +1. Rename `.env.sample` to `.env`. +3. Save the Anthropic API key to `.env`: + +```sh +ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY +``` + +## Development + +### Starting the app + +
Using the Slack CLI + +#### Slack CLI + +```sh +slack run +``` +
+ +
Using the Terminal + +#### Terminal + +```sh +python3 app.py +``` + +
+ +### Using the App + +Once Casey is running, there are three ways to interact: + +**App Home** — Open Casey in Slack and click the _Home_ tab. You'll see five category buttons. Click one to open a modal, describe your issue, and submit. Casey will start a DM thread with you containing a diagnosis and next steps. + +**Direct Messages** — Open a DM with Casey and describe your issue. Casey will react with :eyes: while processing, then reply in a thread. Send follow-up messages in the same thread and Casey will maintain the full conversation context. + +**Channel @mentions** — In any channel where Casey has been added, type `@Casey` followed by your issue. Casey responds in a thread so the channel stays clean. + +Casey will add a :white_check_mark: reaction when it believes an issue has been resolved, and occasionally adds a contextual emoji reaction to keep things friendly. + +### Linting + +```sh +# Run ruff check from root directory for linting +ruff check + +# Run ruff format from root directory for code formatting +ruff format +``` + +## Project Structure + +### `manifest.json` + +`manifest.json` is a configuration for Slack apps. With a manifest, you can create an app with a pre-defined configuration, or adjust the configuration of an existing app. + +### `app.py` + +`app.py` is the entry point for the application and is the file you'll run to start the server. This project uses `AsyncApp` from Bolt for Python, with all handlers running asynchronously. + +### `/listeners` + +Every incoming request is routed to a "listener". This directory groups each listener based on the Slack Platform feature used. + +**`/listeners/events`** — Handles incoming events: + +- `app_home_opened.py` — Publishes the App Home view with category buttons. +- `app_mentioned.py` — Responds to `@Casey` mentions in channels. +- `message_im.py` — Responds to direct messages from users. + +**`/listeners/actions`** — Handles interactive components: + +- `category_buttons.py` — Opens the issue submission modal when a category button is clicked. +- `feedback.py` — Handles thumbs up/down feedback on Casey's responses. + +**`/listeners/views`** — Handles view submissions and builds Block Kit views: + +- `issue_modal.py` — Processes modal submissions, starts a DM thread, and runs the agent. +- `app_home_builder.py` — Constructs the App Home Block Kit view. +- `modal_builder.py` — Constructs the issue submission modal. +- `feedback_block.py` — Creates the feedback button block attached to responses. + +### `/agent` + +The `casey.py` file configures the Claude Agent SDK with a system prompt, tools registered via an MCP server, and a `run_casey_agent()` async function that handles sending queries and collecting responses. + +The `deps.py` file defines the `CaseyDeps` dataclass passed to the agent at runtime, providing access to the Slack client and conversation context. + +The `tools` directory contains five IT helpdesk tools defined using the `@tool` decorator from the Claude Agent SDK. + +### `/conversation` + +The `store.py` file implements a thread-safe in-memory session ID store, keyed by channel and thread. The Claude Agent SDK manages conversation history server-side via sessions, so only session IDs need to be tracked locally for resuming conversations. diff --git a/claude-agent-sdk/agent/__init__.py b/claude-agent-sdk/agent/__init__.py new file mode 100644 index 0000000..b9c1ee5 --- /dev/null +++ b/claude-agent-sdk/agent/__init__.py @@ -0,0 +1,4 @@ +from .casey import run_casey_agent +from .deps import CaseyDeps + +__all__ = ["run_casey_agent", "CaseyDeps"] diff --git a/claude-agent-sdk/agent/casey.py b/claude-agent-sdk/agent/casey.py new file mode 100644 index 0000000..b03545d --- /dev/null +++ b/claude-agent-sdk/agent/casey.py @@ -0,0 +1,112 @@ +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + ResultMessage, + TextBlock, + create_sdk_mcp_server, +) + +from agent.tools import ( + check_system_status_tool, + create_support_ticket_tool, + lookup_user_permissions_tool, + search_knowledge_base_tool, + trigger_password_reset_tool, +) + +CASEY_SYSTEM_PROMPT = """\ +You are Casey, an IT helpdesk agent for a company. You help employees troubleshoot \ +technical issues, answer IT questions, and manage support requests through Slack. + +## Personality +- Calm, competent, and efficient +- Lightly witty — a touch of dry humor when appropriate, but never at the user's expense +- Empathetic to frustration ("I know VPN issues are the worst, let's get you sorted") +- Confident but honest when you don't know something + +## Formatting Rules +- Use standard Markdown syntax: **bold**, _italic_, `code`, ```code blocks```, > blockquotes +- Use bullet points for multi-step instructions +- Keep responses concise — aim for helpful, not verbose +- When referencing ticket IDs or system names, use `inline code` + +## Workflow +1. Acknowledge the user's issue +2. Search the knowledge base for relevant articles +3. If the KB has a solution, walk the user through it step by step +4. If the issue requires action (password reset, ticket creation), use the appropriate tool +5. After taking action, confirm what was done and what the user should expect next +6. If you cannot resolve the issue, create a support ticket and let the user know + +## Escalation Rules +- Always create a ticket for hardware failures, account compromises, or data loss +- Create a ticket when the user has already tried the KB steps and they didn't work +- For access requests, verify the system name and create a ticket with the details + +## Boundaries +- You are an IT helpdesk agent only — politely redirect non-IT questions +- Do not make up system statuses or ticket numbers — always use the provided tools +- Do not promise specific resolution times unless the tool response includes them +- If unsure about a user's issue, ask clarifying questions before taking action +""" + +casey_tools_server = create_sdk_mcp_server( + name="casey-tools", + version="1.0.0", + tools=[ + search_knowledge_base_tool, + create_support_ticket_tool, + trigger_password_reset_tool, + check_system_status_tool, + lookup_user_permissions_tool, + ], +) + +ALLOWED_TOOLS = [ + "search_knowledge_base", + "create_support_ticket", + "trigger_password_reset", + "check_system_status", + "lookup_user_permissions", +] + + +async def run_casey_agent( + text: str, session_id: str | None = None +) -> tuple[str, str | None]: + """Run the Casey agent with the given text and optional session for context. + + Args: + text: The user's message text. + session_id: Optional session ID to resume a previous conversation. + + Returns: + A tuple of (response_text, new_session_id). + """ + options = ClaudeAgentOptions( + system_prompt=CASEY_SYSTEM_PROMPT, + mcp_servers={"casey-tools": casey_tools_server}, + allowed_tools=ALLOWED_TOOLS, + permission_mode="bypassPermissions", + ) + + if session_id: + options.resume = session_id + + response_parts: list[str] = [] + new_session_id: str | None = None + + async with ClaudeSDKClient(options) as client: + await client.query(text) + + async for message in client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + response_parts.append(block.text) + if isinstance(message, ResultMessage): + new_session_id = message.session_id + + response_text = "\n".join(response_parts) if response_parts else "" + return response_text, new_session_id diff --git a/claude-agent-sdk/agent/deps.py b/claude-agent-sdk/agent/deps.py new file mode 100644 index 0000000..53f0b52 --- /dev/null +++ b/claude-agent-sdk/agent/deps.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from slack_sdk.web.async_client import AsyncWebClient + + +@dataclass +class CaseyDeps: + client: AsyncWebClient + user_id: str + channel_id: str + thread_ts: str diff --git a/claude-agent-sdk/agent/tools/__init__.py b/claude-agent-sdk/agent/tools/__init__.py new file mode 100644 index 0000000..6290ed6 --- /dev/null +++ b/claude-agent-sdk/agent/tools/__init__.py @@ -0,0 +1,13 @@ +from .knowledge_base import search_knowledge_base_tool +from .password_reset import trigger_password_reset_tool +from .system_status import check_system_status_tool +from .ticket import create_support_ticket_tool +from .user_permissions import lookup_user_permissions_tool + +__all__ = [ + "search_knowledge_base_tool", + "create_support_ticket_tool", + "trigger_password_reset_tool", + "check_system_status_tool", + "lookup_user_permissions_tool", +] diff --git a/claude-agent-sdk/agent/tools/knowledge_base.py b/claude-agent-sdk/agent/tools/knowledge_base.py new file mode 100644 index 0000000..dfe8d8d --- /dev/null +++ b/claude-agent-sdk/agent/tools/knowledge_base.py @@ -0,0 +1,132 @@ +from claude_agent_sdk import tool + +KB_ARTICLES = { + "vpn": { + "title": "VPN Connection Troubleshooting", + "content": ( + "1. Ensure you're connected to the internet\n" + "2. Restart the VPN client application\n" + "3. Try connecting to a different VPN server region\n" + "4. Clear VPN client cache: Settings > Advanced > Clear Cache\n" + "5. If still failing, uninstall and reinstall the VPN client from the Software Center" + ), + "article_id": "KB-1001", + }, + "email": { + "title": "Email Configuration and Common Issues", + "content": ( + "1. Verify your email credentials at mail.company.com/settings\n" + "2. For Outlook sync issues, remove and re-add the account\n" + "3. Check mailbox storage quota in Settings > Storage\n" + "4. For mobile email setup, use ActiveSync with server mail.company.com\n" + "5. Two-factor authentication must be enabled for email access" + ), + "article_id": "KB-1002", + }, + "wifi": { + "title": "Office Wi-Fi Connection Guide", + "content": ( + "1. Connect to 'CorpNet-Secure' (not 'CorpNet-Guest') for internal access\n" + "2. Use your network credentials (same as laptop login)\n" + "3. If prompted for a certificate, click 'Trust' or 'Accept'\n" + "4. Forget and rejoin the network if experiencing intermittent drops\n" + "5. Guest network: 'CorpNet-Guest', password rotates weekly (check lobby display)" + ), + "article_id": "KB-1003", + }, + "software": { + "title": "Software Installation and Requests", + "content": ( + "1. Approved software can be installed from the Software Center app\n" + "2. For software not in the catalog, submit a request via the IT portal\n" + "3. License requests typically take 2-3 business days for approval\n" + "4. Admin rights are not granted for standard installations\n" + "5. For developer tools, request access through your team lead" + ), + "article_id": "KB-1004", + }, + "printer": { + "title": "Printer Setup and Troubleshooting", + "content": ( + "1. Add network printers via Settings > Printers > Add > search 'CorpPrint'\n" + "2. Default printer drivers are installed automatically\n" + "3. For print queue issues, restart the Print Spooler service\n" + "4. Color printing requires manager approval code\n" + "5. Secure print: Use your badge at the printer to release jobs" + ), + "article_id": "KB-1005", + }, + "password": { + "title": "Password Policy and Reset Guide", + "content": ( + "1. Passwords must be at least 12 characters with uppercase, lowercase, number, and symbol\n" + "2. Passwords expire every 90 days\n" + "3. Self-service reset available at password.company.com\n" + "4. Account locks after 5 failed attempts (auto-unlocks after 30 minutes)\n" + "5. For immediate unlock, contact IT helpdesk or use Casey's password reset tool" + ), + "article_id": "KB-1006", + }, + "hardware": { + "title": "Hardware Requests and Replacements", + "content": ( + "1. Standard hardware refresh cycle is every 3 years\n" + "2. Submit hardware requests through the IT portal under 'Equipment'\n" + "3. Emergency replacements available for broken/lost devices\n" + "4. Peripheral requests (monitors, keyboards) approved by direct manager\n" + "5. All hardware must be returned upon offboarding" + ), + "article_id": "KB-1007", + }, + "security": { + "title": "Security Best Practices", + "content": ( + "1. Never share your credentials or write passwords on sticky notes\n" + "2. Report suspicious emails to phishing@company.com\n" + "3. Enable two-factor authentication on all supported systems\n" + "4. Lock your workstation when stepping away (Win+L or Cmd+Ctrl+Q)\n" + "5. Do not connect personal USB drives to company devices" + ), + "article_id": "KB-1008", + }, +} + + +@tool( + name="search_knowledge_base", + description=( + "Search the IT knowledge base for articles matching the given query. " + "Use this tool when users ask about common IT topics like VPN, email, Wi-Fi, " + "software installation, printers, passwords, hardware, or security." + ), + input_schema={"query": str}, +) +async def search_knowledge_base_tool(args): + """Search the IT knowledge base for articles matching the given query.""" + query = args["query"] + query_lower = query.lower() + matches = [] + + for keyword, article in KB_ARTICLES.items(): + if keyword in query_lower or any( + word in query_lower for word in article["title"].lower().split() + ): + matches.append(article) + + if not matches: + return { + "content": [ + { + "type": "text", + "text": "No knowledge base articles found matching the query. Consider creating a support ticket for further assistance.", + } + ] + } + + results = [] + for article in matches: + results.append( + f"**{article['title']}** ({article['article_id']})\n{article['content']}" + ) + + return {"content": [{"type": "text", "text": "\n\n---\n\n".join(results)}]} diff --git a/claude-agent-sdk/agent/tools/password_reset.py b/claude-agent-sdk/agent/tools/password_reset.py new file mode 100644 index 0000000..6dd07a1 --- /dev/null +++ b/claude-agent-sdk/agent/tools/password_reset.py @@ -0,0 +1,26 @@ +from claude_agent_sdk import tool + + +@tool( + name="trigger_password_reset", + description=( + "Trigger a password reset for a specified user account. " + "Use this tool when a user requests a password reset for their own account " + "or reports being locked out. The reset link will be sent to their registered " + "email address." + ), + input_schema={"target_user": str}, +) +async def trigger_password_reset_tool(args): + """Trigger a password reset for a specified user account.""" + target_user = args["target_user"] + + text = ( + f"Password reset initiated for **{target_user}**.\n\n" + f"A reset link has been sent to the email address on file. " + f"The link will expire in 30 minutes.\n\n" + f"_If the user doesn't receive the email within 5 minutes, " + f"ask them to check their spam folder or verify their registered email address._" + ) + + return {"content": [{"type": "text", "text": text}]} diff --git a/claude-agent-sdk/agent/tools/system_status.py b/claude-agent-sdk/agent/tools/system_status.py new file mode 100644 index 0000000..4d9b3e0 --- /dev/null +++ b/claude-agent-sdk/agent/tools/system_status.py @@ -0,0 +1,87 @@ +from claude_agent_sdk import tool + +SYSTEM_STATUSES = { + "email": { + "name": "Email (Exchange Online)", + "status": "operational", + "details": "All email services running normally. Last incident resolved 3 days ago.", + }, + "vpn": { + "name": "Corporate VPN", + "status": "degraded", + "details": "Intermittent connectivity issues reported on the US-East gateway. Engineering is investigating. ETA for resolution: 2 hours.", + }, + "jira": { + "name": "Jira", + "status": "operational", + "details": "All project management services running normally.", + }, + "confluence": { + "name": "Confluence", + "status": "operational", + "details": "Wiki and documentation services running normally.", + }, + "slack": { + "name": "Slack", + "status": "operational", + "details": "All messaging services running normally.", + }, + "github": { + "name": "GitHub Enterprise", + "status": "operational", + "details": "All code repository and CI/CD services running normally.", + }, + "sso": { + "name": "Single Sign-On (SSO)", + "status": "operational", + "details": "Authentication services running normally.", + }, + "network": { + "name": "Corporate Network", + "status": "operational", + "details": "All office networks operating at full capacity.", + }, + "erp": { + "name": "ERP System", + "status": "maintenance", + "details": "Scheduled maintenance window active. Service will be restored by 6:00 AM UTC tomorrow.", + }, +} + + +@tool( + name="check_system_status", + description=( + "Check the current operational status of a company system or service. " + "Use this tool when a user asks about outages, system availability, or " + "whether a specific service is currently working." + ), + input_schema={"system_name": str}, +) +async def check_system_status_tool(args): + """Check the current operational status of a company system or service.""" + system_name = args["system_name"] + system_lower = system_name.lower() + + for key, info in SYSTEM_STATUSES.items(): + if key in system_lower or system_lower in info["name"].lower(): + status_emoji = { + "operational": ":large_green_circle:", + "degraded": ":large_yellow_circle:", + "outage": ":red_circle:", + "maintenance": ":wrench:", + }.get(info["status"], ":white_circle:") + + text = ( + f"**{info['name']}** {status_emoji} `{info['status'].upper()}`\n" + f"{info['details']}" + ) + return {"content": [{"type": "text", "text": text}]} + + available = ", ".join(sorted(SYSTEM_STATUSES.keys())) + text = ( + f"System '{system_name}' not found in monitoring. " + f"Available systems: {available}. " + f"If you need status for a different system, try a more specific name." + ) + return {"content": [{"type": "text", "text": text}]} diff --git a/claude-agent-sdk/agent/tools/ticket.py b/claude-agent-sdk/agent/tools/ticket.py new file mode 100644 index 0000000..f7e2c7d --- /dev/null +++ b/claude-agent-sdk/agent/tools/ticket.py @@ -0,0 +1,57 @@ +import random + +from claude_agent_sdk import tool + + +@tool( + name="create_support_ticket", + description=( + "Create a new IT support ticket for issues that require human follow-up. " + "Use this tool when a user's issue cannot be resolved through knowledge base " + "articles or automated tools, and needs to be escalated to the IT support team." + ), + input_schema={ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "A concise title describing the issue.", + }, + "description": { + "type": "string", + "description": "A detailed description of the problem and any troubleshooting already attempted.", + }, + "priority": { + "type": "string", + "description": "The ticket priority level.", + "enum": ["low", "medium", "high", "critical"], + }, + "category": { + "type": "string", + "description": "The issue category.", + "enum": ["hardware", "software", "network", "access", "other"], + }, + }, + "required": ["title", "description", "priority", "category"], + }, +) +async def create_support_ticket_tool(args): + """Create a new IT support ticket for issues that require human follow-up.""" + title = args["title"] + priority = args["priority"] + category = args["category"] + + ticket_id = f"INC-{random.randint(100000, 999999)}" + + text = ( + f"Support ticket created successfully.\n" + f"**Ticket ID:** {ticket_id}\n" + f"**Title:** {title}\n" + f"**Priority:** {priority}\n" + f"**Category:** {category}\n" + f"**Status:** Open\n" + f"**Assigned to:** IT Support Queue\n\n" + f"The IT team will review this ticket and follow up within the SLA for {priority} priority issues." + ) + + return {"content": [{"type": "text", "text": text}]} diff --git a/claude-agent-sdk/agent/tools/user_permissions.py b/claude-agent-sdk/agent/tools/user_permissions.py new file mode 100644 index 0000000..070f6c2 --- /dev/null +++ b/claude-agent-sdk/agent/tools/user_permissions.py @@ -0,0 +1,42 @@ +from claude_agent_sdk import tool + + +@tool( + name="lookup_user_permissions", + description=( + "Look up a user's access permissions and group memberships for a given system. " + "Use this tool when a user asks about their access level, group memberships, " + "or whether they have permission to use a specific system or resource." + ), + input_schema={ + "type": "object", + "properties": { + "target_user": { + "type": "string", + "description": "The username or email of the user to look up.", + }, + "system": { + "type": "string", + "description": "The system or resource to check permissions for (e.g., 'github', 'jira', 'aws').", + }, + }, + "required": ["target_user", "system"], + }, +) +async def lookup_user_permissions_tool(args): + """Look up a user's access permissions and group memberships for a given system.""" + target_user = args["target_user"] + system = args["system"] + + text = ( + f"**Permissions for {target_user} on {system}:**\n\n" + f"**Groups:** `{system}-users`, `{system}-readonly`\n" + f"**Access Level:** Standard User\n" + f"**Last Login:** 2 hours ago\n" + f"**Account Status:** Active\n" + f"**MFA Enabled:** Yes\n\n" + f"_To request elevated access, the user's manager must submit an access request " + f"through the IT portal._" + ) + + return {"content": [{"type": "text", "text": text}]} diff --git a/claude-agent-sdk/app.py b/claude-agent-sdk/app.py new file mode 100644 index 0000000..83a68f0 --- /dev/null +++ b/claude-agent-sdk/app.py @@ -0,0 +1,33 @@ +import asyncio +import logging +import os + +from dotenv import load_dotenv +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler +from slack_bolt.async_app import AsyncApp +from slack_sdk.web.async_client import AsyncWebClient + +from listeners import register_listeners + +load_dotenv(dotenv_path=".env", override=False) + +logging.basicConfig(level=logging.DEBUG) + +app = AsyncApp( + token=os.environ.get("SLACK_BOT_TOKEN"), + client=AsyncWebClient( + base_url=os.environ.get("SLACK_API_URL", "https://slack.com/api"), + token=os.environ.get("SLACK_BOT_TOKEN"), + ), +) + +register_listeners(app) + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")) + await handler.start_async() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/claude-agent-sdk/conversation/__init__.py b/claude-agent-sdk/conversation/__init__.py new file mode 100644 index 0000000..8654bd7 --- /dev/null +++ b/claude-agent-sdk/conversation/__init__.py @@ -0,0 +1,5 @@ +from .store import SessionStore + +session_store = SessionStore() + +__all__ = ["session_store"] diff --git a/claude-agent-sdk/conversation/store.py b/claude-agent-sdk/conversation/store.py new file mode 100644 index 0000000..394beff --- /dev/null +++ b/claude-agent-sdk/conversation/store.py @@ -0,0 +1,62 @@ +import threading +import time + + +class SessionStore: + """Thread-safe in-memory session ID store. + + Stores Claude Agent SDK session IDs keyed by (channel_id, thread_ts). + The SDK manages conversation history server-side via sessions, so we + only need to track session IDs for resuming conversations. + Includes TTL-based cleanup and a maximum entry limit. + """ + + def __init__(self, ttl_seconds: int = 86400, max_entries: int = 1000): + self._store: dict[tuple[str, str], dict] = {} + self._lock = threading.Lock() + self._ttl_seconds = ttl_seconds + self._max_entries = max_entries + + def get_session(self, channel_id: str, thread_ts: str) -> str | None: + """Retrieve session ID for a thread. + + Returns None if no session exists or if the session has expired. + """ + key = (channel_id, thread_ts) + with self._lock: + entry = self._store.get(key) + if entry is None: + return None + if time.time() - entry["timestamp"] > self._ttl_seconds: + del self._store[key] + return None + return entry["session_id"] + + def set_session(self, channel_id: str, thread_ts: str, session_id: str) -> None: + """Store session ID for a thread.""" + key = (channel_id, thread_ts) + with self._lock: + self._store[key] = { + "session_id": session_id, + "timestamp": time.time(), + } + self._cleanup() + + def _cleanup(self) -> None: + """Remove expired entries and enforce max entry limit.""" + now = time.time() + + expired = [ + k + for k, v in self._store.items() + if now - v["timestamp"] > self._ttl_seconds + ] + for k in expired: + del self._store[k] + + if len(self._store) > self._max_entries: + sorted_keys = sorted( + self._store.keys(), key=lambda k: self._store[k]["timestamp"] + ) + for k in sorted_keys[: len(self._store) - self._max_entries]: + del self._store[k] diff --git a/claude-agent-sdk/listeners/__init__.py b/claude-agent-sdk/listeners/__init__.py new file mode 100644 index 0000000..66b7756 --- /dev/null +++ b/claude-agent-sdk/listeners/__init__.py @@ -0,0 +1,9 @@ +from slack_bolt.async_app import AsyncApp + +from listeners import actions, events, views + + +def register_listeners(app: AsyncApp): + actions.register(app) + events.register(app) + views.register(app) diff --git a/claude-agent-sdk/listeners/actions/__init__.py b/claude-agent-sdk/listeners/actions/__init__.py new file mode 100644 index 0000000..f024df8 --- /dev/null +++ b/claude-agent-sdk/listeners/actions/__init__.py @@ -0,0 +1,11 @@ +import re + +from slack_bolt.async_app import AsyncApp + +from .category_buttons import handle_category_button +from .feedback import handle_feedback + + +def register(app: AsyncApp): + app.action(re.compile(r"^category_"))(handle_category_button) + app.action("feedback")(handle_feedback) diff --git a/claude-agent-sdk/listeners/actions/category_buttons.py b/claude-agent-sdk/listeners/actions/category_buttons.py new file mode 100644 index 0000000..339026d --- /dev/null +++ b/claude-agent-sdk/listeners/actions/category_buttons.py @@ -0,0 +1,21 @@ +from logging import Logger + +from slack_bolt import Ack +from slack_sdk.web.async_client import AsyncWebClient + +from listeners.views.modal_builder import build_issue_modal + + +async def handle_category_button( + ack: Ack, body: dict, client: AsyncWebClient, logger: Logger +): + """Open the issue submission modal when a category button is clicked.""" + await ack() + + try: + category = body["actions"][0]["value"] + trigger_id = body["trigger_id"] + modal = build_issue_modal(category) + await client.views_open(trigger_id=trigger_id, view=modal) + except Exception as e: + logger.exception(f"Failed to open issue modal: {e}") diff --git a/claude-agent-sdk/listeners/actions/feedback.py b/claude-agent-sdk/listeners/actions/feedback.py new file mode 100644 index 0000000..eaa0286 --- /dev/null +++ b/claude-agent-sdk/listeners/actions/feedback.py @@ -0,0 +1,36 @@ +from logging import Logger + +from slack_bolt import Ack +from slack_sdk.web.async_client import AsyncWebClient + + +async def handle_feedback(ack: Ack, body: dict, client: AsyncWebClient, logger: Logger): + """Handle thumbs up/down feedback on Casey's responses.""" + await ack() + + try: + channel_id = body["channel"]["id"] + user_id = body["user"]["id"] + message_ts = body["message"]["ts"] + feedback_value = body["actions"][0]["value"] + + if feedback_value == "good-feedback": + await client.chat_postEphemeral( + channel=channel_id, + user=user_id, + thread_ts=message_ts, + text="Glad that was helpful! :tada:", + ) + else: + await client.chat_postEphemeral( + channel=channel_id, + user=user_id, + thread_ts=message_ts, + text="Sorry that wasn't helpful. :slightly_frowning_face: Try rephrasing your question or I can create a support ticket for you.", + ) + + logger.debug( + f"Feedback received: value={feedback_value}, message_ts={message_ts}" + ) + except Exception as e: + logger.exception(f"Failed to handle feedback: {e}") diff --git a/claude-agent-sdk/listeners/events/__init__.py b/claude-agent-sdk/listeners/events/__init__.py new file mode 100644 index 0000000..3cd7980 --- /dev/null +++ b/claude-agent-sdk/listeners/events/__init__.py @@ -0,0 +1,11 @@ +from slack_bolt.async_app import AsyncApp + +from .app_home_opened import handle_app_home_opened +from .app_mentioned import handle_app_mentioned +from .message_im import handle_message_im + + +def register(app: AsyncApp): + app.event("app_home_opened")(handle_app_home_opened) + app.event("app_mention")(handle_app_mentioned) + app.event("message")(handle_message_im) diff --git a/claude-agent-sdk/listeners/events/app_home_opened.py b/claude-agent-sdk/listeners/events/app_home_opened.py new file mode 100644 index 0000000..c2a804d --- /dev/null +++ b/claude-agent-sdk/listeners/events/app_home_opened.py @@ -0,0 +1,15 @@ +from logging import Logger + +from slack_sdk.web.async_client import AsyncWebClient + +from listeners.views.app_home_builder import build_app_home_view + + +async def handle_app_home_opened(client: AsyncWebClient, event: dict, logger: Logger): + """Publish the App Home view when a user opens the app's Home tab.""" + try: + user_id = event["user"] + view = build_app_home_view() + await client.views_publish(user_id=user_id, view=view) + except Exception as e: + logger.exception(f"Failed to publish App Home: {e}") diff --git a/claude-agent-sdk/listeners/events/app_mentioned.py b/claude-agent-sdk/listeners/events/app_mentioned.py new file mode 100644 index 0000000..b09c535 --- /dev/null +++ b/claude-agent-sdk/listeners/events/app_mentioned.py @@ -0,0 +1,116 @@ +import random +import re +from logging import Logger + +from slack_bolt.context.say.async_say import AsyncSay +from slack_sdk.web.async_client import AsyncWebClient + +from agent import run_casey_agent +from conversation import session_store +from listeners.views.feedback_block import create_feedback_block + +RESOLUTION_PHRASES = [ + "resolved", + "that should fix", + "you're all set", + "should be working now", + "has been reset", + "ticket created", +] + +CONTEXTUAL_EMOJIS = ["+1", "raised_hands", "rocket", "tada", "bulb", "fire"] + + +async def handle_app_mentioned( + client: AsyncWebClient, event: dict, logger: Logger, say: AsyncSay +): + """Handle @Casey mentions in channels.""" + try: + channel_id = event["channel"] + text = event.get("text", "") + thread_ts = event.get("thread_ts") or event["ts"] + + # Strip the bot mention from the text + cleaned_text = re.sub(r"<@[A-Z0-9]+>", "", text).strip() + + if not cleaned_text: + await say( + text="Hey there! How can I help you? Describe your IT issue and I'll do my best to assist.", + thread_ts=thread_ts, + ) + return + + # Set assistant thread status with loading messages + await client.assistant_threads_setStatus( + channel_id=channel_id, + thread_ts=thread_ts, + status="Thinking...", + loading_messages=[ + "Teaching the hamsters to type faster…", + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Polishing up the response just for you…", + "Convincing the AI to stop overthinking…", + ], + ) + + # Add eyes reaction only to the first message (not threaded replies) + if not event.get("thread_ts"): + await client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="eyes", + ) + + # Get session ID for conversation context + existing_session_id = session_store.get_session(channel_id, thread_ts) + + # Run the agent + response_text, new_session_id = await run_casey_agent( + cleaned_text, session_id=existing_session_id + ) + + # Post response in thread with feedback buttons + feedback_blocks = create_feedback_block() + response_blocks = [ + { + "type": "markdown", + "text": response_text, + }, + *feedback_blocks, + ] + await client.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + text=response_text, + blocks=response_blocks, + ) + + # Store session ID for future context + if new_session_id: + session_store.set_session(channel_id, thread_ts, new_session_id) + + # ~20% chance contextual emoji (lower than DM to be less noisy) + if random.random() < 0.2: + emoji = random.choice(CONTEXTUAL_EMOJIS) + await client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name=emoji, + ) + + # Check for resolution phrases + output_lower = response_text.lower() + if any(phrase in output_lower for phrase in RESOLUTION_PHRASES): + await client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="white_check_mark", + ) + + except Exception as e: + logger.exception(f"Failed to handle app mention: {e}") + await say( + text=f":warning: Something went wrong! ({e})", + thread_ts=event.get("thread_ts") or event["ts"], + ) diff --git a/claude-agent-sdk/listeners/events/message_im.py b/claude-agent-sdk/listeners/events/message_im.py new file mode 100644 index 0000000..3c8d46c --- /dev/null +++ b/claude-agent-sdk/listeners/events/message_im.py @@ -0,0 +1,113 @@ +import random +from logging import Logger + +from slack_bolt.context.say.async_say import AsyncSay +from slack_sdk.web.async_client import AsyncWebClient + +from agent import run_casey_agent +from conversation import session_store +from listeners.views.feedback_block import create_feedback_block + +RESOLUTION_PHRASES = [ + "resolved", + "that should fix", + "you're all set", + "should be working now", + "has been reset", + "ticket created", +] + +CONTEXTUAL_EMOJIS = ["+1", "raised_hands", "rocket", "tada", "bulb", "fire"] + + +async def handle_message_im( + client: AsyncWebClient, event: dict, logger: Logger, say: AsyncSay +): + """Handle direct messages sent to Casey.""" + # Skip bot messages and message subtypes (edits, deletes, etc.) + if event.get("bot_id") or event.get("subtype"): + return + + # Only handle IM channel type + if event.get("channel_type") != "im": + return + + try: + channel_id = event["channel"] + text = event.get("text", "") + thread_ts = event.get("thread_ts") or event["ts"] + + # Set assistant thread status with loading messages + await client.assistant_threads_setStatus( + channel_id=channel_id, + thread_ts=thread_ts, + status="Thinking...", + loading_messages=[ + "Teaching the hamsters to type faster…", + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Polishing up the response just for you…", + "Convincing the AI to stop overthinking…", + ], + ) + + # Add eyes reaction only to the first message (not threaded replies) + if not event.get("thread_ts"): + await client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="eyes", + ) + + # Get session ID for conversation context + existing_session_id = session_store.get_session(channel_id, thread_ts) + + # Run the agent + response_text, new_session_id = await run_casey_agent( + text, session_id=existing_session_id + ) + + # Post response in thread with feedback buttons + feedback_blocks = create_feedback_block() + response_blocks = [ + { + "type": "markdown", + "text": response_text, + }, + *feedback_blocks, + ] + await client.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + text=response_text, + blocks=response_blocks, + ) + + # Store session ID for future context + if new_session_id: + session_store.set_session(channel_id, thread_ts, new_session_id) + + # ~30% chance contextual emoji + if random.random() < 0.3: + emoji = random.choice(CONTEXTUAL_EMOJIS) + await client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name=emoji, + ) + + # Check for resolution phrases + output_lower = response_text.lower() + if any(phrase in output_lower for phrase in RESOLUTION_PHRASES): + await client.reactions_add( + channel=channel_id, + timestamp=event["ts"], + name="white_check_mark", + ) + + except Exception as e: + logger.exception(f"Failed to handle DM: {e}") + await say( + text=f":warning: Something went wrong! ({e})", + thread_ts=event.get("thread_ts") or event.get("ts"), + ) diff --git a/claude-agent-sdk/listeners/views/__init__.py b/claude-agent-sdk/listeners/views/__init__.py new file mode 100644 index 0000000..a85fdc5 --- /dev/null +++ b/claude-agent-sdk/listeners/views/__init__.py @@ -0,0 +1,7 @@ +from slack_bolt.async_app import AsyncApp + +from .issue_modal import handle_issue_submission + + +def register(app: AsyncApp): + app.view("issue_submission")(handle_issue_submission) diff --git a/claude-agent-sdk/listeners/views/app_home_builder.py b/claude-agent-sdk/listeners/views/app_home_builder.py new file mode 100644 index 0000000..cc2c6af --- /dev/null +++ b/claude-agent-sdk/listeners/views/app_home_builder.py @@ -0,0 +1,83 @@ +CATEGORIES = [ + { + "action_id": "category_password_reset", + "text": ":closed_lock_with_key: Password Reset", + "value": "Password Reset", + }, + { + "action_id": "category_access_request", + "text": ":key: Access Request", + "value": "Access Request", + }, + { + "action_id": "category_software_help", + "text": ":computer: Software Help", + "value": "Software Help", + }, + { + "action_id": "category_network_issues", + "text": ":globe_with_meridians: Network Issues", + "value": "Network Issues", + }, + { + "action_id": "category_something_else", + "text": ":speech_balloon: Something Else", + "value": "Something Else", + }, +] + + +def build_app_home_view() -> dict: + """Build the App Home Block Kit view with category buttons.""" + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Hey there :wave: I'm Casey, your IT helpdesk agent.", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + "I can help you troubleshoot technical issues, reset passwords, " + "check system status, and create support tickets.\n\n" + "*Choose a category below to get started*, or send me a direct message anytime." + ), + }, + }, + {"type": "divider"}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": cat["text"], + "emoji": True, + }, + "action_id": cat["action_id"], + "value": cat["value"], + } + for cat in CATEGORIES + ], + }, + {"type": "divider"}, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "You can also mention me in any channel with `@Casey` or send me a DM.", + } + ], + }, + ] + + return { + "type": "home", + "blocks": blocks, + } diff --git a/claude-agent-sdk/listeners/views/feedback_block.py b/claude-agent-sdk/listeners/views/feedback_block.py new file mode 100644 index 0000000..afe9661 --- /dev/null +++ b/claude-agent-sdk/listeners/views/feedback_block.py @@ -0,0 +1,29 @@ +from slack_sdk.models.blocks import ( + Block, + ContextActionsBlock, + FeedbackButtonObject, + FeedbackButtonsElement, +) + + +def create_feedback_block() -> list[Block]: + """Create feedback block with thumbs up/down buttons.""" + return [ + ContextActionsBlock( + elements=[ + FeedbackButtonsElement( + action_id="feedback", + positive_button=FeedbackButtonObject( + text="Good Response", + accessibility_label="Submit positive feedback on this response", + value="good-feedback", + ), + negative_button=FeedbackButtonObject( + text="Bad Response", + accessibility_label="Submit negative feedback on this response", + value="bad-feedback", + ), + ) + ] + ) + ] diff --git a/claude-agent-sdk/listeners/views/issue_modal.py b/claude-agent-sdk/listeners/views/issue_modal.py new file mode 100644 index 0000000..5833635 --- /dev/null +++ b/claude-agent-sdk/listeners/views/issue_modal.py @@ -0,0 +1,82 @@ +from logging import Logger + +from slack_bolt import Ack +from slack_sdk.web.async_client import AsyncWebClient + +from agent import run_casey_agent +from conversation import session_store +from listeners.views.feedback_block import create_feedback_block + + +async def handle_issue_submission( + ack: Ack, body: dict, client: AsyncWebClient, logger: Logger +): + """Handle modal submission: open DM, post issue, and run Casey agent.""" + await ack() + + try: + user_id = body["user"]["id"] + values = body["view"]["state"]["values"] + category = values["category_block"]["category_select"]["selected_option"][ + "value" + ] + description = values["description_block"]["description_input"]["value"] + + # Open a DM with the user + dm = await client.conversations_open(users=[user_id]) + channel_id = dm["channel"]["id"] + + # Post the initial message with category and description + user_message = f"*Category:* {category}\n*Description:* {description}" + initial = await client.chat_postMessage( + channel=channel_id, + text=user_message, + ) + thread_ts = initial["ts"] + + # Set assistant thread status with loading messages + await client.assistant_threads_setStatus( + channel_id=channel_id, + thread_ts=thread_ts, + status="Thinking...", + loading_messages=[ + "Teaching the hamsters to type faster…", + "Untangling the internet cables…", + "Consulting the office goldfish…", + "Polishing up the response just for you…", + "Convincing the AI to stop overthinking…", + ], + ) + + # Add eyes reaction + await client.reactions_add( + channel=channel_id, + timestamp=thread_ts, + name="eyes", + ) + + # Run the agent + response_text, new_session_id = await run_casey_agent(user_message) + + # Post the response in thread with feedback buttons + feedback_blocks = create_feedback_block() + response_blocks = [ + { + "type": "markdown", + "text": response_text, + }, + *feedback_blocks, + ] + await client.chat_postMessage( + channel=channel_id, + thread_ts=thread_ts, + text=response_text, + blocks=response_blocks, + ) + + # Store session ID for future context + if new_session_id: + session_store.set_session(channel_id, thread_ts, new_session_id) + + except Exception as e: + logger.exception(f"Failed to handle issue submission: {e}") diff --git a/claude-agent-sdk/listeners/views/modal_builder.py b/claude-agent-sdk/listeners/views/modal_builder.py new file mode 100644 index 0000000..1c9316a --- /dev/null +++ b/claude-agent-sdk/listeners/views/modal_builder.py @@ -0,0 +1,60 @@ +from listeners.views.app_home_builder import CATEGORIES + + +def build_issue_modal(category: str) -> dict: + """Build the issue submission modal pre-filled with the selected category. + + Args: + category: The pre-selected category value from the button click. + """ + category_options = [ + { + "text": {"type": "plain_text", "text": cat["value"], "emoji": True}, + "value": cat["value"], + } + for cat in CATEGORIES + ] + + initial_option = next( + (opt for opt in category_options if opt["value"] == category), + category_options[0], + ) + + return { + "type": "modal", + "callback_id": "issue_submission", + "title": {"type": "plain_text", "text": "Submit an Issue"}, + "submit": {"type": "plain_text", "text": "Submit"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "blocks": [ + { + "type": "input", + "block_id": "category_block", + "element": { + "type": "static_select", + "action_id": "category_select", + "placeholder": { + "type": "plain_text", + "text": "Select a category", + }, + "options": category_options, + "initial_option": initial_option, + }, + "label": {"type": "plain_text", "text": "Category"}, + }, + { + "type": "input", + "block_id": "description_block", + "element": { + "type": "plain_text_input", + "action_id": "description_input", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "Describe your issue in detail...", + }, + }, + "label": {"type": "plain_text", "text": "Description"}, + }, + ], + } diff --git a/claude-agent-sdk/manifest.json b/claude-agent-sdk/manifest.json new file mode 100644 index 0000000..0ac8b2e --- /dev/null +++ b/claude-agent-sdk/manifest.json @@ -0,0 +1,51 @@ +{ + "display_information": { + "name": "Casey - Claude Agent SDK" + }, + "features": { + "assistant_view": { + "assistant_description": "Hi, I am an agent built using Bolt for Python. I am here to help you out!", + "suggested_prompts": [] + }, + "app_home": { + "home_tab_enabled": true, + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false + }, + "bot_user": { + "display_name": "Casey - Claude Agent SDK", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "app_mentions:read", + "chat:write", + "im:history", + "im:read", + "im:write", + "reactions:write", + "reactions:read", + "users:read", + "assistant:write" + ] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "app_home_opened", + "app_mention", + "assistant_thread_started", + "message.im" + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": true, + "socket_mode_enabled": true, + "token_rotation_enabled": false + } +} diff --git a/claude-agent-sdk/pyproject.toml b/claude-agent-sdk/pyproject.toml new file mode 100644 index 0000000..af8f614 --- /dev/null +++ b/claude-agent-sdk/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "bolt-python-support-agent-claude" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "slack-sdk==3.40.0", + "slack-bolt==1.27.0", + "slack-cli-hooks<1.0.0", + "claude-agent-sdk>=0.1.36", + "aiohttp>=3.13.3", + "python-dotenv==1.2.1", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "ruff==0.15.1", +] + +[tool.setuptools.packages.find] +include = ["agent*", "listeners*", "conversation*"] + +[tool.ruff] +[tool.ruff.lint] +[tool.ruff.format] + +[tool.pytest.ini_options] +testpaths = ["tests"] +log_file = "logs/pytest.log" +log_file_level = "DEBUG" +log_format = "%(asctime)s %(levelname)s %(message)s" +log_date_format = "%Y-%m-%d %H:%M:%S" diff --git a/claude-agent-sdk/requirements.txt b/claude-agent-sdk/requirements.txt new file mode 100644 index 0000000..eef2b03 --- /dev/null +++ b/claude-agent-sdk/requirements.txt @@ -0,0 +1,9 @@ +slack-sdk==3.40.0 +slack-bolt==1.27.0 +slack-cli-hooks<1.0.0 +claude-agent-sdk>=0.1.36 +aiohttp>=3.13.3 +python-dotenv==1.2.1 + +pytest +ruff==0.15.1 diff --git a/claude-agent-sdk/tests/__init__.py b/claude-agent-sdk/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 51998f1f101328697d1792ce961e94968458964a Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Thu, 19 Feb 2026 11:28:34 -0800 Subject: [PATCH 2/3] fix: alphabetize implementation orderings in CLAUDE.md --- .claude/CLAUDE.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index db44334..5e29109 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -6,15 +6,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co A monorepo containing three parallel implementations of **Casey**, an AI-powered IT helpdesk agent for Slack built with Bolt for Python. All implementations are functionally identical from the Slack user's perspective but use different AI agent frameworks: -- `pydantic-ai/` — Built with **Pydantic AI** -- `openai-agents-sdk/` — Built with **OpenAI Agents SDK** - `claude-agent-sdk/` — Built with **Claude Agent SDK** +- `openai-agents-sdk/` — Built with **OpenAI Agents SDK** +- `pydantic-ai/` — Built with **Pydantic AI** All tool data (knowledge base, tickets, password resets, system status, permissions) is hardcoded for demo purposes. ## Commands -All commands must be run from within the respective project directory (`pydantic-ai/`, `openai-agents-sdk/`, or `claude-agent-sdk/`). +All commands must be run from within the respective project directory (`claude-agent-sdk/`, `openai-agents-sdk/`, or `pydantic-ai/`). ```sh # Run the app (requires .env with OPENAI_API_KEY or ANTHROPIC_API_KEY; Slack tokens optional with CLI) @@ -33,14 +33,14 @@ pytest ``` .github/ # Shared CI workflows and dependabot config -pydantic-ai/ # Pydantic AI implementation -openai-agents-sdk/ # OpenAI Agents SDK implementation claude-agent-sdk/ # Claude Agent SDK implementation +openai-agents-sdk/ # OpenAI Agents SDK implementation +pydantic-ai/ # Pydantic AI implementation ``` CI runs ruff lint/format checks against all directories via a matrix strategy in `.github/workflows/ruff.yml`. Dependabot monitors `requirements.txt` in all directories independently. -## Architecture (shared across both implementations) +## Architecture (shared across all implementations) Three-layer design: **app.py** → **listeners/** → **agent/** @@ -61,16 +61,16 @@ Each sub-package has a `register(app)` function called from `listeners/__init__. ## Key Differences Between Implementations -| Aspect | Pydantic AI | OpenAI Agents SDK | Claude Agent SDK | -|--------|-------------|-------------------|-----------------| +| Aspect | Claude Agent SDK | OpenAI Agents SDK | Pydantic AI | +|--------|-----------------|-------------------|-------------| | Agent file | `agent/casey.py` | `agent/support_agent.py` | `agent/casey.py` | -| App type | `App` (sync) | `App` (sync) | `AsyncApp` (fully async) | -| Agent definition | `Agent(deps_type=CaseyDeps)` | `Agent[CaseyDeps](model="gpt-4o-mini")` | `ClaudeSDKClient` with `ClaudeAgentOptions` | -| Model config | Passed at runtime via `run_sync(model=DEFAULT_MODEL)` | Set directly on agent constructor | Managed by SDK (Claude models) | -| Tool definition | Plain async functions | `@function_tool` decorated functions | `@tool` decorated functions via MCP server | -| Tool context param | `RunContext[CaseyDeps]` | `RunContextWrapper[CaseyDeps]` | `args` dict (no context param) | -| Execution | `casey_agent.run_sync(text, model=..., deps=..., message_history=...)` | `Runner.run_sync(casey_agent, input=..., context=...)` | `await run_casey_agent(text, session_id=...)` | -| Result output | `result.output` | `result.final_output` | `response_text` from collected `TextBlock.text` | -| Conversation history | `list[ModelMessage]` stored locally | `list` stored locally | Session-based via `resume` (server-side) | -| API key env var | `OPENAI_API_KEY` | `OPENAI_API_KEY` | `ANTHROPIC_API_KEY` | +| App type | `AsyncApp` (fully async) | `App` (sync) | `App` (sync) | +| Agent definition | `ClaudeSDKClient` with `ClaudeAgentOptions` | `Agent[CaseyDeps](model="gpt-4o-mini")` | `Agent(deps_type=CaseyDeps)` | +| Model config | Managed by SDK (Claude models) | Set directly on agent constructor | Passed at runtime via `run_sync(model=DEFAULT_MODEL)` | +| Tool definition | `@tool` decorated functions via MCP server | `@function_tool` decorated functions | Plain async functions | +| Tool context param | `args` dict (no context param) | `RunContextWrapper[CaseyDeps]` | `RunContext[CaseyDeps]` | +| Execution | `await run_casey_agent(text, session_id=...)` | `Runner.run_sync(casey_agent, input=..., context=...)` | `casey_agent.run_sync(text, model=..., deps=..., message_history=...)` | +| Result output | `response_text` from collected `TextBlock.text` | `result.final_output` | `result.output` | +| Conversation history | Session-based via `resume` (server-side) | `list` stored locally | `list[ModelMessage]` stored locally | +| API key env var | `ANTHROPIC_API_KEY` | `OPENAI_API_KEY` | `OPENAI_API_KEY` | | Feedback blocks | Native `FeedbackButtonsElement` | Native `FeedbackButtonsElement` | Native `FeedbackButtonsElement` | From f50601b895e37ca3142612ebe57f33bf3feca657 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Thu, 19 Feb 2026 11:35:10 -0800 Subject: [PATCH 3/3] docs: switch framework table to row-based layout in README --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5d91b32..40f294b 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ Built with [Bolt for Python](https://docs.slack.dev/tools/bolt-python/). This repo contains the same app built with three different AI agent frameworks. Pick the one that fits your stack: -| | [Pydantic AI](./pydantic-ai/) | [OpenAI Agents SDK](./openai-agents-sdk/) | [Claude Agent SDK](./claude-agent-sdk/) | -|---|---|---|---| -| **Framework** | [pydantic-ai](https://ai.pydantic.dev/) | [openai-agents](https://openai.github.io/openai-agents-python/) | [claude-agent-sdk](https://docs.anthropic.com/en/docs/agents/claude-agent-sdk) | -| **Get started** | [View README](./pydantic-ai/README.md) | [View README](./openai-agents-sdk/README.md) | [View README](./claude-agent-sdk/README.md) | +| App | Framework | Get started | Directory | +|-----|-----------|-------------|-----------| +| **Claude Agent SDK** | [claude-agent-sdk](https://docs.anthropic.com/en/docs/agents/claude-agent-sdk) | [View README](./claude-agent-sdk/README.md) | `claude-agent-sdk/` | +| **OpenAI Agents SDK** | [openai-agents](https://openai.github.io/openai-agents-python/) | [View README](./openai-agents-sdk/README.md) | `openai-agents-sdk/` | +| **Pydantic AI** | [pydantic-ai](https://ai.pydantic.dev/) | [View README](./pydantic-ai/README.md) | `pydantic-ai/` | All implementations share the same Slack listener layer, the same five simulated IT tools, and the same user experience. The only difference is how the agent is defined and executed under the hood.