ZAgents Docs

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:

  1. 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 single UPDATE.

  2. Sidecar → Backend — X-Internal-Token: <shared secret> on every /internal/a2a/* call. The secret is in the backend .env and the sidecar .env; if either side is missing it, the routes return 503 (sidecar refuses to start) or 403 (backend rejects).

  3. Token validation cache — sidecar caches a successful validate_token response 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.state

Task lifecycle mapping

Multica agent_task_queue.statusA2A v1.0 TaskState
queuedTASK_STATE_SUBMITTED
dispatchedTASK_STATE_WORKING
runningTASK_STATE_WORKING
completedTASK_STATE_COMPLETED
failedTASK_STATE_FAILED
cancelledTASK_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 fieldInternal /internal/a2a/agents/{id}A2A AgentCard
agent.namenamename
agent.descriptiondescriptiondescription (truncated to 500)
agent.instructionsinstructionsdescription only when description is blank — otherwise omitted
agent.avatar_urlavatar_urliconUrl
workspace.nameworkspace_nameprovider.organization (provider.url filled with the sidecar's public base)
agent.is_a2a_exposedis_a2a_exposedgate — false → 404 on the card endpoint
agent_skill[] joinskills[] (id, name, description)skills[] with id, name, description, default i/o modes, empty tags
agent.workspace_idworkspace_idsupportedInterfaces[].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": {...}}. No kind. mediaType (not mimeType). filename available on any variant.
  • Stream events — wrapper-discriminated: {"taskStatusUpdate": {...}} or {"taskArtifactUpdate": {..., "index": N}}. No kind, no final (closure of the SSE stream signals end).
  • Agent Card — supportedInterfaces[] with url, protocolBinding, protocolVersion: "1.0", tenant. capabilities.extendedAgentCard (not the legacy top-level field). securitySchemes.workspaceToken.httpAuthSecurityScheme.scheme: "bearer".
  • Errors — google.rpc.Status with details: [{ "@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}/cancel

Exposing 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

LayerWhat it limitsCode
SidecarPer-token rate (60/min, 300 burst)services/a2a/a2a_server/auth.py
BackendPer-workspace concurrent A2A tasks (50)server/internal/handler/a2a_internal.go (MaxA2AInflightTasksPerWorkspace)
Backend SQLPer-(agent, chat_session_id) — strictly serialserver/pkg/db/queries/agent.sql ClaimAgentTask
Backend GoPer-agent MaxConcurrentTasks (default 6)server/internal/service/task.go
Backend sweeperQueued > 2h or running without heartbeat > 2.5h → failedserver/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

WhereWhat
services/a2a/a2a_server/main.pyFastAPI factory, BearerAuth middleware, lazy per-agent dispatch.
services/a2a/a2a_server/executor.pyMulticaAgentExecutor — execute() creates the chat task and polls until terminal; cancel() forwards to backend.
services/a2a/a2a_server/routes.pyAgentSubAppRegistry (lazy per-agent Starlette sub-apps) + _ContextBuilderWithToken.
services/a2a/a2a_server/card_builder.pyMultica agent dict → AgentCard protobuf.
services/a2a/a2a_server/parts.pyMultica result → unified Part / Artifact.
services/a2a/a2a_server/state_mapping.pyStatus string → TaskState_* enum.
server/migrations/081_a2a_protocol.up.sqlAll schema additions.
server/internal/handler/a2a_internal.goSidecar-secret-gated internal API.
server/internal/handler/a2a_token.goUser-facing token CRUD (mounted under /api/workspaces/.../a2a/tokens).
server/internal/handler/a2a_workspace.goAudit + card preview for the settings UI.
server/internal/middleware/sidecar_secret.goShared-secret middleware.
packages/views/settings/components/a2a-tab.tsxSettings → A2A tab.
packages/views/agents/components/inspector/a2a-exposure-picker.tsxPer-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:

  1. 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 in UPSTREAM_SYNC.md).
  2. After merge, run the post-merge sanity script: file-presence checks, grep checks for our patches inside upstream files, the three test suites, and the live inspector compliance probe.
  3. Never git checkout --theirs on 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_usage table; an A2A-specific dashboard can come later without protocol changes.