A2A Architecture
How the A2A sidecar fits between an external client and Multica's existing agent pipeline. Covers transport, auth, task lifecycle, data shape, and what's deliberately out of scope.
This document is the architectural reference for Multica's A2A v1.0 implementation. For the user-facing how-to, see A2A Protocol.
Layer cake
┌─────────────────────────────────────────┐
│ External A2A Client │ ← LangChain, CrewAI, custom, a2a-inspector
└────────────────┬────────────────────────┘
│ HTTPS Authorization: Bearer mca_…
│ A2A-Version: 1.0
▼
┌─────────────────────────────────────────┐
│ Cloudflare Tunnel a2a.magomadov.dev │ ← optional; localhost dev works without it
└────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Python sidecar services/a2a/ :5555 │ ← FastAPI + a2a-sdk[http-server]
│ - Bearer validation + TTL cache │
│ - Per-token rate limiter │
│ - Per-agent Starlette sub-app (lazy) │
│ - SDK serves card / JSON-RPC / REST │
│ - MulticaAgentExecutor │
└────────────────┬────────────────────────┘
│ HTTP X-Internal-Token: <shared secret>
▼
┌─────────────────────────────────────────┐
│ Multica Go backend :8080 │
│ /internal/a2a/{tokens,agents,tasks} │ ← gated by the shared secret only
│ - sqlc queries (a2a.sql) │
│ - workspace + agent + chat session │
└────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ PostgreSQL │
│ agent_task_queue (source='a2a') │
│ chat_session (a2a_context_id) │
│ a2a_client_token │
└────────────────┬────────────────────────┘
│ SQL claim by daemon / cloud runtime
▼
┌─────────────────────────────────────────┐
│ Existing Multica agent runtime │
│ daemon → spawns agent subprocess │
│ agent runs, posts result back │
└─────────────────────────────────────────┘The crucial choice: the sidecar is a thin protocol adapter, not a parallel agent pipeline. Once a task lands in agent_task_queue, the existing daemon claim/run/complete machinery picks it up identically to a UI-triggered chat task. We didn't build A2A into the Go server — we built one process whose only job is talking the protocol.
Why a Python sidecar (and not Go)
The official SDK is a2a-python. It already implements the protocol's painful bits — protobuf canonical types, JSON-RPC dispatch, REST + SSE binding, push notification dispatch, request validation. Reimplementing in Go would be week+ of work to recreate exactly what the SDK does, and any spec drift would bite us first. The sidecar pattern lets us treat A2A as a published contract: if the SDK adopts a future v1.x revision, we update one Python project, not the entire Multica server.
The trade-off is one extra process per host. That's acceptable: it's stateless, sub-100MB RAM, and managed by launchctl exactly like the rest of the stack.
Auth model
Three auth boundaries:
-
External client → Sidecar —
Authorization: Bearer mca_<…>. The token is workspace-scoped and minted via UI (Settings → A2A) or CLI. SHA-256 hash stored, prefix kept for display, raw secret shown once on creation. Revocation is a singleUPDATE. -
Sidecar → Backend —
X-Internal-Token: <shared secret>on every/internal/a2a/*call. The secret is in the backend.envand the sidecar.env; if either side is missing it, the routes return 503 (sidecar refuses to start) or 403 (backend rejects). -
Token validation cache — sidecar caches a successful
validate_tokenresponse for 60s to avoid round-tripping every request. Negative results are NOT cached — a revoked token is rejected immediately.
# pseudocode of the sidecar auth middleware
raw = parse_bearer(request.headers["authorization"])
token = await token_cache.resolve(raw) # → workspace_id, token_id, created_by
await rate_limiter.check(token.token_id) # → 60/min, 300 burst
request.state.a2a_token = token
# downstream: SDK runs, executor reads workspace_id from ServerCallContext.stateTask lifecycle mapping
Multica agent_task_queue.status | A2A v1.0 TaskState |
|---|---|
queued | TASK_STATE_SUBMITTED |
dispatched | TASK_STATE_WORKING |
running | TASK_STATE_WORKING |
completed | TASK_STATE_COMPLETED |
failed | TASK_STATE_FAILED |
cancelled | TASK_STATE_CANCELED |
A2A's TASK_STATE_INPUT_REQUIRED / TASK_STATE_AUTH_REQUIRED / TASK_STATE_REJECTED are reserved for future Multica states; today the executor only emits REJECTED for the per-workspace quota guard.
Context → chat session mapping
A2A contextId is an external client-supplied UUID that groups related messages into a session. Multica internally has chat_session (which already keys chat-style tasks). The bridge is a single chat_session.a2a_context_id UUID UNIQUE column added by migration 081 — a UPSERT keyed on it gives the sidecar deterministic mapping per contextId. The chat session's creator_id is the user who minted the token, so all the existing audit + access-control machinery works without change.
Field mapping: Multica agent → A2A AgentCard
This table is enforced by the test suite (a2a_field_mapping_test.go on
the Go side, test_card_builder.py on the sidecar). Adding a new
user-facing agent field in the LK without updating both this table and
the tests will fail CI.
| Multica field | Internal /internal/a2a/agents/{id} | A2A AgentCard |
|---|---|---|
agent.name | name | name |
agent.description | description | description (truncated to 500) |
agent.instructions | instructions | description only when description is blank — otherwise omitted |
agent.avatar_url | avatar_url | iconUrl |
workspace.name | workspace_name | provider.organization (provider.url filled with the sidecar's public base) |
agent.is_a2a_exposed | is_a2a_exposed | gate — false → 404 on the card endpoint |
agent_skill[] join | skills[] (id, name, description) | skills[] with id, name, description, default i/o modes, empty tags |
agent.workspace_id | workspace_id | supportedInterfaces[].tenant (per-interface) |
Internal-only fields (NEVER reach the card): model, runtime_id,
runtime_mode, custom_env, custom_args, mcp_config,
max_concurrent_tasks, visibility, status, skill content/config.
The omission set is asserted in
TestA2AGetAgent_OmitsInternalOnlyFields — adds an agent with a
secret-looking custom_env and asserts none of the internal field names
or values appear in the rendered card preview.
Data shape — what goes on the wire
The SDK enforces v1.0 shape automatically when we use a2a.types.a2a_pb2 types. The sidecar's card_builder.py, parts.py, and state_mapping.py modules cover:
- Part — unified, discriminated by member presence:
{"text": "..."},{"url": "..."},{"raw": "<base64>"},{"data": {...}}. Nokind.mediaType(notmimeType).filenameavailable on any variant. - Stream events — wrapper-discriminated:
{"taskStatusUpdate": {...}}or{"taskArtifactUpdate": {..., "index": N}}. Nokind, nofinal(closure of the SSE stream signals end). - Agent Card —
supportedInterfaces[]withurl,protocolBinding,protocolVersion: "1.0",tenant.capabilities.extendedAgentCard(not the legacy top-level field).securitySchemes.workspaceToken.httpAuthSecurityScheme.scheme: "bearer". - Errors —
google.rpc.Statuswithdetails: [{ "@type": "...ErrorInfo", "reason": "TASK_NOT_FOUND" | "WORKSPACE_QUOTA_EXCEEDED" | …, "domain": "a2a-protocol.org" }].
Each module has a test_v1_compliance.py peer that walks the JSON output and asserts no v0.3 leftovers (kind, mimeType, final, supportsAuthenticatedExtendedCard, preferredTransport, additionalInterfaces, /v1/ URL prefix).
URL layout
GET /agents/{agentId}/.well-known/agent-card.json (auth required in this deployment)
POST /agents/{agentId}/jsonrpc (JSON-RPC 2.0 envelope)
POST /agents/{agentId}/message:send (HTTP+JSON, sync or returnImmediately)
POST /agents/{agentId}/message:stream (SSE)
GET /agents/{agentId}/tasks/{taskId}
POST /agents/{agentId}/tasks/{taskId}:cancel
POST /agents/{agentId}/tasks/{taskId}:subscribe (SSE)
POST /agents/{agentId}/tasks/{taskId}/pushNotificationConfigs
GET /agents/{agentId}/tasks/{taskId}/pushNotificationConfigs/{configId}
GET /agents/{agentId}/tasks/{taskId}/pushNotificationConfigs
DELETE /agents/{agentId}/tasks/{taskId}/pushNotificationConfigs/{configId}
GET /health (sidecar liveness — no auth)The card endpoint is technically public per spec; we require Bearer here because Multica keys agents by workspace and we use the token to disambiguate.
Backend internal API
Lives at /internal/a2a/* on the Go backend. Only callable with X-Internal-Token:
POST /internal/a2a/tokens/validate {token} → {workspace_id, token_id, created_by}
GET /internal/a2a/agents?workspace_id=… → [agent…] (only is_a2a_exposed)
GET /internal/a2a/agents/{id}?workspace_id=…
POST /internal/a2a/tasks → create chat-style task (source='a2a')
GET /internal/a2a/tasks/{id}
GET /internal/a2a/tasks?workspace_id=…&cursor=…&limit=N (cursor pagination)
POST /internal/a2a/tasks/{id}/cancelExposing the same endpoints to the public web UI is done via a thin parallel surface under /api/workspaces/{id}/a2a/{tokens,tasks,agents/{agentId}/card-preview}, gated by the standard Auth + workspace-role middleware. That's what powers Settings → A2A.
Quotas — what enforces what
| Layer | What it limits | Code |
|---|---|---|
| Sidecar | Per-token rate (60/min, 300 burst) | services/a2a/a2a_server/auth.py |
| Backend | Per-workspace concurrent A2A tasks (50) | server/internal/handler/a2a_internal.go (MaxA2AInflightTasksPerWorkspace) |
| Backend SQL | Per-(agent, chat_session_id) — strictly serial | server/pkg/db/queries/agent.sql ClaimAgentTask |
| Backend Go | Per-agent MaxConcurrentTasks (default 6) | server/internal/service/task.go |
| Backend sweeper | Queued > 2h or running without heartbeat > 2.5h → failed | server/cmd/server/runtime_sweeper.go |
These layer cleanly: rate-limit shapes burst at the sidecar; quota guards workspace-wide flooding at the backend; per-agent serialization keeps the daemon honest. None of them required changes for A2A to work — the only A2A-specific addition is the per-workspace concurrent cap.
Files of interest
| Where | What |
|---|---|
services/a2a/a2a_server/main.py | FastAPI factory, BearerAuth middleware, lazy per-agent dispatch. |
services/a2a/a2a_server/executor.py | MulticaAgentExecutor — execute() creates the chat task and polls until terminal; cancel() forwards to backend. |
services/a2a/a2a_server/routes.py | AgentSubAppRegistry (lazy per-agent Starlette sub-apps) + _ContextBuilderWithToken. |
services/a2a/a2a_server/card_builder.py | Multica agent dict → AgentCard protobuf. |
services/a2a/a2a_server/parts.py | Multica result → unified Part / Artifact. |
services/a2a/a2a_server/state_mapping.py | Status string → TaskState_* enum. |
server/migrations/081_a2a_protocol.up.sql | All schema additions. |
server/internal/handler/a2a_internal.go | Sidecar-secret-gated internal API. |
server/internal/handler/a2a_token.go | User-facing token CRUD (mounted under /api/workspaces/.../a2a/tokens). |
server/internal/handler/a2a_workspace.go | Audit + card preview for the settings UI. |
server/internal/middleware/sidecar_secret.go | Shared-secret middleware. |
packages/views/settings/components/a2a-tab.tsx | Settings → A2A tab. |
packages/views/agents/components/inspector/a2a-exposure-picker.tsx | Per-agent toggle. |
Workflow rules — extending A2A and surviving upstream sync
Every change that touches A2A — adding a new field, extending the
executor, modifying the card builder, accepting a new SDK release,
upgrading zagents from upstream — MUST end with the official
a2a-inspector validator showing validation_errors: NONE. The unit
suites (Go field-mapping, sidecar pytest, Vitest UI) catch most
regressions, but the inspector is the only thing that exercises the
full wire format against the spec's pydantic schema. A passing local
suite + failing inspector means the spec drifted under us — fix it
before shipping.
The A2A stack lives only in the private fork and never PRs upstream.
For every git merge upstream/main:
- Run the pre-merge diff scan over the sensitive files
(
server/cmd/server/router.go,server/pkg/db/queries/agent.sql,server/internal/handler/agent.go,packages/core/types/agent.ts, etc — full list inUPSTREAM_SYNC.md). - After merge, run the post-merge sanity script: file-presence checks,
grepchecks for our patches inside upstream files, the three test suites, and the live inspector compliance probe. - Never
git checkout --theirson conflicts in those files — that silently drops our A2A patches.
The full procedure is in UPSTREAM_SYNC.md under
"Защита приватного A2A-слоя при merge upstream".
What we deliberately deferred
- JWS signing of Agent Cards — optional in v1.0; we'll add when external trust matters.
- gRPC binding — pluggable via
a2a-sdk[grpc]; ship if asked. - Cross-process streaming bridge — sidecar uses the SDK's in-memory task store; sufficient for single-instance setups.
- OAuth flows — Bearer is enough for the internal-tooling target audience.
- Per-token usage / billing rollup — we capture token usage on the existing
task_usagetable; an A2A-specific dashboard can come later without protocol changes.