slack_agents.a2a.agent and slack_agents.a2a.proxy connect this framework
to remote agents over the
Agent2Agent protocol. 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
(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.proxyforwards 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.
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: 3600llm:
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.
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 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:
{
"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:
tools:
mya2a:
type: slack_agents.a2a.agent
url: "https://remote-agent.example.com"
import-files:
type: slack_agents.tools.file_importer
allowed_functions: [".*"]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. |
Credentials are resolved from environment variables at startup via the standard
{ENV_VAR} interpolation. These are shared across all users.
# 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 }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).
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. These
are validated at startup; the agent refuses to start if they are missing or
malformed.
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:
- Persists an in-flight record (task ID, context, thread, channel) to the storage backend.
- Returns an acknowledgement to the LLM/proxy immediately ("Started a longer task — I'll post the result here when it's ready.").
- Spawns a background poller that calls
tasks/geteverypoll_intervalseconds until the task reaches a terminal state ormax_task_lifetimeis exceeded. - 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 callstasks/getoutbound, 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.
When a remote A2A agent returns a file artifact, the framework uploads it to the
Slack thread and makes its content visible to the local LLM as context. Text
artifacts (CSV, Markdown, plain text, and — if an import-files provider is
configured — PDF/DOCX/XLSX/PPTX) are extracted through the same file-import
handlers used for user uploads; unparseable or oversized files (and types with no
handler) become a short metadata placeholder the LLM can acknowledge.
Because the file is always shown to the user directly, each artifact is tagged "already delivered to the user — reference it, don't reproduce it." The LLM summarizes or acts on the content but won't re-paste it.
For long-running tasks, artifacts arriving via polling are delivered (file +
context) when the task finishes. For push, every update is uploaded to the thread
as it streams and its content (status text + extracted artifact text) is
accumulated into the LLM's context; the LLM is then given one turn per
actionable status — a terminal state (completed/failed/canceled/rejected)
or a needs-input state (input-required/auth-required) — flushing everything
accumulated since the last turn. Progress states (working/submitted) stream to
the thread and accumulate silently but never trigger an LLM turn, so a chatty agent
doesn't cause a flurry. A task that pauses for input and later completes therefore
yields two turns (the question, then the wrap-up); re-pushes of the same status are
de-duplicated.
For a fast response (synchronous path):
- User asks the bot something.
- 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):
- User asks the bot something.
- The bot replies: "Started a longer task — I'll post the result here when it's ready."
- When the remote agent finishes, the result appears in the same Slack thread.
You don't need to write an A2A agent to try the integration — point it at the
official 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.
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-serverSet 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,
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:
A2A_TEST_URL=http://127.0.0.1:9999 pytest tests/a2a/test_integration.py -vThe assertions are agent-agnostic — the same test works against any conformant A2A agent.
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):
tools:
mya2a:
type: slack_agents.a2a.agent
url: "https://remote-agent.example.com"
push_notifications: trueIngress. 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.
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.
"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.