---
title: "Connect two capsules with A2A messaging"
description: "Murmur capsules can send tasks to each other directly — an orchestrator capsule can delegate work to a worker capsule by sending it a message."
canonical_url: "https://docs.murmur.nexus/how-to/capsules-a2a-messaging"
last_updated: "2026-09-16T12:06:30.000Z"
---

# 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.

> **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](/reference/manifest.md#lifecycle-task-acceptance) | Whether and how many A2A tasks the capsule accepts |
| [lifecycle.after_task](/reference/manifest.md#lifecycle-after-task) | What the capsule does after a task completes |
| [network.internal_port](/reference/manifest.md#field-capabilities) | Pins the worker capsule to a fixed port so the orchestrator capsule allow list stays stable |
| [capabilities.network.allow](/reference/manifest.md#field-capabilities) | 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:

```bash
export PORT=52222
```

Create `worker/murmur.yaml`:

**Anthropic**

```yaml
name: my-worker
version: "0.1.0"

artifacts:
  - name: murmur-driver-anthropic
    version: "1.0.0"
    runtime: driver

network:
  internal_port: 52222

inference:
  transport: http
  endpoint: https://api.anthropic.com
  model: claude-sonnet-5
  api_key: ${ANTHROPIC_API_KEY}
  driver:
    artifact: murmur-driver-anthropic

lifecycle:
  task_acceptance: queue
  after_task: sleep
  queue_depth: 2
```

**OpenAI**

```yaml
name: my-worker
version: "0.1.0"

artifacts:
  - name: murmur-driver-openai
    version: "1.0.0"
    runtime: driver

network:
  internal_port: 52222

inference:
  transport: http
  endpoint: https://api.openai.com
  model: o3-mini-high
  api_key: ${OPENAI_API_KEY}
  driver:
    artifact: murmur-driver-openai

lifecycle:
  task_acceptance: queue
  after_task: sleep
  queue_depth: 2
```

**DeepSeek**

```yaml
name: my-worker
version: "0.1.0"

artifacts:
  - name: murmur-driver-deepseek
    version: "1.0.0"
    runtime: driver

network:
  internal_port: 52222

inference:
  transport: http
  endpoint: https://api.deepseek.com
  model: deepseek-r1
  api_key: ${DEEPSEEK_API_KEY}
  driver:
    artifact: murmur-driver-deepseek

lifecycle:
  task_acceptance: queue
  after_task: sleep
  queue_depth: 2
```

`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:

```bash
mur install
```

> **Different ways to install artifacts**
>
> `mur install` needs to know where to fetch artifacts from. You have two options:
>
> **Option A — configure a registry source** in `~/.murmur/config.yaml`:
>
> ```yaml
> registry:
>   default: official
>   sources:
>     - name: official
>       type: github
>       repo: <owner>/<repo>
>       token: "${GITHUB_TOKEN}"
> ```
>
> Then install by artifact name and version:
>
> ```bash
> mur install <artifact-name@version>
> ```
>
> **Option B — pass a full GitHub reference** and skip configuration entirely:
>
> ```bash
> mur install github:<username>/<repo>@<tag>
> ```
>
> See [Installing artifacts](/reference/installing-artifacts.md) to learn more.

---

## Step 3 — start the worker capsule

```bash
mur run --manifest worker/murmur.yaml
```

The worker capsule starts and prints its URL to stderr:

```
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`:

**Anthropic**

```yaml
name: my-orchestrator
version: "0.1.0"

artifacts:
  - name: murmur-driver-anthropic
    version: "1.0.0"
    runtime: driver

capabilities:
  network:
    allow:
      - localhost:52222

inference:
  transport: http
  endpoint: https://api.anthropic.com
  model: claude-sonnet-5
  api_key: ${ANTHROPIC_API_KEY}
  driver:
    artifact: murmur-driver-anthropic
```

**OpenAI**

```yaml
name: my-orchestrator
version: "0.1.0"

artifacts:
  - name: murmur-driver-openai
    version: "1.0.0"
    runtime: driver

capabilities:
  network:
    allow:
      - localhost:52222

inference:
  transport: http
  endpoint: https://api.openai.com
  model: o3-mini-high
  api_key: ${OPENAI_API_KEY}
  driver:
    artifact: murmur-driver-openai
```

**DeepSeek**

```yaml
name: my-orchestrator
version: "0.1.0"

artifacts:
  - name: murmur-driver-deepseek
    version: "1.0.0"
    runtime: driver

capabilities:
  network:
    allow:
      - localhost:52222

inference:
  transport: http
  endpoint: https://api.deepseek.com
  model: deepseek-r1
  api_key: ${DEEPSEEK_API_KEY}
  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>`:

```bash
curl -s -X POST http://localhost:$PORT \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "message/send",
    "params": {
      "message": {
        "messageId": "msg-001",
        "contextId": "ctx-001",
        "role": "user",
        "parts": [{"text": "Acknowledge message from orchestrator capsule."}]
      }
    }
  }'
```

Response:

```json
{"jsonrpc":"2.0","id":1,"result":{"contextId":"ctx-001","id":"tsk_019ed33d1f6873b09d25f0dc5ce387c4","status":{"state":"submitted"}}}
```

Save the returned `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](/reference/cli.md#session-addresses), so the second terminal needs nothing the first one printed:

```bash
mur watch @1
```

`@1` is the most recent capsule running on this machine, resolved against the
[running-capsule records](/reference/cli.md#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`.

---

## Step 6 — poll the task status

Use the task ID returned in step 5 with `tasks/get`:

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

`state` progresses through: `submitted` → `working` → `completed` | `failed` | `canceled`.

```json
{
    "jsonrpc": "2.0",
    "id": 2,
    "result":
    {
        "contextId": "ctx-001",
        "id": "tsk_019ed5211c827f63a8fe4be623277c55",
        "status":
        {
            "state": "completed"
        }
    }
}
```

Poll until `state` is `completed` or `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 `trace.jsonl` in the worker capsule's workdir.

---

## Cancelling a running task

`tasks/cancel` 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.

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

The inference call in flight is dropped rather than waited out, and the task reaches the terminal
state `canceled`:

```json
{
    "jsonrpc": "2.0",
    "id": 3,
    "result":
    {
        "contextId": "ctx-001",
        "id": "tsk_019ed5211c827f63a8fe4be623277c55",
        "status":
        {
            "state": "canceled"
        }
    }
}
```

`mur cancel @1 <your-task-id>` does the same thing from a terminal, naming the capsule by
[session address](/reference/cli.md#session-addresses) rather than by URL.

Cancelling a task that has already reached `completed`, `failed`, `rejected` or `canceled` returns
that state and changes nothing. A task id the capsule never held is the one error: JSON-RPC code
`-32001`, `Task not found`.

### What the cancel left running

Nothing else is stopped. A detached shell command keeps its own lifecycle, and a delegated
sub-capsule is left running exactly as a delegation deadline leaves it. When either was running at
the moment the cancel was answered, the response carries an artifact named `residue` with one part
per item, each part's `text` a JSON object:

```json
{
    "artifacts":
    [
        {
            "name": "residue",
            "parts":
            [
                {"text": "{\"kind\":\"detached_shell\",\"work_id\":\"wrk_9f2a1c\",\"binary\":\"bash\",\"command\":\"sleep 30\",\"started_at_ms\":1757068800123}"},
                {"text": "{\"kind\":\"delegation\",\"delegation_id\":\"dlg_7b31de\",\"capsule\":\"my-worker\",\"version\":\"0.1.0\",\"child_session_id\":\"ses_019ed…\",\"child_workdir\":\".murmur/children/my-worker-7b31de\"}"}
            ]
        }
    ]
}
```

A cancel with nothing left running omits the `artifacts` key entirely, so "nothing else is
running" is distinguishable from "these things are" without parsing an empty list.

The trace tells the same story from the loop's side: a
[`task_canceled`](/reference/observability-schemas.md#task-canceled) event naming the wait that
was interrupted and what was still running when the loop stopped, and a `task_end` carrying
`exit_status: "canceled"`.

---

## Ending the session

`session/stop` 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.

```bash
curl -s -X POST http://localhost:$PORT \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":4,"method":"session/stop","params":{}}'
```

```json
{
    "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 `canceled`, sorted. Empty when nothing was still running |
| `residue` | One object per thing the session leaves running, in the same vocabulary `tasks/cancel` uses. 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 `tasks/cancel`: 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`](/reference/cli.md#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:

```bash
mur trace show 34af
```

> **Different ways to identify a session**
>
> `mur trace show` with no argument reads the most recent session:
>
> ```bash
> 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`:
>
> ```bash
> 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](/reference/cli.md#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:

```text
── 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:

```json
{"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 |
| 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 `tasks/get` to poll status and `tasks/cancel` to stop it |
| Stopping one task | `tasks/cancel`, or `mur cancel <session> <task-id>`; the session, its conversation and its queue keep running |
| Ending the session | `mur stop <session>`, which calls `session/stop` for the account of what is still running and then signals the process. `session/stop` 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 |
