{
  "schema_version": 2,
  "id": "develop/ai/context-engine/agent-mesh/a2a-sdk",
  "title": "Integrate Agent Mesh with A2A",
  "url": "https://redis.io/docs/latest/develop/ai/context-engine/agent-mesh/a2a-sdk/",
  "summary": "Add resumable task updates to an A2A Python application.",
  "tags": [
    "docs",
    "develop",
    "ai"
  ],
  "last_updated": "2026-11-03T09:50:14-05:00",
  "page_type": "content",
  "content_hash": "4eb9e8edafaf0d4519041bd7f5f9ebba820407d4102b6e0aa580d31f14a3172e",
  "sections": [
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "Use the Agent Mesh software development kit (SDK) to let clients resume Agent2Agent (A2A) task updates after a disconnect, including through another serving replica. Redis retains the updates and a current Task snapshot. Your application runs the agent and controls access.\n\nFor a runnable browser example, start with the [A2A quickstart](https://redis.io/docs/latest/develop/ai/context-engine/agent-mesh/quickstart#resume-a2a-task-updates). This guide explains how to add the integration to an existing application."
    },
    {
      "id": "install-the-integration",
      "title": "Install the integration",
      "role": "setup",
      "text": "You need Python 3.11 or later, Redis 8.6.2 or later with native producer idempotency (`XADD IDMP`) and JavaScript Object Notation (JSON) support, and the official A2A Python SDK 1.2.x.\n\n[code example]\n\nTo use both integrations in one application, install `redis-iris[a2a,fastapi-sse]`. Their shared redis-py range is `>=8.0.1,<9`; separate Python environments are not required.\n\nUse the [quickstart's database setup](https://redis.io/docs/latest/develop/ai/context-engine/agent-mesh/quickstart#choose-your-redis-database) for a local or Redis Cloud connection. Keep database credentials in application configuration."
    },
    {
      "id": "connect-your-request-handler",
      "title": "Connect your request handler",
      "role": "content",
      "text": "The application factory in this example accepts your existing `AgentExecutor`, `AgentCard`, and Starlette authentication backend. The authentication backend must verify credentials and populate `request.user`. Set your card's streaming capability to `True` before constructing the application.\n\n[code example]\n\nUse the same Redis database, namespace, and ownership policy on every replica. Use primary reads for both snapshots and events. Your application owns credentials, Transport Layer Security (TLS), connection pools, timeouts, and shutdown.\n\nEach waiting subscription occupies a Redis connection. A separate reader pool keeps blocked subscriptions from exhausting connections needed for writes and heartbeats. Set its socket timeout above the event stream's `block_ms` interval, which defaults to at most 4,000 milliseconds and adapts to a shorter client socket timeout.\n\n`RedisTaskStore` commits the Task snapshot, version, and event together. The paired event stream reads that log; its `publish()` method does not append another copy. Keep writing through the official request handler and its task store."
    },
    {
      "id": "enforce-ownership",
      "title": "Enforce ownership",
      "role": "content",
      "text": "By default, the store requires an authenticated user. Behind the SDK's Starlette routes, ownership uses the user's `display_name`. Ensure this value uniquely identifies a principal across your tenants.\n\nFor a different identity model, pass `owner_resolver` to `RedisTaskStore`. The callable receives the official `ServerCallContext` and must return a nonempty, stable owner derived from verified identity. Never derive it from request bodies, task IDs, or unverified headers.\n\nThe official handler and resumable routes check ownership before subscribing. The internal `RedisTaskEventStream` reader has no request context: do not expose it directly to callers without that check. Task IDs and event cursors grant no access."
    },
    {
      "id": "start-and-resume-a-subscription",
      "title": "Start and resume a subscription",
      "role": "content",
      "text": "Pass the handler's same `agent_card` to `create_resumable_rest_routes()`; its streaming capability must be enabled. The factory adds event cursors to the HTTP+JSON binding's `POST /message:stream` and `GET` or `POST /tasks/{id}:subscribe` routes. Other REST routes retain the official SDK's behavior.\n\nTo follow an existing active task, send an authenticated request to `/tasks/<task-id>:subscribe` with `A2A-Version: 1.0`. Without a cursor, the response starts with the current Task snapshot and follows new updates.\n\nTask frames use server-sent events (SSE) and carry an `id:` containing the Task version. After applying a frame, save its ID. On reconnect, send that value in `Last-Event-ID` to receive retained updates strictly after it. The routes also accept the `after` query parameter.\n\nFor example, these are the request headers for a reconnect using your application's bearer authentication:\n\n[code example]\n\nUse an SSE client that parses frames and handles reconnect status codes. A fetch-based browser client can supply both the version and authorization headers. Native `EventSource` cannot set those headers. To use it, you need an explicit fixed-version server policy and browser-compatible authentication, as demonstrated by the [local quickstart](https://redis.io/docs/latest/develop/ai/context-engine/agent-mesh/quickstart#resume-a2a-task-updates). The route factory does not install that policy.\n\nA missing version header is interpreted as A2A 0.3 and rejected by the resumable routes. Declaring `resumable_streams_extension()` in the card advertises support; it does not select the request version. The official A2A client does not send `Last-Event-ID`, so it receives standard snapshot-then-live subscriptions.\n\nMessage-only replies and error frames have no Task-version ID. They must not advance the saved cursor. Task versions are not raw Redis stream entry IDs.\n\nThe route factory's `credential_expiry` callback and `max_connection_duration` limit also bound the wait for the first frame. If credentials expire before the server sends response headers, the request returns HTTP 401; refresh credentials before reconnecting. If the maximum connection duration expires first, the request returns HTTP 503 with `Retry-After: 1`; wait one second, then reconnect with your saved cursor."
    },
    {
      "id": "handle-replay-and-completion",
      "title": "Handle replay and completion",
      "role": "content",
      "text": "| Condition | Behavior |\n| --- | --- |\n| Retained cursor | Replay updates after the cursor, then follow new updates. |\n| Trimmed history or compact Task replacement | Return the current Task snapshot, then follow events after its version. Intermediate updates covered by the snapshot are not reproduced. |\n| Final cursor already consumed | Return HTTP 204; stop reconnecting. |\n| No cursor for a terminal task | Reject the subscription; use the normal task retrieval API. |\n| Malformed, repeated, or future cursor | Return HTTP 400. |\n| Missing or expired snapshot | A new request receives TaskNotFound (HTTP 404). After streaming headers, send a TaskNotFound error frame. |\n\nAn update that moves a Task to a terminal, input-required, or auth-required state ends the HTTP subscription. If the Task already requires input or authentication when you subscribe, the stream can remain open. Without a cursor, it sends the waiting Task snapshot and then waits. With a cursor matching the current version, it waits without sending a Task frame.\n\nAct on the Task's state and provide the required input or credentials through your application's A2A interaction. Do not wait for the stream to close. Disconnecting a subscriber does not cancel execution.\n\nFor cancellation, use the official handler's cancellation path. A cancellation can supersede a stale version while the stored Task is nonterminal. A later write cannot revive a terminal Task. These guards protect Redis updates; they do not undo external effects."
    },
    {
      "id": "monitor-executing-agents",
      "title": "Monitor executing agents",
      "role": "content",
      "text": "`store.request_handler()` applies `store.heartbeat(executor, interval=5.0, alive_ttl=15.0)` to track the running agent under a process lease. It checks stored terminal state and stops local execution when another replica cancels or finishes the Task. Set `alive_ttl` greater than `interval`.\n\nA store-backed subscriber or `get()` marks a submitted or working Task failed with a `producer_lost` status message when its producer lease expires. Tasks waiting for input or authentication, and tasks without tracked heartbeats, are excluded. To resume such a Task in another process instead, see [Hand off a task to another process](#hand-off-a-task-to-another-process).\n\nThe wrapper does not restart computation. Without it, an agent on another replica learns of cancellation at its next write. With A2A SDK 1.2.3, continued emission after that cancellation can also leave handler shutdown waiting indefinitely; the wrapper stops the agent first."
    },
    {
      "id": "hand-off-a-task-to-another-process",
      "title": "Hand off a task to another process",
      "role": "content",
      "text": "One process can start a Task and another process can finish it. Both write to the same Task and its ordered event log, so subscribers on any replica see one sequence of updates, whichever process produced them.\n\nBefore the heartbeat wrapper runs your executor, it claims the Task for its process. Each save carries that claim. After another process claims the Task, the store rejects saves from the previous process with `ConcurrentTaskModificationError`, so a process that stalls and later wakes up cannot overwrite newer work. That process also stops its executor at its next successful heartbeat.\n\nThe wrapper grants a claim when the Task is new, when no process runs it, when it waits for input or authentication, or when the process that ran it stopped sending heartbeats. It refuses a claim for a terminal or deleted Task, a Task the caller doesn't own, and a Task that a process sending heartbeats is running or has just claimed. After a refused claim, the executor doesn't run, the Task keeps its current state, and the message is not added to the Task's history. If your executor's first update on a resumed Task is a status or artifact update, and you don't limit `history_length`, check the returned Task's history to tell whether your message was accepted."
    },
    {
      "id": "continue-a-waiting-task-in-another-process",
      "title": "Continue a waiting task in another process",
      "role": "content",
      "text": "A Task that waits for input or authentication is not running anywhere. The next message can reach any replica. That replica's wrapper claims the Task and runs your executor with the stored Task as `context.current_task`. No extra configuration is required. While one replica is processing a reply, other replicas refuse a second reply to the same Task."
    },
    {
      "id": "take-over-a-long-running-task",
      "title": "Take over a long-running task",
      "role": "content",
      "text": "By default, a reader that notices a lost heartbeat marks a submitted or working Task failed with `producer_lost`. This includes the task read that a resume request makes. To let another process resume the Task instead, create the store with `takeover=True` on every replica:\n\n[code example]\n\nTo resume a Task, send it a message through the official request handler in the process that takes it over. The handler reloads the Task, the wrapper claims it, and your executor runs with the stored Task. A worker can make that call in-process, using a `ServerCallContext` that carries the Task owner's verified identity:\n\n[code example]\n\nBuild `owner_context` in trusted worker code from the identity that owns the Task. Never build it from request input. The message becomes part of the Task's history.\n\nHave your executor record its progress in the Task, for example as one artifact per completed step, and skip completed steps when `context.current_task` already contains them:\n\n[code example]\n\nThe integration does not detect stopped processes or schedule resumption. Track the Tasks your workers run, for example in your job queue, and resume or cancel those whose process stopped. With `takeover=True`, a running Task that nobody resumes stays working until `active_ttl` expires, and its subscribers stay connected until then. A step that the first process performed but did not record runs again in the next process, so make external effects safe to repeat."
    },
    {
      "id": "configure-retention-and-limits",
      "title": "Configure retention and limits",
      "role": "configuration",
      "text": "Pass these options to `RedisTaskStore`:\n\n| Option | Default | Purpose |\n| --- | --- | --- |\n| `namespace` | `iris:a2a` | Isolate applications and deployments. |\n| `active_ttl` | 604,800 seconds (7 days) | Active snapshot and event retention, refreshed by saves. |\n| `terminal_ttl` | 86,400 seconds (1 day) | Terminal snapshot and event retention. |\n| `metadata_ttl` | 86,400 seconds | Additional ownership and version retention after snapshot expiry. |\n| `max_task_bytes` | 1,048,576 | Serialized snapshot size limit. |\n| `max_event_bytes` | 1,048,576 | Serialized event size limit. |\n| `max_events` | 10,000 | Approximate retained log length. |\n| `max_attempts` | 5 | Maximum attempts under transaction contention. |\n| `takeover` | `False` | Keep a submitted or working Task claimable after its process stops sending heartbeats, instead of marking it failed. See [Hand off a task to another process](#hand-off-a-task-to-another-process). |\n\nHeartbeats do not extend Task retention. Set `active_ttl` longer than the longest interval without a saved update.\n\nThe byte limits count serialized protocol buffer bytes; stored JSON payloads use more space. Size the event limit and log length for your memory budget. An oversized payload raises `A2ACapacityExceeded` without changing the snapshot or log.\n\nOwner metadata prevents another owner from reusing a Task ID until it expires. Deleting an owned Task removes its snapshot and events while retaining ownership metadata for `metadata_ttl`. After metadata expiry, reuse cannot be detected.\n\n| Public API | Use it to |\n| --- | --- |\n| `RedisTaskStore` | Store owner-scoped snapshots and versioned updates through the official SDK. |\n| `store.request_handler()` | Build the official handler with paired events and heartbeat tracking. |\n| `store.event_stream()` | Build the matching event reader, with producer-loss recovery. |\n| `RedisTaskEventStream` | Configure a reader explicitly with `store=store` and a separate client. |\n| `store.heartbeat()` | Track execution liveness, claim Tasks for hand-off, and notice remote cancellation. |\n| `create_resumable_rest_routes()` | Expose cursor-enabled REST streaming. |\n| `resumable_streams_extension()` | Advertise cursor support in the agent card. |\n\nStorage and internal reader calls can raise `A2AStorageError`, `A2ACapacityExceeded`, or `A2AStreamExpired`. Stale or conflicting writes raise the official SDK's `ConcurrentTaskModificationError`. Let the handler and routes translate these into protocol responses rather than exposing storage exceptions to clients.\n\nTask listing scans an owner's Task index and suits modest inventories per owner. It is not a search index or a consistent snapshot during concurrent writes.\n\nSee the [beta limits](https://redis.io/docs/latest/develop/ai/context-engine/agent-mesh#requirements-and-limits) for deployment qualification. Redis durability remains application-owned; replay does not guarantee exactly-once external effects."
    }
  ],
  "examples": [
    {
      "id": "install-the-integration-ex0",
      "language": "bash",
      "code": "pip install 'redis-iris[a2a]'",
      "section_id": "install-the-integration"
    },
    {
      "id": "connect-your-request-handler-ex0",
      "language": "python",
      "code": "import os\nfrom contextlib import asynccontextmanager\n\nfrom a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes\nfrom redis.asyncio import Redis\nfrom redis_iris.agent_mesh.a2a import RedisTaskEventStream, RedisTaskStore\nfrom redis_iris.agent_mesh.a2a.routes import (\n    create_resumable_rest_routes,\n    resumable_streams_extension,\n)\nfrom starlette.applications import Starlette\nfrom starlette.middleware import Middleware\nfrom starlette.middleware.authentication import AuthenticationMiddleware\n\n\ndef create_app(agent_executor, agent_card, auth_backend):\n    commands = Redis.from_url(\n        os.environ[\"REDIS_URL\"], max_connections=16, socket_timeout=5\n    )\n    readers = Redis.from_url(\n        os.environ[\"REDIS_URL\"], max_connections=64, socket_timeout=5\n    )\n    store = RedisTaskStore(commands, namespace=\"myapp:a2a\")\n    event_stream = RedisTaskEventStream(readers, store=store)\n    handler = store.request_handler(\n        agent_executor, agent_card=agent_card, event_stream=event_stream\n    )\n    agent_card.capabilities.extensions.append(resumable_streams_extension())\n\n    @asynccontextmanager\n    async def lifespan(app):\n        async with commands, readers:\n            try:\n                yield\n            finally:\n                await handler.aclose()\n\n    return Starlette(\n        routes=[\n            *create_agent_card_routes(agent_card),\n            *create_jsonrpc_routes(handler, rpc_url=\"/\"),\n            *create_resumable_rest_routes(\n                handler, agent_card=agent_card, store=store, event_stream=event_stream\n            ),\n        ],\n        middleware=[Middleware(AuthenticationMiddleware, backend=auth_backend)],\n        lifespan=lifespan,\n    )",
      "section_id": "connect-your-request-handler"
    },
    {
      "id": "start-and-resume-a-subscription-ex0",
      "language": "http",
      "code": "GET /tasks/<task-id>:subscribe HTTP/1.1\nAccept: text/event-stream\nA2A-Version: 1.0\nAuthorization: Bearer <application-access-token>\nLast-Event-ID: <last-applied-task-version>",
      "section_id": "start-and-resume-a-subscription"
    },
    {
      "id": "take-over-a-long-running-task-ex0",
      "language": "python",
      "code": "store = RedisTaskStore(commands, namespace=\"myapp:a2a\", takeover=True)",
      "section_id": "take-over-a-long-running-task"
    },
    {
      "id": "take-over-a-long-running-task-ex1",
      "language": "python",
      "code": "import uuid\n\nfrom a2a.types import Message, Part, Role, SendMessageRequest\n\n\nasync def resume(handler, task_id, context_id, owner_context):\n    request = SendMessageRequest(\n        message=Message(\n            message_id=f\"resume-{uuid.uuid4()}\",\n            role=Role.ROLE_USER,\n            task_id=task_id,\n            context_id=context_id,\n            parts=[Part(text=\"resume\")],\n        )\n    )\n    request.configuration.return_immediately = True\n    return await handler.on_message_send(request, owner_context)",
      "section_id": "take-over-a-long-running-task"
    },
    {
      "id": "take-over-a-long-running-task-ex2",
      "language": "python",
      "code": "from a2a.server.agent_execution import AgentExecutor\n\n\nclass TripPlanner(AgentExecutor):\n    async def execute(self, context, event_queue):\n        task = context.current_task\n        done = {artifact.artifact_id for artifact in task.artifacts} if task else set()\n        if \"flights\" not in done:\n            ...  # book flights, then emit the \"flights\" artifact\n        if \"hotels\" not in done:\n            ...  # book hotels, then emit the \"hotels\" artifact\n        ...  # emit the completed status\n\n    async def cancel(self, context, event_queue):\n        ...  # emit the canceled status",
      "section_id": "take-over-a-long-running-task"
    }
  ]
}
