Skip to content

How to connect two capsules with A2A messaging

Murmur capsules can send tasks to each other directly — an orchestrator capsule can delegate work to a worker capsule by sending it a message. This guide walks through launching a worker capsule, reading its URL, and calling it from an orchestrator capsule. It also covers the network policy that governs what each capsule is allowed to contact.

This is the hand-wired path: you pin the worker's port, send it work with curl, poll it and cancel it yourself. For a group of capsules that hand each other work on their own, launch a formation instead — see How to launch a formation of capsules.

What is A2A?

A2A (Agent-to-Agent) is a JSON-RPC 2.0 protocol for structured communication between autonomous agents. One capsule sends a message to another over HTTP and receives a task ID in return. It can then poll for results or stream live events while the receiving capsule processes the work — without either side knowing about the other's internal implementation.

Key concepts:

  • Task — a unit of work submitted via an incoming message, identified by a task ID
  • Context — a conversation thread shared across multiple tasks (contextId)
  • Orchestrator capsule — the capsule that sends tasks to other capsules
  • Worker capsule — the capsule that accepts and processes incoming tasks

The relevant manifest options are:

Option Controls
lifecycle.task_acceptance Whether and how many A2A tasks the capsule accepts
lifecycle.after_task What the capsule does after a task completes
exports.peer_tasks.accept Whether the worker capsule takes tasks that other capsules send it
network.internal_port Pins the worker capsule to a fixed port so the orchestrator capsule allow list stays stable
capabilities.network.allow Host/URL patterns the capsule may connect to — must include peer capsule URLs

Step 1 — export PORT and write the worker capsule manifest

The worker capsule is a long-running capsule that accepts messages and processes them one at a time.

Export a fixed port so every subsequent command can reference it by name:

export PORT=52222

Create worker/murmur.yaml:

name: my-worker
version: "0.1.0"

artifacts:
  - name: murmur/driver-anthropic
    version: "0.19.0"
    runtime: driver
    gateway:
      endpoint: https://api.anthropic.com
      api_key: ${ANTHROPIC_API_KEY}

network:
  internal_port: 52222

inference:
  transport: http
  model: claude-sonnet-5
  driver:
    artifact: murmur/driver-anthropic

lifecycle:
  task_acceptance: queue
  after_task: sleep
  queue_depth: 2

exports:
  peer_tasks:
    accept: true
name: my-worker
version: "0.1.0"

artifacts:
  - name: murmur/driver-openai
    version: "0.19.0"
    runtime: driver
    gateway:
      endpoint: https://api.openai.com/v1
      api_key: ${OPENAI_API_KEY}

network:
  internal_port: 52222

inference:
  transport: http
  model: o3-mini-high
  driver:
    artifact: murmur/driver-openai

lifecycle:
  task_acceptance: queue
  after_task: sleep
  queue_depth: 2

exports:
  peer_tasks:
    accept: true
name: my-worker
version: "0.1.0"

artifacts:
  - name: murmur/driver-deepseek
    version: "0.17.0"
    runtime: driver
    gateway:
      endpoint: https://api.deepseek.com
      api_key: ${DEEPSEEK_API_KEY}

network:
  internal_port: 52222

inference:
  transport: http
  model: deepseek-r1
  driver:
    artifact: murmur/driver-deepseek

lifecycle:
  task_acceptance: queue
  after_task: sleep
  queue_depth: 2

exports:
  peer_tasks:
    accept: true

exports.peer_tasks.accept: true lets the worker capsule take tasks from other capsules. Without it, the worker answers every message an orchestrator capsule sends it 403 peer_not_accepted, and the orchestrator's send fails with that reason. A message from curl or any client that does not identify as a capsule is served either way.

network.internal_port pins the worker capsule to a fixed port on every run. Without it the runtime picks an OS-assigned port at startup, which changes between runs and would invalidate the orchestrator capsule's allow list entry. task_acceptance: queue and after_task: sleep keep the capsule alive between tasks so the orchestrator capsule can send multiple messages to the same session.


Step 2 — install dependencies

From the worker/ directory, fetch all declared artifacts:

mur install
Different ways to install artifacts

mur install fetches an artifact from the source of its scope. murmur/ artifacts come from the built-in source, murmur-nexus/default-artifacts, with no configuration.

Option A — name the source of a scope in ~/.murmur/config.yaml:

registry:
  sources:
    - repo: <owner>/<repo>
      token: "${GITHUB_TOKEN}"

Then install by full name, with or without a version:

mur install <owner>/<artifact-name>@<version>

Option B — pass a full GitHub reference and skip configuration entirely:

mur install github:<owner>/<repo>@<tag>

See Installing artifacts to learn more.


Step 3 — start the worker capsule

mur run --manifest worker/murmur.yaml

The worker capsule starts and prints its URL:

mur run --manifest worker/murmur.yaml
murmur: url localhost:52222
session: ses_019ed2af53da75c2aefee84ee10c34af

Because network.internal_port: 52222 is set, the URL is stable across restarts and matches $PORT.

Locking a port is for development convenience

Specifying network.internal_port makes the worker capsule's URL predictable, which simplifies the orchestrator capsule's allow list during development. In production, omit it and let the OS assign a port — fixed ports can cause bind failures when multiple capsule sessions run on the same host or when the port is already in use by another process.


Step 4 — write the orchestrator capsule manifest

The orchestrator capsule must declare the worker capsule's URL in capabilities.network.allow. Without this the runtime refuses the outgoing connection before any TCP packet is sent.

Create orchestrator/murmur.yaml:

name: my-orchestrator
version: "0.1.0"

artifacts:
  - name: murmur/driver-anthropic
    version: "0.19.0"
    runtime: driver
    gateway:
      endpoint: https://api.anthropic.com
      api_key: ${ANTHROPIC_API_KEY}

capabilities:
  network:
    allow:
      - localhost:52222

inference:
  transport: http
  model: claude-sonnet-5
  driver:
    artifact: murmur/driver-anthropic
name: my-orchestrator
version: "0.1.0"

artifacts:
  - name: murmur/driver-openai
    version: "0.19.0"
    runtime: driver
    gateway:
      endpoint: https://api.openai.com/v1
      api_key: ${OPENAI_API_KEY}

capabilities:
  network:
    allow:
      - localhost:52222

inference:
  transport: http
  model: o3-mini-high
  driver:
    artifact: murmur/driver-openai
name: my-orchestrator
version: "0.1.0"

artifacts:
  - name: murmur/driver-deepseek
    version: "0.17.0"
    runtime: driver
    gateway:
      endpoint: https://api.deepseek.com
      api_key: ${DEEPSEEK_API_KEY}

capabilities:
  network:
    allow:
      - localhost:52222

inference:
  transport: http
  model: deepseek-r1
  driver:
    artifact: murmur/driver-deepseek

Step 5 — send a message to the worker capsule

With the worker capsule running, the orchestrator capsule (or any HTTP client) can send it a message. The worker capsule's endpoint is http://localhost:<port>:

curl -s -X POST http://localhost:$PORT \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "SendMessage",
    "params": {
      "message": {
        "messageId": "msg-001",
        "contextId": "ctx-001",
        "role": "ROLE_USER",
        "parts": [{"text": "Acknowledge message from orchestrator capsule."}]
      },
      "configuration": {"returnImmediately": true}
    }
  }'

The door speaks A2A v1.0: every JSON-RPC request carries A2A-Version: 1.0, and one without it is answered with error -32009 and starts nothing.

"returnImmediately": true has the door answer as soon as it accepts the task, so you can poll it. Leave it out and the door holds the request until the task settles, then answers with the task as it ended, response artifact included — see Agent Card: Answers.

Response:

{"jsonrpc":"2.0","id":1,"result":{"task":{"id":"tsk_019ed33d1f6873b09d25f0dc5ce387c4","contextId":"ctx-001","status":{"state":"TASK_STATE_SUBMITTED","timestamp":"2026-10-13T09:00:00.123Z"}}}}

Save the returned result.task.id — that is the task ID you will use to poll status.

When the orchestrator capsule is itself a WASM capsule component, it sends messages via the host's message interface rather than raw HTTP. The host handles the JSON-RPC wire format transparently and enforces the network policy check before making any connection.


Alternative: stream events instead of polling

Use mur watch from a second terminal to observe live progress — inference turns, tool results, and completion state — as the agent loop runs. It takes a session address, so the second terminal needs nothing the first one printed:

mur watch @1

@1 is the most recent capsule running on this machine, resolved against the running-capsule records. Name an older one by the last four characters of its session id, or reach a capsule directly with mur watch --url localhost:$PORT.

To consume the same events from your own client, send murmur.WatchSession or SendStreamingMessage to the capsule's address. Streaming Protocol lists both endpoints, the A2A events they write, and the A2A-Extensions header that adds murmur's text and tool frames to a SendStreamingMessage stream.


Step 6 — poll the task status

Use the task ID returned in step 5 with GetTask:

curl -s -X POST http://localhost:$PORT \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":2,"method":"GetTask","params":{"id":"<your-task-id>"}}'

status.state progresses through TASK_STATE_SUBMITTED → TASK_STATE_WORKING → TASK_STATE_COMPLETED | TASK_STATE_FAILED | TASK_STATE_CANCELED. Task states lists every state.

Final status.state The task
TASK_STATE_COMPLETED Finished its work. A reply cut off at inference.max_tokens on the task's last allowed turn still counts
TASK_STATE_FAILED Did not finish: an inference call failed, a request-input wait timed out, it used every turn inference.max_turns allows, two turns in a row stopped at inference.max_tokens with no text and no tool call, or a spend ceiling stopped it. The worker's trace.jsonl says which — its task_end status, and a task_failed line naming the cause
TASK_STATE_CANCELED Was stopped with CancelTask, murmur.StopSession or mur stop
{
    "jsonrpc": "2.0",
    "id": 2,
    "result":
    {
        "id": "tsk_019ed5211c827f63a8fe4be623277c55",
        "contextId": "ctx-001",
        "status":
        {
            "state": "TASK_STATE_COMPLETED",
            "message":
            {
                "messageId": "msg_tsk_019ed5211c827f63a8fe4be623277c55_status",
                "contextId": "ctx-001",
                "taskId": "tsk_019ed5211c827f63a8fe4be623277c55",
                "role": "ROLE_AGENT",
                "parts": [{"text": "task completed", "mediaType": "text/plain"}]
            },
            "timestamp": "2026-10-13T09:00:04.512Z"
        },
        "artifacts":
        [
            {
                "artifactId": "response",
                "name": "response",
                "parts": [{"text": "Acknowledged.", "mediaType": "text/plain"}]
            }
        ]
    }
}

The worker's answer is the response artifact. Poll until status.state is TASK_STATE_COMPLETED or TASK_STATE_FAILED. The task registry on the worker capsule remembers completed tasks, so you can query a task ID after the task has already finished.

The HTTP server shuts down with the session

Once the worker capsule exits (idle timeout, or after the last queued task), its HTTP server is released. Final status is always available in the worker session's trace.jsonl, in its records directory.


Cancelling a running task

CancelTask stops one task. The worker capsule's session, its conversation and its queue keep going — queued tasks proceed and the capsule keeps answering — so this is not a way to shut a capsule down.

curl -s -X POST http://localhost:$PORT \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":3,"method":"CancelTask","params":{"id":"<your-task-id>"}}'

The work in flight is stopped rather than waited out — the inference call is dropped, and under transport: process the harness the capsule drives is interrupted — and the task reaches the terminal state TASK_STATE_CANCELED:

{
    "jsonrpc": "2.0",
    "id": 3,
    "result":
    {
        "id": "tsk_019ed5211c827f63a8fe4be623277c55",
        "contextId": "ctx-001",
        "status":
        {
            "state": "TASK_STATE_CANCELED",
            "timestamp": "2026-10-13T09:00:04.512Z"
        }
    }
}

mur cancel @1 <your-task-id> does the same thing from a terminal, naming the capsule by session address rather than by URL.

What a cancel does on the process transport

The harness is a CLI with an agent loop of its own, so stopping the task means stopping that process. The runtime interrupts it the way its process driver declares — writing the driver's interrupt bytes to the harness's stdin, or sending it SIGINT — gives it 10 seconds to end on its own, and kills it if it does not. A driver that declares no graceful interrupt has its harness killed at once: a person can always stop the task.

The task ends TASK_STATE_CANCELED either way, whatever the harness says on its way out, and the capsule keeps its session, its queue and the conversation this context was holding: the next message under the same contextId resumes the same harness session. When the harness had to be killed, the terminal status says so — task canceled; the harness was killed and its session may not resume cleanly — because a harness cut off mid-turn may not be able to continue that session.

Cancelling a task that has already reached TASK_STATE_COMPLETED, TASK_STATE_FAILED, TASK_STATE_REJECTED or TASK_STATE_CANCELED changes nothing, and is answered with JSON-RPC error -32002, TaskNotCancelableError, whose ErrorInfo metadata names the task and the state it ended in:

{
    "jsonrpc": "2.0",
    "id": 3,
    "error":
    {
        "code": -32002,
        "message": "Task cannot be canceled: it is already TASK_STATE_COMPLETED",
        "data":
        [
            {
                "@type": "type.googleapis.com/google.rpc.ErrorInfo",
                "reason": "TASK_NOT_CANCELABLE",
                "domain": "a2a-protocol.org",
                "metadata": { "taskId": "tsk_019ed5211c827f63a8fe4be623277c55", "state": "TASK_STATE_COMPLETED" }
            }
        ]
    }
}

mur cancel reports such a task as nothing to cancel and exits 0. A task id the capsule never held is answered -32001, Task not found. Both errors are listed under Agent Card: Errors.

What the cancel names

A detached shell command is not ended by the cancel: it runs until it finishes or its session ends, which kills it. A shell command still running in the foreground is moved to the background at once and kept the same way, and the task ends canceled without waiting for it. The response is read at the moment of the cancel, before that move, so it may leave such a command out; the task's task_canceled record names it. A sub-capsule the task delegated to is ended by the cancelled task before its task_end, whether delegate-task started it or a plan's capsule step did; a plan stops with its task and runs none of its remaining steps. When either was in flight at the moment the cancel was answered, the response carries an artifact named residue with one data part per item, each part's data a JSON object:

{
    "artifacts":
    [
        {
            "artifactId": "residue",
            "name": "residue",
            "parts":
            [
                {"data": {"kind": "detached_shell", "work_id": "wrk_9f2a1c", "binary": "bash", "command": "sleep 30", "started_at_ms": 1757068800123},
                 "mediaType": "application/json"},
                {"data": {"kind": "delegation", "delegation_id": "dlg_7b31de", "capsule": "my-worker", "version": "0.1.0", "child_session_id": "ses_019ed…", "child_workdir": "/home/me/.murmur/records/home/me/proj/ses_019ed1f0…/children/my-worker-7b31de0c4a9f1e25"},
                 "mediaType": "application/json"}
            ]
        }
    ]
}

A cancel with nothing in flight omits the artifacts key entirely, so "nothing was in flight" is distinguishable from "these things were" without parsing an empty list. mur cancel prints a detached shell command as running: and a delegation as ended: — see mur cancel.

The trace tells the same story from the loop's side: a task_canceled event naming the wait that was interrupted and what was in flight when the loop stopped, and a task_end carrying exit_status: "canceled". A process capsule adds a harness_interrupt event naming the interrupt that went out and a harness_exit with cause: "canceled", which mur trace show prints under ── Harness ──.


Ending the session

murmur.StopSession cancels every task the session still holds and reports what it leaves running, in one answer. The capsule keeps running and keeps answering afterwards — the method cancels and reports, and ends nothing. It is one of murmur's own door methods, listed in the agent card's door extension.

curl -s -X POST http://localhost:$PORT \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{"jsonrpc":"2.0","id":4,"method":"murmur.StopSession","params":{}}'
{
    "jsonrpc": "2.0",
    "id": 4,
    "result":
    {
        "session_id": "ses_019ed2af53da75c2aefee84ee10c34af",
        "canceled": ["tsk_019ed5211c827f63a8fe4be623277c55"],
        "residue":
        [
            {"kind": "detached_shell", "work_id": "wrk_9f2a1c", "binary": "bash", "command": "sleep 30", "started_at_ms": 1757068800123}
        ]
    }
}
Key Carries
session_id The session this door answers for
canceled The task ids this call moved to TASK_STATE_CANCELED, sorted. Empty when nothing was still running
residue One object per thing the session leaves running, the residue items CancelTask names. Empty when nothing is

All three keys are always present. residue is [] rather than absent when nothing is running, which is the one place this differs from CancelTask: a session stop has to be able to say "nothing" as a positive fact, because that is the whole answer the caller asked for. Issued a second time the method returns an empty canceled array rather than an error, so a retried stop is not a failure.

To end the capsule itself, use mur stop. It calls this method first and signals the process afterwards, so the account of what the session leaves running is read while the capsule can still be asked for it.


Step 7 — inspect both sides in the trace

After the session, both the orchestrator capsule and worker capsule produce trace.jsonl files. Each captures its own side of the exchange.

Worker capsule trace — shows the incoming task and the agent loop that ran it. Because two capsule sessions are running, pass the last four characters of the worker capsule session ID printed in step 3 to target the correct session:

mur trace show 34af
Different ways to identify a session

mur trace show with no argument reads the most recent session:

mur trace show

To name another one, pass an ordinal counting back from the newest (@2), the last 4 or more characters of its ID (3e4b), the full ID, or a path to its trace.jsonl:

mur trace show @2
mur trace show 3e4b
mur trace show ses_6801f81dd28b4a9daf434e8324c4793e
mur trace show path/to/trace.jsonl

Use --workdir <path> if your session directories are not under ./workdir. Every command that names a session takes the same addresses — see Session addresses.

The worker capsule trace includes an a2a_task_received event for each message it accepted, followed by that task's own task_start / inference / task_end block, all inside the launch's single session_start / session_end pair:

── Session ──────────────────────────────────────
session:    ses_019ed2af53da75c2aefee84ee10c34af
capsule:    my-worker v0.1.0
...
── Tasks ───────────────────────────────────────
task 1  tsk_019ed33d  turns: 3  in: 892  out: 241  ok  4.1s

Orchestrator capsule trace — if the orchestrator capsule is a WASM script capsule, its trace includes an a2a_send event for each outgoing message it dispatched:

{"event_type":"a2a_send","peer_url":"localhost:52222","message_id":"msg-001","task_id":"tsk_019ed33d1f6873b09d25f0dc5ce387c4","context_id":"ctx-001"}

When OTel tracing is configured, the traceparent header links the worker capsule session span as a child of the orchestrator capsule's span, giving you a unified trace across both capsules.


Summary

Concept What to configure
Worker capsule stays alive lifecycle.task_acceptance: queue + lifecycle.after_task: sleep
Fixed worker capsule port network.internal_port in the worker capsule manifest; errors if port is already in use
Worker capsule takes tasks from other capsules exports.peer_tasks.accept: true in the worker capsule manifest
Orchestrator capsule can reach worker capsule capabilities.network.allow must include the worker capsule's URL
Network policy enforcement Any peer URL not in network.allow is rejected before TCP connection
Task ID Returned by the message call; use it with GetTask to poll status and CancelTask to stop it
Stopping one task CancelTask, or mur cancel <session> <task-id>; the session, its conversation and its queue keep running
Ending the session mur stop <session>, which calls murmur.StopSession for the account of what is still running and then signals the process. murmur.StopSession on its own cancels and reports without ending anything
Trace Both capsules write independent trace.jsonl files; a2a_task_received appears on the worker capsule side, a2a_send on the orchestrator capsule side