# python-slack-agents: Complete Reference

> A Python framework for deploying AI agents as Slack bots.
> Each agent is a YAML config and a system prompt — pick your LLM,
> connect some MCP tools, and `slack-agents run`.

- **Package:** `pip install python-slack-agents`
- **CLI entry point:** `slack-agents`
- **Python:** >= 3.12
- **License:** Apache 2.0
- **Source:** https://github.com/CompareNetworks/python-slack-agents

## How to read this document

This file is a concatenation of all documentation files,
designed to be consumed in a single read.
The sections below correspond to individual doc files
in the `docs/` directory.

### Key concepts

- **Config-driven:** each agent is a directory with
  `config.yaml` + `system_prompt.txt`.
  All behavior is configured in YAML.
- **Plugin pattern:** every pluggable component (LLM, storage,
  tools, access) uses a `type` field with a dotted Python import
  path pointing to a module with a `Provider` class. All other
  config keys are passed as kwargs to `Provider.__init__`.
- **Two kinds of tool providers:** `BaseToolProvider` (tools the
  LLM calls) and `BaseFileImporterProvider` (file handlers the
  *framework* calls automatically — invisible to the LLM). Both
  are configured under `tools:` in config.yaml.
- **Environment variables:** `{ENV_VAR}` patterns in config values
  are resolved from environment variables at startup.

---

# Setup

## Prerequisites

- Python 3.12+
- A Slack workspace (all plans supported, including free)
- API key for your LLM provider (Anthropic and/or OpenAI)

## New Project

```bash
mkdir my-agents && cd my-agents
python3 -m venv .venv
source .venv/bin/activate
pip install python-slack-agents

# Scaffold the project
slack-agents init my-agents

# Install framework (and any deps) from requirements.txt
pip install -r requirements.txt

# Add your tokens
cp .env.example .env

# Run the hello-world agent
slack-agents run agents/hello-world
```

*Working on the framework itself? See [CONTRIBUTING.md](../CONTRIBUTING.md).*

## Environment Variables

```bash
cp .env.example .env
```

Edit `.env` with your tokens:

```
SLACK_BOT_TOKEN=xoxb-...  # see below
SLACK_APP_TOKEN=xapp-...  # see below
ANTHROPIC_API_KEY=sk-ant-...
```

## Creating a Slack App

1. Go to [api.slack.com/apps](https://api.slack.com/apps)
2. Create a new app from the manifest in `docs/slack-app-manifest.json`
- Update 4 placeholders starting with ** for names and descriptions
- Settings > Basic Information > App-Level Tokens > Generate Tokens and Scopes
  - token name: "slack-agents-app-token"
  - add scope: add "connections:write"
  - click "Generate"
  - Copy: App Token (eg, SLACK_APP_TOKEN=xapp-...)
- Settings > Install App
  - Copy: Bot User OAuth Token (eg, SLACK_BOT_TOKEN=xoxb-...)
3. If App does not appear in your Slack client:
  - ... > Tools > Apps > (search by name and add the app)

## Download Fonts

PDF generation requires DejaVu Sans for Unicode support:

```bash
python -m slack_agents.scripts.download_fonts
```

This downloads `DejaVuSans.ttf` and `DejaVuSans-Bold.ttf` into `fonts/` (~700KB total). Without these fonts, PDF generation falls back to Helvetica (latin-1 only).

## Optional: PostgreSQL

For conversation persistence via PostgreSQL, update your agent's `config.yaml`:

```yaml
storage:
  type: slack_agents.storage.postgres
  url: "{DATABASE_URL}"
```

Set `DATABASE_URL` in your `.env` file.

---

# Creating an Agent

Each agent lives in its own directory with two files:

## Directory Structure

```
agents/my-agent/
├── config.yaml
└── system_prompt.txt
```

The directory name (e.g. `my-agent`) is used by the CLI and as the default Docker image name. It has no effect on how the agent appears in Slack — the bot's display name is set when you create the Slack app and can be changed anytime in the [Slack app settings](https://api.slack.com/apps) under "App Home".

## config.yaml

```yaml
version: "1.0.0"
schema: "slack-agents/v1"

slack:
  bot_token: "{SLACK_BOT_TOKEN}"
  app_token: "{SLACK_APP_TOKEN}"

access:
  type: slack_agents.access.allow_all

llm:
  type: slack_agents.llm.anthropic
  model: claude-sonnet-4-6
  api_key: "{ANTHROPIC_API_KEY}"
  max_tokens: 4096
  max_input_tokens: 200000

storage:
  type: slack_agents.storage.sqlite
  path: ":memory:"

tools:
  import-documents:
    type: slack_agents.tools.file_importer
    allowed_functions: [".*"]
  my-mcp-server:
    type: slack_agents.tools.mcp_http
    url: "https://my-server.example.com/mcp"
    allowed_functions: [".*"]
```

### version (required)

A user-controlled string tracking changes to the agent's capabilities, system prompt, or configuration. We recommend semver (e.g. `"1.0.0"`, `"2.3.1"`) but any string is valid — the framework does not interpret it. The usage footer in Slack shows this version string instead of the model name. This version is also used as the Docker image tag when building with `slack-agents build-docker`.

### schema (required)

Identifies the config format version: `"slack-agents/v1"`. The framework uses this to determine if it can parse the config. If the config uses a schema newer than the installed version, startup fails with a clear error.

All `{ENV_VAR}` patterns are resolved from environment variables at startup.

## system_prompt.txt

Plain text file with the agent's system prompt:

```
You are a helpful assistant that specializes in...
```

## Running

```bash
slack-agents run agents/my-agent
```

## Slack App Setup

Each agent needs its own Slack app. Use the manifest in `docs/slack-app-manifest.json` as a starting point.

Key permissions needed:
- `app_mentions:read` — respond to @mentions
- `chat:write` — send messages
- `im:history`, `im:read`, `im:write` — handle DMs
- `files:read`, `files:write` — file attachments
- Socket Mode must be enabled

---

# LLM Providers

## Built-in Providers

Two providers ship with the framework: `slack_agents.llm.anthropic` (Claude) and `slack_agents.llm.openai` (OpenAI and compatible APIs).

### OpenAI-compatible providers

Many providers expose an OpenAI-compatible API (Mistral, Groq, Together, Ollama, vLLM, etc.). Use the built-in `slack_agents.llm.openai` provider with `base_url` to point at them:

```yaml
llm:
  type: slack_agents.llm.openai
  model: mistral-small-latest
  api_key: "{MISTRAL_API_KEY}"
  base_url: "https://api.mistral.ai/v1"
  max_tokens: 4096
  max_input_tokens: 32000
  input_cost_per_million: 0.1   # optional — USD per 1M input tokens
  output_cost_per_million: 0.3  # optional — USD per 1M output tokens
```

`input_cost_per_million` and `output_cost_per_million` are optional. When provided, they're used for cost estimation. When omitted, the built-in cost table is checked (covers native OpenAI models). If neither matches, cost estimation returns `None`.

## Adding a Custom Provider

LLM providers are Python modules that export a `Provider` class extending `BaseLLMProvider`.

### Example

```python
# my_llm/gemini.py
from slack_agents.llm.base import BaseLLMProvider, LLMResponse, Message, StreamEvent

class Provider(BaseLLMProvider):
    def __init__(self, model: str, api_key: str, max_tokens: int, max_input_tokens: int):
        self.model = model
        self.max_tokens = max_tokens
        self.max_input_tokens = max_input_tokens
        # Initialize your client here

    def estimate_cost(self, input_tokens, output_tokens,
                      cache_creation_input_tokens=0, cache_read_input_tokens=0):
        # Return estimated cost in USD, or None
        return None

    async def complete(self, messages, system_prompt="", tools=None):
        # Return LLMResponse
        ...

    async def stream(self, messages, system_prompt="", tools=None):
        # Yield StreamEvent objects
        ...
```

### Configuration

```yaml
llm:
  type: my_llm.gemini
  model: gemini-2.0-flash
  api_key: "{GEMINI_API_KEY}"
  max_tokens: 4096
  max_input_tokens: 200000
```

### Key Points

- Internal message format is Anthropic-style (content as list of typed blocks)
- Convert to your provider's format at the boundary (see `openai.py` for an example)
- `stream()` must yield `StreamEvent` objects with types: `text_delta`, `tool_use_start`, `tool_use_delta`, `tool_use_end`, `message_end`
- `estimate_cost()` returns USD cost or None if unknown

---

# Tools

There are two kinds of tool providers, both configured under `tools:` in `config.yaml`:

- **Tool providers** (`BaseToolProvider`) — tools the LLM can call during a conversation (e.g. search, export a PDF, run a calculation)
- **File importer providers** (`BaseFileImporterProvider`) — handlers that process files attached to Slack messages before they reach the LLM (e.g. extract text from a PDF, parse an Excel spreadsheet)

Both use the same `allowed_functions` regex filtering and are loaded as Python modules with a `Provider` class.

## Tool Providers

Tool providers give the LLM callable tools. Extend `BaseToolProvider`:

```python
# my_tools/calculator.py
from slack_agents.tools.base import (
    ERROR_INPUT_ERROR,
    RECOVERY_ABORT,
    BaseToolProvider,
    ToolResult,
    make_tool_error,
)

class Provider(BaseToolProvider):
    def __init__(self, allowed_functions: list[str]):
        super().__init__(allowed_functions)

    def _get_all_tools(self) -> list[dict]:
        return [
            {
                "name": "add",
                "description": "Add two numbers",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "a": {"type": "number"},
                        "b": {"type": "number"},
                    },
                    "required": ["a", "b"],
                },
            }
        ]

    async def call_tool(self, name, arguments, user_context, storage) -> ToolResult:
        if name == "add":
            result = arguments["a"] + arguments["b"]
            return {"content": str(result), "is_error": False, "files": []}
        return make_tool_error(
            error=ERROR_INPUT_ERROR,
            code="unknown_tool",
            tool=name,
            recovery=RECOVERY_ABORT,
            message=f"Tool {name!r} is not provided by this calculator.",
        )
```

### Key points

- `_get_all_tools()` returns tool definitions in Anthropic API format
- `allowed_functions` filtering is handled by the base class
- `call_tool(name, arguments, user_context, storage)` returns a `ToolResult` (`{"content": str, "is_error": bool, "files": list[OutputFile]}`)
- Files in the response are uploaded to Slack automatically
- `initialize()` and `close()` are optional lifecycle hooks
- For error returns, use `make_tool_error(...)` so the LLM gets a structured payload it can interpret consistently. See [Tool error schema](#tool-error-schema) below.

## File Importer Providers

File importer providers process files that users attach to Slack messages. They are invisible to the LLM — the framework calls them automatically to convert files into content the LLM can understand.

Extend `BaseFileImporterProvider`:

```python
# my_tools/csv_importer.py
from slack_agents import InputFile
from slack_agents.tools.base import BaseFileImporterProvider, ContentBlock, FileImportToolException

class Provider(BaseFileImporterProvider):
    def _get_all_tools(self) -> list[dict]:
        return [
            {
                "name": "import_csv",
                "mimes": {"text/csv"},
                "max_size": 5_000_000,
            }
        ]

    async def call_tool(self, name, arguments, user_context, storage) -> ContentBlock:
        if name == "import_csv":
            text = arguments["file_bytes"].decode("utf-8", errors="replace")
            return {"type": "text", "text": f"[File: {arguments['filename']}]\n\n{text}"}
        raise FileImportToolException(f"Unknown handler: {name}")
```

### Tool manifest fields

| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | Handler name, matched against `allowed_functions` (e.g. `import_csv`) |
| `mimes` | `set[str]` | MIME types this handler processes |
| `max_size` | `int` | Maximum file size in bytes |

### call_tool arguments

`call_tool()` receives an `InputFile` dict (with keys `file_bytes`, `mimetype`, `filename`) as the `arguments` parameter, plus `user_context` and `storage`. Return a `ContentBlock` dict that will be included in the user message sent to the LLM:

- Text: `{"type": "text", "text": "..."}`
- Image: `{"type": "image", "source": {"type": "base64", "media_type": "...", "data": "..."}}`
- Raise `FileImportToolException` if extraction fails (the framework catches this and logs the error)

### Built-in handlers

The built-in provider (`slack_agents.tools.file_importer`) handles PDF, DOCX, XLSX, PPTX, plain text, and images.

## MCP over HTTP

`slack_agents.tools.mcp_http` connects to any MCP server over HTTP. Tools are auto-discovered at startup.

```yaml
tools:
  my-mcp-server:
    type: slack_agents.tools.mcp_http
    url: "https://my-server.example.com/mcp"
    headers:
      Authorization: "Bearer {MCP_API_TOKEN}"
    allowed_functions:
      - "search_.*"
      - "get_document"
    init_retries: [5, 10, 30]
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `url` | `str` | required | MCP server endpoint |
| `headers` | `dict` | `{}` | HTTP headers sent with every request |
| `allowed_functions` | `list[str]` | required | Regex patterns to filter discovered tools |
| `init_retries` | `list[int]` | `[5, 10, 30]` | Seconds to wait between connection retries at startup. The server is tried once immediately, then once after each delay. Set to `[]` to disable retries. |

All MCP tool providers are initialized in parallel at startup. If any provider fails to connect after exhausting its retries, the agent exits with an error.

## MCP over HTTP with OAuth

`slack_agents.tools.mcp_http_oauth` is the OAuth-authenticated counterpart to
`mcp_http` — for MCP servers that require per-user OAuth 2.1 authentication
rather than a static API key. See [`docs/oauth.md`](oauth.md) for the full
guide. Minimal example:

```yaml
tools:
  my-mcp:
    type: slack_agents.tools.mcp_http_oauth
    url: "https://my-server.example.com/mcp"
    allowed_functions: [".*"]
```

This pulls in extra runtime requirements: `PUBLIC_URL`,
`OAUTH_SECRET_KEY`, and an in-process HTTP listener for OAuth callbacks. Read
the OAuth doc before configuring this in production.

## Tool error schema

When a tool returns `is_error: True`, every built-in tool emits a JSON-encoded
payload in `content` matching this shape, so the LLM consuming the result can
reason about the failure uniformly. Custom tools should produce the same shape
via the `make_tool_error` helper.

```json
{
  "error":    "<type>",        // required: e.g. "permission_denied"
  "code":     "<subtype>",     // optional: stable per-error sub-classification
  "tool":     "<tool name>",   // optional: which tool was being called
  "server":   "<server id>",   // optional: provider/server context
  "message":  "<human>",       // required: human-readable summary
  "recovery": "<action>",      // required: see below
  "details":  { ... }          // optional: free-form per-error-type
}
```

**Top-level error types** (`error`):

| Constant | When to use |
|---|---|
| `ERROR_PERMISSION_DENIED` | Auth/scope/role refusal — user-level |
| `ERROR_SYSTEM_ERROR` | Operational/library/transient failure |
| `ERROR_AUTH_SETUP_FAILED` | Auth flow itself broke (timeout, prompt failure) |
| `ERROR_INPUT_ERROR` | Bad call / unknown tool / bad arguments |

**Recovery actions** (`recovery`):

| Constant | Meaning |
|---|---|
| `RECOVERY_RETRY` | Transient or user-recoverable; just try again |
| `RECOVERY_CONTACT_ADMIN` | Requires realm/IdP/account admin |
| `RECOVERY_CONTACT_SUPPORT` | Framework operator/dev needs to investigate logs |
| `RECOVERY_ABORT` | Nothing to do for this call (LLM may try a different tool) |

**Helper signature:**

```python
from slack_agents.tools.base import make_tool_error  # plus ERROR_*, RECOVERY_*

return make_tool_error(
    error=ERROR_SYSTEM_ERROR,             # required
    message="Server returned 502.",       # required
    recovery=RECOVERY_RETRY,              # required
    code="upstream_502",                  # optional
    tool="search_docs",                   # optional
    server="my-mcp",                      # optional
    details={"status": 502},              # optional, schema-less
)
```

`details` is intentionally schema-less — each error type can carry whatever
structured fields the LLM benefits from seeing (missing scopes, exception
types, timestamps for support correlation, etc.).

## Configuration

Both types are configured the same way in `config.yaml`:

```yaml
tools:
  calculator:
    type: my_tools.calculator
    allowed_functions: [".*"]
  import-documents:
    type: slack_agents.tools.file_importer
    allowed_functions: [".*"]
```

The module must be importable from your Python path.

---

# Adding a Storage Backend

Storage backends are Python modules that export a `Provider` class extending `BaseStorageProvider`.

## Two-level API

### 1. Required: 6 abstract primitives

Every backend **must** implement these. They are sufficient for a fully working system because all domain methods have default implementations built on them.

| Method | Description |
|--------|-------------|
| `get(namespace, key)` | Key-value read |
| `set(namespace, key, value)` | Key-value upsert |
| `delete(namespace, key)` | Key-value delete |
| `append(namespace, key, item)` | Append to an ordered list, returns item ID |
| `get_list(namespace, key)` | Read an ordered list |
| `query(namespace, filters)` | Equality-filter scan |

### 2. Optional: domain method overrides

Relational or indexed backends can override these for better performance:

- `get_or_create_conversation(...)` — conversation lifecycle
- `has_conversation(...)` — existence check
- `create_message(...)` — message creation
- `get_message_blocks(...)` — fetch blocks grouped by message
- `append_text_block(...)`, `append_file_block(...)`, `append_tool_block(...)`, `append_usage_block(...)` — block persistence
- `get_tool_call(tool_call_id)` — indexed tool-call lookup
- `upsert_heartbeat(...)`, `get_heartbeat(...)` — agent liveness
- `get_conversations_for_export(...)`, `get_messages_with_blocks(...)` — export queries
- `supports_export` property — whether export is available

The built-in PostgreSQL and SQLite providers override all of these with proper SQL.

## Minimal example: Redis

A Redis backend only needs the 6 primitives. All conversation management works automatically via the default implementations.

```python
# my_storage/redis.py
from slack_agents.storage.base import BaseStorageProvider

class Provider(BaseStorageProvider):
    def __init__(self, url: str):
        self._url = url

    async def initialize(self):
        # Connect to Redis
        ...

    async def get(self, namespace, key):
        ...

    async def set(self, namespace, key, value):
        ...

    async def delete(self, namespace, key):
        ...

    async def append(self, namespace, key, item):
        ...

    async def get_list(self, namespace, key):
        ...

    async def query(self, namespace, filters):
        ...

    async def close(self):
        ...
```

For better performance you could override specific domain methods — for example, `get_tool_call` with a Redis hash lookup by `tool_call_id` instead of scanning all blocks.

## Configuration

```yaml
storage:
  type: my_storage.redis
  url: "{REDIS_URL}"
```

## Key points

- Storage providers handle all persistence — the `ConversationManager` is a thin delegation layer
- `initialize()` is called at startup, `close()` at shutdown
- Non-relational backends only need the 6 abstract primitives
- Relational backends (PostgreSQL, SQLite) override domain methods with optimized SQL

---

# Access Control

Control which Slack users can interact with each agent. The `access` key is required in every agent's `config.yaml`.

## Configuration

### Allow all users

```yaml
access:
  type: slack_agents.access.allow_all
```

### Allow list

Restrict access to specific Slack user IDs:

```yaml
access:
  type: slack_agents.access.allow_list
  userid_list:
    - U1234567890
    - U9876543210
  deny_message: "You don't have access to this agent. Ask in #help-infra to request access."
```

The `deny_message` is shown as an ephemeral Slack message to users who are denied access.

## Writing a Custom Provider

Create a module with a `Provider` class that extends `BaseAccessProvider`:

```python
# my_package/access/ldap.py
from slack_agents import UserContext
from slack_agents.access.base import (
    AccessDenied,
    AccessGranted,
    BaseAccessProvider,
)


class Provider(BaseAccessProvider):
    def __init__(self, *, server: str, group: str) -> None:
        self._server = server
        self._group = group

    async def check_access(self, *, context: UserContext) -> AccessGranted:
        # Look up context["user_id"] in your LDAP directory
        # and check group membership
        if is_member:
            return AccessGranted()
        raise AccessDenied(f"You need to be in the {self._group} group.")
```

`check_access` returns `AccessGranted` on success and raises `AccessDenied` on denial. The exception message is shown to the user as an ephemeral Slack message.

`UserContext` and `AccessGranted` are `TypedDict`s. `UserContext` contains:
- `user_id` — the user ID (required)
- `user_name` — display name (optional)
- `user_handle` — user handle (optional)
- `channel_id` — the channel ID (optional)
- `channel_name` — the channel name (optional)

Then reference it in config:

```yaml
access:
  type: my_package.access.ldap
  server: ldap://ldap.example.com
  group: agents-users
```

Any extra keys beyond `type` are passed as keyword arguments to the `Provider` constructor.

---

# Canvas Tool

The canvas tool lets your agent create, read, update, and delete [Slack canvases](https://slack.com/features/canvas) — rich documents that live inside Slack. It exposes a simple file-like API: no section IDs or low-level operations needed.

## Setup

### 1. Add Slack scopes

In your Slack app settings (**OAuth & Permissions → Scopes → Bot Token Scopes**), add:

| Scope | Purpose |
|-------|---------|
| `canvases:read` | Read canvas content |
| `canvases:write` | Create, update, delete canvases and manage access |
| `files:read` | Read canvas content and check user access (uses `files.info` API) |

After adding scopes, reinstall the app to your workspace.

### 2. Configure the tool

Add the canvas tool to your agent's `config.yaml`:

```yaml
tools:
  canvas:
    type: slack_agents.tools.canvas
    bot_token: "{SLACK_BOT_TOKEN}"
    allowed_functions: [".*"]   # all canvas tools
```

To expose only specific tools:

```yaml
    allowed_functions:
      - "canvas_create"
      - "canvas_get"
      - "canvas_update"
```

### 3. Canvas file importer (optional)

To let users attach canvases to messages and have the agent read them automatically, add the canvas importer:

```yaml
tools:
  canvas-importer:
    type: slack_agents.tools.canvas_importer
    bot_token: "{SLACK_BOT_TOKEN}"
    allowed_functions: [".*"]
```

When a user attaches a canvas (mimetype `application/vnd.slack-docs`) to a message, the importer reads its markdown content via the Slack API and includes it in the conversation context. Authorization is enforced — the agent only reads canvases the requesting user can access.

## Authorization model

All canvas operations enforce **user-level permissions**. The agent acts as a delegate for the requesting user — it will not access canvases the user can't access themselves.

Access is resolved from `files.info` metadata (no extra storage or scopes needed):

| Check | Source field |
|-------|-------------|
| Is user the creator? | `user` / `canvas_creator_id` |
| Per-user access | `dm_mpdm_users_with_file_access` |
| Workspace-wide access | `org_or_workspace_access` |

**Access levels** (higher includes lower): `owner` > `write` > `read`

**Required access per tool:**

| Tool | Required |
|------|----------|
| `canvas_create` | — (no existing canvas) |
| `canvas_get` | read |
| `canvas_update` | write |
| `canvas_delete` | owner |
| `canvas_access_get` | read |
| `canvas_access_add` | owner |
| `canvas_access_remove` | owner |

If the user lacks sufficient access, the tool returns an error message explaining what access level is needed.

## Canvas content format

Canvas content is **markdown**. Supported elements:

- Headings (`#`, `##`, `###`)
- Bullet and numbered lists
- Tables
- Code blocks
- Block quotes
- Links
- Mentions (`<@U1234567890>`)
- Unfurls / embeds (`![](URL)`)

Block Kit is **not** supported in canvases.

## Available tools

| Tool | Description |
|------|-------------|
| `canvas_create` | Create a standalone canvas with title + content. |
| `canvas_get` | Get a canvas by ID. Returns title, full markdown content, and permalink. |
| `canvas_update` | Update a canvas — replace content, rename title, or both. |
| `canvas_delete` | Permanently delete a canvas. |
| `canvas_access_get` | Get sharing/access info for a canvas. |
| `canvas_access_add` | Grant read/write/owner access to users. Optionally set `org_access` for workspace-wide access. |
| `canvas_access_remove` | Remove access for users. |

## Example usage

**Create a canvas:**
> "Create a canvas titled 'Q1 Roadmap' with our milestone list"

**Read and update a canvas:**
> "Get the canvas F12345 and update it with the latest status"

**Share a canvas with specific users:**
> "Give users U123 and U456 write access to canvas F12345"

---

# User Context (Per-User Memory)

The user context tool gives each user a personal Slack canvas that stores their preferences and context across conversations. The agent checks it at the start of every conversation to personalize responses, and offers to save important context when users share preferences or corrections.

Users can also edit their canvas directly in Slack to add or update preferences.

## Setup

### 1. Add Slack scopes

In your Slack app settings (**OAuth & Permissions → Scopes → Bot Token Scopes**), add:

| Scope | Purpose |
|-------|---------|
| `canvases:read` | Read the user's context canvas |
| `canvases:write` | Create and update user context canvases |
| `files:read` | Read canvas content (uses `files.info` API) |

These are the same scopes required by the [canvas tool](canvas.md). After adding scopes, reinstall the app to your workspace.

### 2. Configure the tool

Add the user-context tool to your agent's `config.yaml`:

```yaml
tools:
  user-context:
    type: slack_agents.tools.user_context
    bot_token: "{SLACK_BOT_TOKEN}"
    max_tokens: 1000           # limit on context size
    allowed_functions: [".*"]
```

| Option | Default | Description |
|--------|---------|-------------|
| `bot_token` | *(required)* | Slack bot token with canvas scopes |
| `max_tokens` | `1000` | Maximum token budget for user context |
| `allowed_functions` | *(required)* | Regex patterns for which tools to expose |

## How it works

1. **At conversation start**, the agent calls `get_user_context` to load the user's saved preferences.
2. **During conversation**, if the user shares preferences or corrections worth remembering, the agent offers to save them via `set_user_context`.
3. **Canvas creation is lazy** — no canvas is created until the first `set_user_context` call. The canvas is titled `"{agent_name} ({user_name})"` and the user is granted write access.
4. **Users can edit directly** — the canvas is a regular Slack canvas that users can open and edit in Slack at any time.

## Available tools

| Tool | Params | Description |
|------|--------|-------------|
| `get_user_context` | *(none — uses conversation context)* | Load the user's saved context. Returns `{content, permalink}` or empty content. |
| `set_user_context` | `agent_name`, `content` | Save/replace the user's context. Creates the canvas on first use. |

## Storage

Canvas IDs are stored using the agent's storage backend with namespace `user_context_canvas`. The storage key includes the bot user ID to avoid collisions when multiple agents share a database.

## Example interaction

> **User:** I prefer concise bullet-point answers, not long paragraphs.
>
> **Agent:** Got it! Would you like me to save that preference so I remember it in future conversations?
>
> **User:** Yes please.
>
> **Agent:** Saved your preference. You can also edit it directly anytime:
> https://slack.com/docs/T.../F...

---

# CLI Reference

All commands are available as `slack-agents <command>`.

## init

Scaffold a new project in the current directory.

```bash
slack-agents init <project_name>
```

Creates `requirements.txt`, `src/<package>/`, `.env.example`, `.gitignore`, and a `hello-world` agent. Existing files are skipped with a warning.

## run

Start a Slack agent.

```bash
slack-agents run agents/<name>
```

Connects to Slack via Socket Mode, initializes storage and tools, and begins handling messages.

## healthcheck

Check whether an agent's WebSocket connection is healthy.

```bash
slack-agents healthcheck agents/<name>
```

Reads the heartbeat timestamp from storage (written every 10s by the agent). Exits 0 if the heartbeat is fresh (<60s), exits 1 otherwise.

Requires persistent storage (file-based SQLite or PostgreSQL). Designed for use as a Kubernetes liveness probe or similar health check.

## export-conversations

Export stored conversations to HTML.

```bash
slack-agents export-conversations agents/<name> --format=html [options]
```

Options:

| Flag | Description |
|------|-------------|
| `--format` | Export format (required, currently: `html`) |
| `--handle` | Filter by Slack user handle |
| `--date-from` | Filter start datetime (ISO format with timezone, e.g. `2026-01-01T00:00:00+00:00`) |
| `--date-to` | Filter end datetime (ISO format with timezone) |
| `--output` | Output directory (default: `./export-<agent-name>`) |

Requires persistent storage (file-based SQLite or PostgreSQL).

## export-usage

Export per-conversation usage data as CSV. One row per conversation with aggregated token counts, cost, and metadata.

```bash
slack-agents export-usage agents/<name> --format=csv --output=usage.csv [options]
```

Options:

| Flag | Description |
|------|-------------|
| `--format` | Export format (required, currently: `csv`) |
| `--handle` | Filter by Slack user handle |
| `--date-from` | Filter start datetime (ISO format with timezone, e.g. `2026-01-01T00:00:00+00:00`) |
| `--date-to` | Filter end datetime (ISO format with timezone) |
| `--output` | Output CSV file path (required) |

Requires persistent storage (file-based SQLite or PostgreSQL).

## build-docker

Build a Docker image for an agent.

```bash
slack-agents build-docker agents/<name> [options]
```

Options:

| Flag | Description |
|------|-------------|
| `--push REGISTRY` | Push image to registry after building (e.g. `registry.example.com`) |
| `--image-name NAME` | Custom image name (default: `slack-agents-<agent-dir-name>`) |
| `--platform` | Target platform (default: `linux/amd64`) |

The image tag is `<image-name>:<version>`, where version comes from `config.yaml`. The default image name is `slack-agents-<agent-dir-name>`. When `--push` is provided, the registry is prepended.

---

# Observability

Agents can export traces via [OpenTelemetry](https://opentelemetry.io/) (OTLP/HTTP). This works with any OTLP-compatible backend: Langfuse, Jaeger, Datadog, Grafana Tempo, etc.

Observability is configured per-agent in `config.yaml`. If the `observability` section is omitted, tracing is disabled.

## Configuration

Add an `observability` section to your agent's `config.yaml`:

```yaml
observability:
  endpoints:
    - type: otlp
      endpoint: "https://otel-collector.internal:4318/v1/traces"
      headers:
        - key: Authorization
          value: "Bearer {OTEL_TOKEN}"
      attributes:
        trace_name: "my.trace.name"
        user_id: "enduser.id"
        model: "gen_ai.response.model"
        input_tokens: "gen_ai.usage.input_tokens"
        output_tokens: "gen_ai.usage.output_tokens"
```

### Endpoint fields

| Field | Required | Description |
|-------|----------|-------------|
| `type` | yes | Endpoint type (currently `otlp`) |
| `endpoint` | yes | OTLP/HTTP endpoint URL |
| `headers` | no | List of `{key, value}` headers sent with each export |
| `basic_auth` | no | `{user, password}` — auto-constructs a `Basic` auth header |
| `attributes` | no | Semantic key to OTEL attribute name mapping (see below) |

### Attribute mapping

The `attributes` dict maps semantic keys used in the code to OTEL span attribute names expected by your backend. Only keys present in the mapping are set on spans — unmapped keys are silently ignored.

Available semantic keys:

| Semantic key | Set by | Description |
|-------------|--------|-------------|
| `trace_name` | bot.py | Agent name |
| `user_id` | bot.py | Slack user's display name |
| `session_id` | bot.py | `{channel_name}.{thread_id}` |
| `version` | bot.py | Agent version from config |
| `input` | bot.py | User message text |
| `output` | bot.py | Assistant response text |
| `observation_type` | @observe decorator | Span type (e.g. `"generation"`) |
| `model` | LLM providers | Model ID (e.g. `claude-sonnet-4-6`) |
| `input_tokens` | LLM providers | Total input token count (including cached) |
| `output_tokens` | LLM providers | Output token count |
| `usage` | LLM providers | Token breakdown as JSON: `{input, output, cache_read_input, cache_creation_input}` |

### Multiple endpoints

Each endpoint has its own attribute mapping. When sending to multiple backends, each backend's attributes are all set on the same span — backends ignore attributes they don't recognize.

```yaml
observability:
  endpoints:
    - type: otlp
      endpoint: "https://langfuse.example.com/api/public/otel/v1/traces"
      basic_auth:
        user: "{LANGFUSE_PUBLIC_KEY}"
        password: "{LANGFUSE_SECRET_KEY}"
      attributes:
        trace_name: "langfuse.trace.name"
        user_id: "langfuse.user.id"
        model: "langfuse.observation.model.name"
    - type: otlp
      endpoint: "https://jaeger.internal:4318/v1/traces"
      attributes:
        user_id: "enduser.id"
        model: "gen_ai.response.model"
```

## Langfuse

[Langfuse](https://langfuse.com) supports native OTLP ingestion. Use `basic_auth` with your Langfuse public/secret keys and point the endpoint at `/api/public/otel/v1/traces`.

```yaml
observability:
  endpoints:
    - type: otlp
      endpoint: "{LANGFUSE_HOST}/api/public/otel/v1/traces"
      basic_auth:
        user: "{LANGFUSE_PUBLIC_KEY}"
        password: "{LANGFUSE_SECRET_KEY}"
      attributes:
        trace_name: "langfuse.trace.name"
        user_id: "langfuse.user.id"
        session_id: "langfuse.session.id"
        version: "langfuse.version"
        observation_type: "langfuse.observation.type"
        input: "langfuse.observation.input"
        output: "langfuse.observation.output"
        model: "langfuse.observation.model.name"
        input_tokens: "gen_ai.usage.input_tokens"
        output_tokens: "gen_ai.usage.output_tokens"
        usage: "langfuse.observation.usage_details"
```

Add the credentials to `.env`:

```bash
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=https://cloud.langfuse.com
```

The `langfuse.*` attribute names are documented in [Langfuse's OpenTelemetry integration docs](https://langfuse.com/docs/integrations/opentelemetry).

## Architecture

The implementation is a thin wrapper around the OpenTelemetry SDK:

- **`observability.py`** creates a `TracerProvider` with one `OTLPSpanExporter` per endpoint
- **`@observe(name=...)`** decorator creates OTEL spans around functions (supports sync, async, and async generators)
- **`set_span_attrs()`** sets attributes on the current span using the configured mapping
- **`flush_trace()`** calls `TracerProvider.force_flush()`

The code has zero backend-specific knowledge — all attribute naming is driven by `config.yaml`.

---

# Deployment

## Overview

Each agent runs as a single long-running process connected to Slack via Socket Mode (WebSocket). One process = one agent = one Slack app.

All configuration is in `config.yaml`. Secrets use `{ENV_VAR}` placeholders resolved from environment variables at startup.

## Docker

Build a Docker image for any agent with the CLI:

```bash
slack-agents build-docker agents/my-agent
```

This produces an image tagged `slack-agents-my-agent:<version>` (version comes from `config.yaml`). The image runs `slack-agents run agent` on startup.

To use a custom image name:

```bash
slack-agents build-docker agents/my-agent --image-name my-bot
```

To push to a registry:

```bash
slack-agents build-docker agents/my-agent --push registry.example.com
```

### docker-compose

A minimal setup for running an agent locally or on a single server:

```yaml
services:
  my-agent:
    image: slack-agents-my-agent:1.0.0
    restart: unless-stopped
    env_file: .env
```

With PostgreSQL for persistent conversations:

```yaml
services:
  my-agent:
    image: slack-agents-my-agent:1.0.0
    restart: unless-stopped
    env_file: .env
    environment:
      DATABASE_URL: postgresql://agent:secret@db:5432/agents
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: agent
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: agents
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: pg_isready -U agent
      interval: 5s
      retries: 5

volumes:
  pgdata:
```

## Kubernetes

Socket Mode requires exactly one WebSocket connection per Slack app. Run each agent as a Deployment with **1 replica** (`replicas: 1`, or `minReplicas: 1` / `maxReplicas: 1` if using an autoscaler).

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-agent
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-agent
  template:
    metadata:
      labels:
        app: my-agent
    spec:
      containers:
        - name: agent
          image: registry.example.com/slack-agents-my-agent:1.0.0
          envFrom:
            - secretRef:
                name: my-agent-secrets
          livenessProbe:
            exec:
              command: ["slack-agents", "healthcheck", "agent"]
            initialDelaySeconds: 30
            periodSeconds: 30
          resources:
            requests:
              memory: 256Mi
              cpu: 100m
            limits:
              memory: 512Mi
```

### Secrets

Store tokens and API keys in a Kubernetes Secret and reference it via `envFrom`. The agent resolves `{ENV_VAR}` patterns in `config.yaml` from environment variables.

```bash
kubectl create secret generic my-agent-secrets \
  --from-literal=SLACK_BOT_TOKEN=xoxb-... \
  --from-literal=SLACK_APP_TOKEN=xapp-... \
  --from-literal=ANTHROPIC_API_KEY=sk-ant-...
```

### Health checks

The `slack-agents healthcheck` command checks the agent's WebSocket heartbeat (written every 10s to storage). It requires persistent storage (file-based SQLite or PostgreSQL). Use it as a liveness probe — Kubernetes will restart the pod if the connection drops.

## Multiple agents

Each agent is independent — its own Slack app, its own Docker image, its own deployment. To run several agents, repeat the pattern for each one. They share nothing at runtime.

---

# Organizing Your Agents

Agents are just directories with `config.yaml` and `system_prompt.txt`. Where you put them depends on your situation.

## Option 1: In the framework repo

If you're developing the framework itself, add agents directly to `agents/`. To keep private agents out of version control, use a gitignored directory instead:

```bash
slack-agents run agents-local/my-agent
```

## Option 2: Separate repository

For production agents with company-specific prompts, tools, and configs, create a standalone repository:

```bash
mkdir my-agents && cd my-agents
python3 -m venv .venv
source .venv/bin/activate
pip install python-slack-agents
slack-agents init my-agents
pip install -r requirements.txt
```

This scaffolds:

```
my-agents/
├── requirements.txt              # pins python-slack-agents
├── .env.example
├── .gitignore
├── agents/
│   └── hello-world/
│       ├── config.yaml
│       └── system_prompt.txt
└── src/
    └── my_agents/
        └── __init__.py           # add custom providers here
```

Your overlay is a **plain git repo** — not a Python package. You edit configs, commit, and run. There is no `pip install .` / `pip install -e .` step.

### Two conventions to know

- **`src/` holds custom Python.** On `slack-agents run`, the framework walks up from the agent directory looking for a `src/` sibling and prepends it to `sys.path`. Anything you put under `src/my_agents/...` becomes importable as `my_agents.…` — no install step.
- **`requirements.txt` pins your framework and any extra Python deps.** `pip install -r requirements.txt` is the only install command you ever run.

### Custom providers

Drop a module under `src/` and reference it in config:

```yaml
tools:
  internal-api:
    type: my_agents.tools.internal_api
    allowed_functions: [".*"]
    base_url: "{INTERNAL_API_URL}"
```

Create `src/my_agents/tools/internal_api.py` with a `Provider` class; the framework will find it on the next `slack-agents run`. No reinstall needed.

### Prefer pyproject.toml?

You can use a `pyproject.toml` instead of `requirements.txt` — but **do not add a `[project]` table**, or your overlay becomes an installable package again (the thing this design deliberately avoids). Use PEP 735 `[dependency-groups]`:

```toml
[dependency-groups]
default = ["python-slack-agents==X.Y.Z"]
```

Install with `pip install --group default` (pip ≥ 24.1) or `uv sync`.

### Docker

No custom Dockerfile needed — `python-slack-agents` bundles one that auto-detects your dependency file:

```bash
slack-agents build-docker agents/my-agent
slack-agents build-docker agents/my-agent --push registry.example.com
```

## Protecting secrets in your overlay

Overlay configs reference secrets via `{ENV_VAR}` placeholders — Slack tokens, LLM API keys, and OAuth client secrets. The scaffolded `.gitignore` keeps `.env` out of git, but that's a single layer. A few minutes of setup adds defense in depth.

### 1. Enable GitHub push protection

GitHub refuses pushes that contain known provider tokens (Slack `xoxb-`/`xapp-`, Anthropic `sk-ant-`, OpenAI `sk-`, AWS, etc.) before they ever reach the remote. It cannot be bypassed by `git commit --no-verify` — the check runs server-side. Free on public repos, and included in GitHub Advanced Security on private/organisation repos.

Toggle it in **Settings → Code security → Secret scanning** (enable both *Secret scanning* and *Push protection*), or in one shot via the CLI:

```bash
gh api -X PATCH repos/<org>/<repo> --input - <<'EOF'
{
  "security_and_analysis": {
    "secret_scanning": {"status": "enabled"},
    "secret_scanning_push_protection": {"status": "enabled"},
    "secret_scanning_non_provider_patterns": {"status": "enabled"}
  }
}
EOF
```

### 2. Add a gitleaks pre-commit hook

Catches secrets on the developer's machine before they ever reach a remote — useful as a first line of defense and as the only layer for contributors who fork the repo. Add to your overlay's `.pre-commit-config.yaml`:

```yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1   # pin to a tag; bump via `pre-commit autoupdate`
    hooks:
      - id: gitleaks
```

Then run `pre-commit install` once per clone. Pre-commit requires a pinned `rev` for reproducibility and supply-chain safety. Keep it fresh either by running `pre-commit autoupdate` periodically or by adding a `package-ecosystem: "pre-commit"` entry to `.github/dependabot.yml` so Dependabot opens hook-bump PRs.

### 3. Sweep history once

Before turning the layers above on, check whether anything already leaked. Trufflehog walks every commit in your history and reports candidate secrets:

```bash
docker run --rm -v "$PWD:/repo" trufflesecurity/trufflehog:latest \
  git file:///repo --no-update
```

If trufflehog finds a real secret, **rotate it immediately** at the issuer (Slack, Anthropic, OpenAI, etc.). Rewriting git history with `git-filter-repo` is optional — once a token has been pushed publicly, assume it's compromised and prioritise rotation over removal.

---

# OAuth-protected MCP servers

`slack_agents.tools.mcp_http_oauth` connects to MCP servers that require OAuth 2.1
authentication, with **per-Slack-user tokens**: each user authenticates separately,
and the agent uses that user's access token when calling tools on their behalf.

This complements `slack_agents.tools.mcp_http`, which is for servers that issue
long-lived API keys you put in YAML headers. If your MCP server speaks the MCP
authorization spec (Dynamic Client Registration + auth-code + PKCE), use
`mcp_http_oauth`.

## Configuration

```yaml
tools:
  my-mcp:
    type: slack_agents.tools.mcp_http_oauth
    url: "https://my-server.example.com/mcp"
    allowed_functions: [".*"]
    init_retries: [5, 10, 30]    # optional
    auth_timeout: 300             # optional, seconds, default 300
```

Only `url` and `allowed_functions` are required. There is intentionally no
`client_id` / `client_secret` / `scopes` field — the provider performs Dynamic
Client Registration against the MCP server's authorization server, registers
with whatever scopes the server's PRM document advertises, and discovers
runtime scopes through standard 401/403 step-up challenges.

## Required environment variables

These are validated at startup. If any `mcp_http_oauth` provider is configured
and any of these are missing or malformed, the agent refuses to start with a
single consolidated error message.

| Variable | Required | Default | Description |
|---|---|---|---|
| `PUBLIC_URL` | yes | — | Externally reachable base URL of this agent process (shared with A2A push). Must be `https://`, or `http://` with a loopback host (`localhost`, `127.0.0.1`, `[::1]`) for local dev. |
| `OAUTH_SECRET_KEY` | yes | — | Root key for HKDF; ≥32 bytes after base64 decode. Used to sign OAuth state tokens and encrypt refresh tokens at rest. |
| `HTTP_BIND_HOST` | no | `0.0.0.0` | Interface the in-process listener binds to (shared ingress). |
| `HTTP_BIND_PORT` | no | `8080` | TCP port for the listener (shared ingress). |

### Generating `OAUTH_SECRET_KEY`

```bash
openssl rand -base64 32
```

or

```bash
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
```

Treat this value like any other long-lived secret: keep it out of source
control, rotate it the same way you rotate database credentials. Rotating it
forces every user to re-authenticate but does not break the agent.

## Local development

OAuth callbacks need a URL the user's browser can reach. For local dev use a
tunnel (ngrok, cloudflared, tailscale funnel, etc.):

```bash
# Terminal 1 — start the tunnel pointing at your bind port:
ngrok http 8080
# → forwards https://abcd-1234.ngrok-free.app to localhost:8080

# Terminal 2 — set env vars and run the agent:
export PUBLIC_URL=https://abcd-1234.ngrok-free.app
export OAUTH_SECRET_KEY=$(openssl rand -base64 32)
slack-agents run agents/my-agent
```

If you'd rather not use a tunnel, you can run with
`PUBLIC_URL=http://localhost:8080` — the validator allows loopback
addresses over plain HTTP per RFC 8252.

## What a Slack user sees

1. They ask the bot to do something that needs OAuth-protected tools.
2. The bot replies with an ephemeral message in the same thread (visible only to
   them) containing an "Authenticate" button.
3. They click; the browser opens the upstream service's login page.
4. They log in and click Allow.
5. The browser shows "Authentication completed — you can close this tab and
   return to Slack."
6. Slack shows a brief "✅ Authenticated to *server*" ephemeral, the agent
   picks up the new token, runs tool discovery, and the conversation continues
   normally.

If they don't click within the configured `auth_timeout` (default 5 minutes),
the agent surfaces a "timed out — please try again" error and the tool call
ends. They can re-ask whenever they're ready and a fresh prompt appears.

If the upstream tool later requires additional permissions (e.g. they had read
access but are now trying to write), the same flow re-runs requesting the
broader scope. Most identity providers auto-collapse the consent screen if the
broader scope is a superset of what they've already approved.

If the user's account doesn't actually have the role the upstream needs (so
the IdP silently issues a token without the requested scope), the agent
detects this on the next tool call and surfaces a clear permission-denied
message naming the specific missing scope — rather than a generic error.

## How scopes work

Three scope-related decisions happen at different times, with different
sources of truth. Knowing which is which makes troubleshooting much easier.

### 1. DCR registration scope (one-time, per server)

When the agent first encounters an OAuth-protected MCP server, it does
Dynamic Client Registration with the server's authorization server. The
client registration declares **all the scopes this client could ever
legitimately request** — this is the catalog, not the per-call request.

The agent uses **PRM `scopes_supported`** as that catalog: it pre-fetches
`/.well-known/oauth-protected-resource` from the resource server and
registers with exactly that list. No heuristics, no extrapolation.

This means the **resource server's PRM document must advertise every scope
that any of its tools might ever require**, not just the default tier. If
PRM only advertises `mcp:foo:read` but a tool returns a 403 demanding
`mcp:foo:write`, the registered client was never permitted to request
`mcp:foo:write` and the step-up will fail with `invalid_scope`.

### 2. Per-request authorize scope (every tool call)

For each authorize request to the IdP (initial auth and step-up alike), the
agent computes the union of three sources:

```
authorize_scope =
    {openid, offline_access}                  # OIDC protocol baseline (always)
  ∪ scopes from the user's currently-cached token
  ∪ scopes the server hinted in `WWW-Authenticate scope=`
```

The OIDC baseline is added by the agent unconditionally — without `openid`
the IdP issues a non-OIDC token (no identity claims) and without
`offline_access` no refresh token is issued (forcing fresh auth on every
token expiry). The cached scopes preserve what's already been granted, so
step-up never accidentally narrows what the user has. The hint from
`WWW-Authenticate` is what the resource server *just* asked for, this call.

The resource server can be stateless: it can return either the cumulative
set the user now needs (`scope="mcp:foo:read mcp:foo:write"`) or just the
delta scope for this call (`scope="mcp:foo:write"`). The client merges with
its own state either way.

### 3. What the token actually grants (decided by the IdP)

After the user consents, the IdP issues a token with whatever scopes their
roles actually permit — it may silently drop scopes the user can't have.
The agent compares the post-step-up token's scope against the server's
demand. If a required scope wasn't granted, the agent surfaces a clean
permission-denied error naming the specific missing scope — instead of
retrying forever or surfacing the upstream 403 verbatim.

### Resource server expectations

For the agent to behave correctly out of the box, your MCP server should:

- **PRM `/.well-known/oauth-protected-resource`** should advertise every
  scope its tools might require, including step-up scopes (e.g. read AND
  write AND admin), not just the default tier.
- **401 responses** (no token at all) should include `scope=` with the
  minimum needed to use the resource (typically the read-equivalent).
- **403 responses** (token with insufficient scope) should include
  `WWW-Authenticate: Bearer error="insufficient_scope" scope="…"` per RFC
  9470, naming the scope(s) needed for this specific call. The server can
  return either the cumulative set or the delta — the client tolerates both.
- **OIDC scopes** (`openid`, `offline_access`) don't need to appear in
  either header — the client always adds them on its own.

## Token storage

Tokens are persisted via the agent's normal storage backend (SQLite or
Postgres) in two new tables:

- `oauth_tokens` — per (user_id, server_id) access token + encrypted refresh
  token, scopes, expiry.
- `oauth_clients` — per server_id Dynamic Client Registration result, shared
  across all users connecting to that server through this agent.

Refresh tokens are AES-GCM-encrypted at rest using a subkey derived from
`OAUTH_SECRET_KEY` via HKDF. Access tokens are short-lived and stored
plaintext (still in the private DB).

## Troubleshooting

**"Configuration error: ... PUBLIC_URL is not set"** — set the env vars
listed in the message and restart.

**"Authentication timed out"** — the user didn't click the link within
`auth_timeout`. They can re-ask the bot whenever they're ready.

**"<server> does not support dynamic client registration"** — the upstream
authorization server doesn't speak RFC 7591, or it has a Client Registration
Policy that rejects requests from your agent's host. Common Keycloak gates:

- *Trusted Hosts* policy — the realm admin must add your agent's
  externally-reachable host to the trusted-hosts list.
- A CDN/WAF in front of the IdP — some Cloudflare bot-management rules block
  anonymous DCR requests; the realm admin needs an exception for the
  `/clients-registrations/openid-connect` endpoint.

This provider is DCR-only by design. Static pre-registered client credentials
are not currently supported.

**`invalid_scope` on step-up after a successful first auth** — the
DCR-registered client doesn't have the requested scope in its allowed-request
list, even though it would have been included in the registration request.
This is Keycloak's *Allowed Client Scopes* policy under
`Realm Settings → Client Registration → Anonymous Access Policies`: the
realm silently filters DCR registration scope to a permitted subset. The
realm admin must add the missing scope (e.g. `mcp:foo:write`) to that
policy. Verify what the realm actually registered for your client by querying
the local DB:

```bash
sqlite3 /tmp/<agent>.slack-agents.db \
  "SELECT json_extract(metadata_json, '\$.scope') FROM oauth_clients WHERE server_id='<server>';"
```

If a scope is missing here, the policy filtered it out at DCR time.

**"This action cannot be run on `<server>`: your account does not have a role
granting the required scope"** — the IdP issued a token but silently dropped
the requested scope because the user's role doesn't include it. The user (or
their admin) needs to grant the missing scope at the role level. The agent
names the specific missing scope in the message.

**"You declined access"** — the user clicked Deny on the consent screen, or
the IdP returned `error=access_denied`. They can re-ask to retry.

**Tokens disappear after a key rotation** — expected. Rotating
`OAUTH_SECRET_KEY` invalidates all stored refresh tokens (the agent detects
this on the first read and deletes the row, then prompts for fresh auth on the
next call).

## Implementation notes

- The in-process callback listener runs alongside the Slack Bolt connection
  (same asyncio loop, same process). It only listens when at least one
  `mcp_http_oauth` provider is configured.
- The listener exposes exactly two routes: `/oauth/start/{signed_state}` and
  `/oauth/callback`. Anything else returns 404.
- OAuth state is signed (HMAC-SHA256) and includes a single-use nonce; replays
  are rejected.
- Restarting the agent during a pending auth flow drops that flow — the user
  re-asks and gets a fresh prompt. Persistent mid-flow recovery is intentionally
  not implemented.

### MCP SDK workarounds

The provider patches around four behaviors of the `mcp` Python SDK
(`mcp.client.auth`) at the time of writing. When the SDK addresses any of
these upstream, the corresponding shim can be removed:

1. **Pre-DCR with full PRM scope set.** The SDK's `async_auth_flow`
   overwrites `client_metadata.scope` with the runtime authorize scope
   (`get_client_metadata_scopes`) *before* running DCR. If we let the SDK do
   DCR, the registered client gets only "what's needed for the current
   operation," not the full catalog, and step-up later fails with
   `invalid_scope`. We do DCR ourselves with PRM's `scopes_supported`,
   persist the result, and the SDK's `if not self.context.client_info: …
   register …` branch is skipped.
2. **Discovery on every fresh `OAuthClientProvider`.** The SDK's 403 step-up
   path calls `_perform_authorization()` without first running protected-
   resource discovery, so a freshly constructed provider falls back to
   `urljoin(server_url, "/authorize")` (wrong when the AS is on a different
   host). We pre-populate `oauth.context.protected_resource_metadata` and
   `oauth_metadata` from a per-Provider cache.
3. **`token_expiry_time` not propagated from storage.** The SDK's
   `_initialize` loads `current_tokens` from storage but doesn't set
   `token_expiry_time`, so `is_token_valid()` returns True for any cached
   token regardless of actual expiry — meaning a stale token is sent at
   restart, the server returns 401, the SDK skips the refresh-token branch
   entirely, and the user is re-prompted. We force `_initialize` plus
   `update_token_expiry(tokens)` after construction so refresh works
   silently across restarts.
4. **Scope merge on `WWW-Authenticate`.** The SDK uses the server's
   `WWW-Authenticate scope=` value verbatim, dropping anything the cached
   token already had and the OIDC baseline. We attach an httpx response
   hook to every MCP request that augments the header in place to be the
   union of `{openid, offline_access}` ∪ cached-token scopes ∪ server-hinted
   scopes, so the SDK's verbatim use produces the correct cumulative set.

Each shim is annotated in the source with a comment explaining the SDK
behavior it works around.

---

# Agent2Agent (A2A) integration

`slack_agents.a2a.agent` and `slack_agents.a2a.proxy` connect this framework
to remote agents over the
[Agent2Agent protocol](https://a2a-protocol.org). A2A is an interaction
protocol — the remote agent is an opaque, possibly-conversational message
endpoint that describes itself via an Agent Card. The framework treats it as a
single free-text channel (not a menu of typed tools the way MCP does).

Built on the official [`a2a-sdk`](https://github.com/a2aproject/a2a-python)
(1.x, protobuf-based). All SDK-specific code is isolated in
`slack_agents.a2a.client`; the rest of the package depends only on a small
stable interface, so SDK version changes are contained to one module.

Two deployment topologies are supported from one codebase:

- **Option A — smart router.** A real local LLM (`slack_agents.llm.anthropic`,
  etc.) orchestrates a conversation and delegates to one or more remote A2A
  agents exposed as tools (`slack_agents.a2a.agent`). The local LLM decides
  when and how to invoke each agent, synthesizes the replies, and continues the
  conversation normally. Multiple A2A agents can coexist alongside other tool
  providers.
- **Option B — dumb frontend.** No local reasoning at all. `slack_agents.a2a.proxy`
  forwards every user message to a single remote A2A agent and relays the reply
  back. Slack becomes a thin chat front-end; the intelligence lives entirely in
  the remote agent. This is primarily a **development/debugging convenience** for
  poking at an A2A agent directly through Slack — Option A is the production shape.

## Configuration

### Option A — smart router

```yaml
llm:
  type: slack_agents.llm.anthropic
  model: claude-sonnet-4-6
  api_key: "{ANTHROPIC_API_KEY}"
  max_tokens: 4096
  max_input_tokens: 200000
tools:
  research-agent:
    type: slack_agents.a2a.agent
    url: "https://research.example.com"
    auth: { type: bearer, token: "{RESEARCH_A2A_TOKEN}" }
    poll_interval: 5
    max_task_lifetime: 3600
```

### Option B — dumb frontend

```yaml
llm:
  type: slack_agents.a2a.proxy
  model: "mya2a"
  max_input_tokens: 200000
tools:
  mya2a:
    type: slack_agents.a2a.agent
    url: "https://remote-agent.example.com"
    auth: { type: bearer, token: "{A2A_API_KEY}" }
```

The system prompt is ignored in Option B; it is used normally in Option A.

## `a2a.agent` — tool provider

`slack_agents.a2a.agent` is a `BaseToolProvider`. At startup it fetches the
remote agent's Agent Card and registers a single tool — named after the
provider's key under `tools:` — using the card's description.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `url` | `str` | required | Base URL of the remote agent. The framework tries `<url>/.well-known/agent-card.json` automatically. |
| `auth` | `dict` | none | Auth credentials. See [Auth](#auth) below. |
| `timeout` | `float` | `300` | HTTP timeout in seconds for individual A2A requests. |
| `poll_interval` | `float` | `5` | Seconds between polling attempts for long-running tasks. |
| `max_task_lifetime` | `float` | `3600` | Maximum seconds to poll before abandoning a task and delivering a timeout message. |

**One tool per agent.** A2A is a single opaque channel — it has no mechanism to
advertise a menu of typed tools the way MCP does. Each `a2a.agent` provider
exposes exactly one free-text tool, named after its key under `tools:`
(slugified to satisfy the LLM tool-name rules — letters, digits, `_`, `-`).
Rename the key to rename the tool; the key is also unique within `tools:`, so
two agents never collide:

```json
{
  "name": "<tools: key, slugified>",
  "description": "<from Agent Card>",
  "input_schema": {
    "type": "object",
    "properties": { "message": { "type": "string" } },
    "required": ["message"]
  }
}
```

**Conversation continuity.** The framework tracks the A2A `contextId` (and, for
multi-turn tasks, the `taskId`) per Slack thread, transparently — the LLM never
sees or passes these. Successive messages in the same thread are sent on the
same `contextId` so the remote agent can keep conversational state.

When the agent replies with an **`input-required`** (or `auth-required`) state —
i.e. it is mid-task and wants another message — the framework relays the agent's
prompt to the user and **threads the same `taskId`** on the next message, so the
exchange continues the *same* Task (and its server-side state). When the task
reaches a terminal state, the saved `taskId` is cleared and the next message
starts a fresh Task. This is what makes multi-turn agents (e.g. a step-by-step
form or a guessing game) behave correctly instead of restarting each turn.

**Files.** File attachments flow in both directions. A file a user uploads in
Slack is forwarded to the agent as an A2A `raw` part (alongside the text); a
file the agent returns as an artifact is surfaced back into the Slack thread as
an upload. Non-text artifacts (CSV, PDF, …) come through as files; text
artifacts are used as the reply.

To **send** files you must also configure a file-import handler (e.g.
`slack_agents.tools.file_importer`) in `tools:` — the framework only accepts
uploads it has a handler for, and the a2a tool then forwards the raw bytes:

```yaml
tools:
  mya2a:
    type: slack_agents.a2a.agent
    url: "https://remote-agent.example.com"
  import-files:
    type: slack_agents.tools.file_importer
    allowed_functions: [".*"]
```

## `a2a.proxy` — passthrough LLM

`slack_agents.a2a.proxy` is a `BaseLLMProvider` that does no local reasoning.
It drives the same conversation loop as any other LLM provider, but instead of
calling an LLM it routes directly to the configured A2A tool.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | `str` | — | Name of the target `a2a.agent` tool (the key under `tools:` in `config.yaml`). When set, it is always used. When omitted, the proxy auto-selects the tool **only if exactly one** is configured; with more than one tool and no `model:`, startup fails with an error. |
| `max_input_tokens` | `int` | `200000` | Context-window guard, passed through to the loop. |

## Auth

### Static / API-key auth (service-level)

Credentials are resolved from environment variables at startup via the standard
`{ENV_VAR}` interpolation. These are shared across all users.

```yaml
# Bearer token — Authorization: Bearer <token>
auth: { type: bearer, token: "{A2A_API_KEY}" }

# Arbitrary header
auth: { type: header, name: "X-API-Key", value: "{A2A_API_KEY}" }

# API-key scheme — sends the raw value as the named header
# (matches an Agent Card that advertises apiKey security)
auth: { type: apiKey, name: "Authorization", value: "{A2A_API_KEY}" }

# No auth (default when omitted)
auth: { type: none }
```

### Per-user OAuth (`auth: { type: oauth2 }`)

Each Slack user authenticates separately to the remote agent. The framework
uses that user's access token on subsequent calls, exactly as it does for
OAuth-protected MCP servers (see [docs/oauth.md](oauth.md)).

```yaml
tools:
  research-agent:
    type: slack_agents.a2a.agent
    url: "https://research.example.com"
    auth: { type: oauth2 }
```

There is intentionally no `client_id`/`scopes` field. The provider discovers
OAuth metadata from the remote agent's **Agent Card** (`securitySchemes.oauth2`
→ `oauth2MetadataUrl`), performs Dynamic Client Registration, and uses the
auth-code + PKCE flow. This means a server whose RFC 9728 protected-resource-metadata
endpoint is missing or internal still works as long as its Agent Card advertises
the oauth2 scheme and `oauth2MetadataUrl`.

**User experience.** The first time a user invokes an OAuth-protected A2A agent,
the framework posts an ephemeral "Authenticate" button in Slack. Clicking it
opens the remote agent's auth server in the user's browser. After consent the
browser redirects to the shared callback URL and the original request is
completed automatically.

**Required environment variables.** Per-user OAuth needs the shared ingress
(same as MCP OAuth) — configure `PUBLIC_URL` and `OAUTH_SECRET_KEY` exactly
as described in [docs/oauth.md](oauth.md#required-environment-variables). These
are validated at startup; the agent refuses to start if they are missing or
malformed.

## Long-running tasks

The A2A protocol distinguishes between tasks that complete quickly and tasks
that may take minutes or hours. The framework handles both transparently.

The client sends every message with `blocking: true`, requesting that the
server wait for completion before responding. This is only a hint — the server
is free to return a non-terminal (`working`) task for long jobs.

**Synchronous path.** If the remote agent returns a terminal result
(`completed`, `failed`, `canceled`, or `rejected`) immediately, the result is
returned inline to the LLM (Option A) or relayed directly to Slack (Option B).

**Background-polling path.** If the remote agent returns a non-terminal
(`submitted` or `working`) task, the framework:

1. Persists an in-flight record (task ID, context, thread, channel) to the
   storage backend.
2. Returns an acknowledgement to the LLM/proxy immediately
   ("Started a longer task — I'll post the result here when it's ready.").
3. Spawns a background poller that calls `tasks/get` every `poll_interval`
   seconds until the task reaches a terminal state or `max_task_lifetime` is
   exceeded.
4. On completion, delivers the result **out-of-band** into the original Slack
   thread:
   - **Option A (real LLM):** the result is injected as a synthetic inbound
     turn and the standard loop re-runs, so the local LLM can interpret or act
     on it before replying to the user.
   - **Option B (proxy):** the result text is posted directly to the thread
     without re-entering the loop (the proxy has no intelligence to add).

This is entirely outbound — the background poller calls the remote agent's
`tasks/get` endpoint on a timer. No public URL or inbound HTTP listener is
needed. The Socket Mode deploy-anywhere property is preserved.

**Crash resilience.** In-flight task records are persisted in the storage
backend. If the agent process restarts while tasks are in flight, they are
re-discovered at startup and pollers are resumed automatically.

**OAuth and async delivery.** The two async paths behave differently with
respect to the user's token, and the framework is **push-preferred**:

- **Push (webhook).** Delivery is *inbound* — the agent POSTs updates to the
  ingress, which relays them to Slack without ever calling the agent back. No
  token is involved at delivery time, so a task started while the user was
  authenticated **still delivers even if their OAuth token later expires or is
  revoked.** When a push webhook is registered for a task, the framework does
  **not** also run the poller for it (avoiding redundant — potentially double —
  delivery).
- **Polling (fallback, no push).** Only used when push is not registered (the
  agent doesn't support it, or no `PUBLIC_URL`). The poller calls `tasks/get`
  *outbound*, which requires the user's token: it builds a fresh per-user client
  from the stored token and refreshes silently — no Slack interaction happens
  out-of-band. If the token cannot be refreshed (revoked, or the refresh
  expired), the bot posts "Your session for this task has expired — please ask
  again and re-authenticate when prompted." to the thread, instead of prompting
  out-of-band where there is no safe way to do so.

## What a Slack user sees

**For a fast response (synchronous path):**

1. User asks the bot something.
2. The bot forwards the message to the remote A2A agent and replies with the
   result, same as any other tool call.

**For a long-running task (async path):**

1. User asks the bot something.
2. The bot replies: "Started a longer task — I'll post the result here when
   it's ready."
3. When the remote agent finishes, the result appears in the same Slack thread.

## Trying it against a reference agent

You don't need to write an A2A agent to try the integration — point it at the
official [`helloworld`](https://github.com/a2aproject/a2a-samples/tree/main/samples/python/agents/helloworld)
sample (a2a-sdk 1.x, no API key). It's a simple echo, so it exercises the core
path — Agent Card resolution, send, completion, artifact handling — but not
multi-turn or files.

```bash
git clone --depth 1 https://github.com/a2aproject/a2a-samples /tmp/a2a-samples
cd /tmp/a2a-samples/samples/python/agents/helloworld

# The upstream Containerfile's CMD passes --host 0.0.0.0, but __main__.py hardcodes
# 127.0.0.1, so it binds container-loopback and isn't reachable from the host.
# Make it bind 0.0.0.0 (the card's advertised url stays 127.0.0.1:9999, which is
# correct for a client on the host):
sed -i '' "s/host='127.0.0.1'/host='0.0.0.0'/" __main__.py   # GNU sed: use  sed -i

docker build -f Containerfile -t helloworld-a2a-server .
docker run -d -p 9999:9999 helloworld-a2a-server
```

Set `url: "http://127.0.0.1:9999"` in your `a2a.agent` config and message the
bot — it replies `Hello, World! I have received your request (...)`. A ready-made
example lives at [`agents/a2a-agent/config.yaml`](../agents/a2a-agent/config.yaml),
which documents both the smart-routing and proxy topologies (proxy commented out
for easy switching) and repeats these server-start steps inline.

**Integration test.** `tests/a2a/test_integration.py` drives the real client
against a live agent and is **skipped unless `A2A_TEST_URL` is set**, so it
never affects CI:

```bash
A2A_TEST_URL=http://127.0.0.1:9999 pytest tests/a2a/test_integration.py -v
```

The assertions are agent-agnostic — the same test works against any conformant
A2A agent.

## Push notifications

When a remote agent supports push (`capabilities.pushNotifications`), the
framework can register a **webhook** so the agent delivers task updates —
status messages and file artifacts — to the Slack thread out-of-band, including
reports that arrive *after* a synchronous reply. This is the unified,
**push-preferred** async path; the background poller remains the fallback for
agents that don't support push.

**Enable it** with `push_notifications: true` on the `a2a.agent` config (opt-in,
because push requires a publicly reachable URL):

```yaml
tools:
  mya2a:
    type: slack_agents.a2a.agent
    url: "https://remote-agent.example.com"
    push_notifications: true
```

**Ingress.** Push needs the in-process HTTP listener (shared with OAuth) and a
public URL the agent can POST to. Configure:

| Env | Default | Purpose |
|-----|---------|---------|
| `PUBLIC_URL` | — (required) | Public base URL of the ingress; the webhook is `<PUBLIC_URL>/a2a/push`. |
| `HTTP_BIND_HOST` | `0.0.0.0` | Listener bind host. |
| `HTTP_BIND_PORT` | `8080` | Listener bind port. |

This is the one A2A feature that needs **inbound** HTTP — unlike the polling
path, which is outbound-only. For local testing the URL can be a loopback
(`http://127.0.0.1:8080`) reachable by an agent on the same host.

**How it works.** On the first send the framework registers the webhook inline
(`SendMessageConfiguration.task_push_notification_config`) with a random
per-task token, and persists a `taskId → thread` record. Incoming POSTs are
validated against the token, correlated by `taskId`, **de-duplicated** by
message/artifact id (the server re-pushes the immediate reply, which we already
delivered synchronously), and any genuinely-new text/files are posted/uploaded
to the thread.

**Security & limits.** We validate the shared-secret token and only act on tasks
we registered. The protocol's signing (JWS) and SSRF-allowlist are server-side
concerns we don't yet rely on. There are no delivery retries (the agent sends a
single POST), and a *server* restart drops its own registration — so a
previously-registered task simply stops pushing.

## Current limitations

The following are not yet implemented. They are planned for future releases.

- **Files: image uploads and the async path.** *Receiving* files works for any
  type. When *sending*, only non-image uploads (CSV, PDF, DOCX, …) are forwarded
  — images are not yet — and files are forwarded/surfaced only on the synchronous
  path (a long-running task delivered out-of-band carries its text, not files).
- **Atomic responses.** Responses are collected in full before being posted.
  Token-by-token streaming to Slack is not supported yet.
- **No A2A server mode.** This framework cannot be addressed as an A2A agent
  by other agents. It is a client only.

## Troubleshooting

**"A2A agent returned a long-running task but async delivery is unavailable."**
— the `a2a.agent` provider was not given a `framework_ctx` (injected
automatically by the framework when the provider is loaded via `config.yaml`).
This should not happen in normal operation; it can appear in test setups that
construct the provider directly without the framework context.

**The background poller never delivers a result.** Check that `poll_interval`
and `max_task_lifetime` are appropriate for the remote agent. If the agent
takes longer than `max_task_lifetime`, the task is abandoned and a timeout
message is posted to the thread. Increase `max_task_lifetime` if needed.

**"a2a.proxy: set `model:` to the target a2a tool name"** — you have more than
one tool configured and the proxy doesn't know which one to route to. Set
`model:` under the `llm:` section to the key of the intended `a2a.agent` tool.
