---
title: "Instrument multi-agent systems"
description: "Record delegation between a parent agent and child agents with the AI SDK, in one process or across A2A, ACP, and HTTP. Signed context from your own agents joins one session; context from other agents links sessions without merging them."
product: general
lang: en
last_updated: 2026-11-09
token_estimate: 3394
---
# Instrument multi-agent systems

In a multi-agent system, a parent agent delegates work to child agents. Create child agents from the parent with the AI SDK and Agent Analytics records the delegation: child agents carry `[Agent] Parent Agent ID`, sessions record the root agent and chain depth, and provider wrappers suppress spurious user-message events inside delegated calls. The AI SDK sets `[Agent] Message Source` to `agent` on a delegated user message unless you set the source yourself. Every agent's events stay under one `[Agent] Session ID`.

> For AI agents: a documentation index is available at [/docs/llms.txt](/docs/llms.txt). Append `.md` to any page URL for markdown, or send `Accept: text/markdown`.

Give every agent a stable, human-readable ID. Those IDs become the primary dimension for comparing quality and cost across your system.

## Declare and dispatch child agents

Declare children off the parent with `.child()`, then dispatch to them with `runAs()` (Node) or `arun_as()` (Python):

#### Node

```typescript
const orchestrator = ai.agent('shopping-agent', { description: 'Orchestrates shopping requests' });
const recipeAgent = orchestrator.child('recipe-agent', { description: 'Finds recipes' });

await orchestrator.session({ userId }).run(async (s) => {
  s.trackUserMessage(userInput);
  const result = await s.runAs(recipeAgent, async (cs) => {
    cs.trackUserMessage(delegatedQuery);
    return openai.chat.completions.create({ model: 'gpt-4o', messages: [...] });
  });
});
```

#### Python

```python
orchestrator = ai.agent("shopping-agent", description="Orchestrates shopping requests")
recipe_agent = orchestrator.child("recipe-agent", description="Finds recipes")

async with orchestrator.session(user_id=user_id) as s:
    s.track_user_message(user_input)
    async with s.arun_as(recipe_agent) as cs:
        cs.track_user_message(delegated_query)
        result = client.chat.completions.create(model="gpt-4o", messages=[...])
```

Child agents inherit the parent's `experiments`, and a child can add or override entries by key. Events from `runAs()` and `arun_as()` also carry the session's experiments. Refer to [Record experiment variants in Agent Analytics](https://amplitude.com/docs/amplitude-ai/agent-analytics/track-experiments).

For the full delegation semantics (context inheritance, span wrapping, nested children, fan-out), refer to [Multi-agent architectures](https://amplitude.com/docs/sdks/agent-analytics/sdk#multi-agent-architectures) in the SDK reference.

## Analyze multi-agent sessions

Each `[Agent] Session Record` carries `[Agent] Root Agent Name` and `[Agent] Agent Chain Depth`. Depth is the number of agents on the longest delegation path: 1 is a single agent, 2 is one hop, and 3 or more is deeper nesting. When a parent fans out to several children, depth follows the deepest branch. Filter Session Records where chain depth is greater than 1 to find multi-agent sessions. Depth 2 means exactly one hop. Group any chart by `[Agent] Agent ID` to compare agents. Refer to [Data hierarchy](https://amplitude.com/docs/amplitude-ai/agent-analytics/taxonomy#data-hierarchy).

A span's parent span isn't the parent agent. On OpenTelemetry, set the span attribute `amplitude.parent.agent.id`. A LangSmith forwarder stamps one configured agent: nested LangSmith runs stay tool calls or spans and don't become child agents.

## Across processes

When an agent calls another agent in a different process, service, or company, the AI SDK sends an agent context envelope with the request. The envelope carries the caller's session, agent, and trace. By default it doesn't share the caller's user ID, so the receiving agent tracks its own user, the same as for any other request. To share the user, the caller sends a per-recipient pseudonym or, with signing, the real user ID. What the receiving agent does with it depends on whether it can verify the envelope's signature. The wire format is public. Refer to [Amplitude agent context v1](https://amplitude.com/docs/amplitude-ai/agent-analytics/agent-context-v1).

Agent context v1 requires `@amplitude/ai` 0.29.0 or later, or `amplitude-ai` 1.26.0 or later.

### Trusted and untrusted peers

A receiver trusts a context only when its signature verifies against a key the receiver holds. Nothing the sender sets on the message can mark it as trusted.

|  | Trusted peer | Untrusted peer |
| --- | --- | --- |
| Typical case | Your own agents, which share a signing key | Another company's agent, or any unsigned context |
| Session and user | The receiver joins the caller's session and user | The receiver keeps its own session and user |
| Trace ID | The receiver adopts the caller's trace ID | The receiver keeps its own `[Agent] Trace ID` |
| Caller recorded as | `[Agent] Parent Agent ID`, and the hop counts in `[Agent] Agent Chain Depth` | `[Agent] Peer Agent ID`, `[Agent] Peer Session ID`, and `[Agent] Peer Trace ID` |
| `[Agent] Message Source` on the inbound request | `agent`, a delegated prompt inside the caller's session | `user`, so the request counts as a turn and shows in the transcript |

A trusted context joins the two sides into one session only when both agents send events to the same Amplitude project. When they send to different projects, each project holds only its own side of the session, and Amplitude doesn't join sessions across projects.

Every inbound context also stamps `[Agent] Context Trust`, `[Agent] Context Verify Result`, `[Agent] Actor Type`, and `[Agent] Identity Type` on the receiver's session. A verify result such as `unknown_kid` or `expired` names the reason a context isn't trusted, so a missing key or a clock problem shows up in your data. Use `[Agent] Actor Type` to separate agent-led sessions from human-led ones. Refer to [Agent context properties](https://amplitude.com/docs/amplitude-ai/agent-analytics/taxonomy#agent-context-properties).

By default, A2A and Agent Client Protocol (ACP) contexts carry no user ID. To send one, refer to [Identity modes](https://amplitude.com/docs/amplitude-ai/agent-analytics/agent-context-v1#identity-modes).

### Set up signing between your own agents

Generate a random secret of at least 32 bytes and give it a key ID. Load both into every service that sends or receives agent context, from your secret manager. Any service that holds the key can vouch for any user and session, so treat key holders like database credentials and never put a key in a browser or mobile app.

The SDK reads the keys from the environment:

```bash
AMPLITUDE_AI_CONTEXT_KEYS='{"ctx-2026-11": "<secret from your secret manager>"}'
AMPLITUDE_AI_CONTEXT_ACTIVE_KID=ctx-2026-11
```

To configure the keys in code instead, pass a `ContextPolicy` to the client:

#### Node

```typescript
import { AIConfig, AmplitudeAI, ContextPolicy } from "@amplitude/ai";

const ai = new AmplitudeAI({
  apiKey: process.env.AMPLITUDE_AI_API_KEY!,
  config: new AIConfig({
    contextPolicy: new ContextPolicy({
      signingKeys: { "ctx-2026-11": config.contextSigningKey },
      activeKid: "ctx-2026-11",
    }),
  }),
});
```

#### Python

```python
from amplitude_ai import AIConfig, AmplitudeAI, ContextPolicy

ai = AmplitudeAI(
    api_key=os.environ["AMPLITUDE_AI_API_KEY"],
    config=AIConfig(
        context_policy=ContextPolicy(
            signing_keys={"ctx-2026-11": config.context_signing_key},
            active_kid="ctx-2026-11",
        ),
    ),
)
```

The SDK signs outbound context with the active key and accepts every configured key, so you can rotate keys without dropping trust. Use a separate key ID and secret for each counterparty, so you can revoke one without rotating the rest. Without a key, the SDK sends unsigned context, and every receiver treats it as untrusted. If the key environment variables are invalid, the SDK logs an error and treats every inbound context as untrusted; `amplitude-ai doctor` names the problem.

The policy in `AIConfig` applies to the client's sessions and, outside a session, to the whole process. Run one policy per process: a second client with a different policy replaces the first and logs a warning. Refer to [Key rotation](https://amplitude.com/docs/amplitude-ai/agent-analytics/agent-context-v1#key-rotation).

### Send and receive over A2A

On the calling agent, add the context to the outbound A2A message. On the receiving agent, extract it and open the session with `context=`. The SDK applies the trust rules, so don't copy IDs from the extracted context into the session yourself.

#### Node

```typescript
import { extractA2aMetadata, injectA2aMetadata } from "@amplitude/ai";

// Calling agent
message.metadata = injectA2aMetadata(message.metadata);

// Receiving agent
const context = extractA2aMetadata(request.message.metadata);
await sellerAgent.session({ context }).run(async (s) => {
  s.trackUserMessage(text);
  // ...
});
```

#### Python

```python
from amplitude_ai import extract_a2a_metadata, inject_a2a_metadata

# Calling agent
message.metadata = inject_a2a_metadata(message.metadata)

# Receiving agent
context = extract_a2a_metadata(request.message.metadata)
with seller_agent.session(context=context) as s:
    s.track_user_message(text)
    ...
```

To advertise support, add the extension to your AgentCard with `declare_a2a_extension()` (`declareA2aExtension()` in Node). The extension URI is the [agent context v1](https://amplitude.com/docs/amplitude-ai/agent-analytics/agent-context-v1) page.

The other transports follow the same pattern:

| Transport | Outbound | Inbound |
| --- | --- | --- |
| Agent2Agent (A2A) | `inject_a2a_metadata()` on `Message.metadata` | `extract_a2a_metadata()` |
| Agent Client Protocol (ACP) | `inject_acp_meta()` on the request's `_meta` | `extract_acp_meta()` |
| HTTP | `inject_context()` on request headers | `extract_context(headers)` |

Node has the same helpers in camel case, and both SDKs read each other's envelopes, so a Python agent and a Node agent interoperate. For HTTP services, the bundled middleware can apply the same trust rules to every request. Refer to [Propagate context across services](https://amplitude.com/docs/sdks/agent-analytics/sdk#propagate-context-across-services).

### Link the response back to the caller

The receiving agent can return its own context in the response, so the caller can find the receiver's session too. Inside the receiver's session, add it with `inject_a2a_response_metadata()` for A2A, `inject_acp_result_meta()` for ACP, or `inject_response_headers()` for HTTP. Response contexts never carry a user ID or an actor type.

On the calling agent, extract the response context and attach it to the tool call that made the request. The tool call event then carries `[Agent] Peer Agent ID`, `[Agent] Peer Session ID`, and `[Agent] Context Verify Result` for that one call:

#### Node

```typescript
const reply = await a2aClient.sendMessage(message);
s.trackToolCall("seller_agent.checkout", latencyMs, true, {
  peer: extractA2aMetadata(reply.metadata),
});
```

#### Python

```python
reply = await a2a_client.send_message(message)
s.track_tool_call(
    "seller_agent.checkout", latency_ms, True,
    peer=extract_a2a_metadata(reply.metadata),
)
```

The link attaches to one call, never to the session, so an agent that calls several peers in parallel keeps each peer's IDs on its own tool call. The caller's session and user never change. To get the same properties as a dictionary, call `peer_properties()` (`peerProperties()` in Node) on the extracted context.

### Join outcomes to the agent session

A business outcome such as a checkout usually arrives as a product event, not an agent event. To join it to the agent session that produced it, add the agent's IDs to the event with `context_properties()` (`contextProperties()` in Node):

#### Node

```typescript
amplitude.track(
  "Checkout Completed",
  { orderId, ...ai.contextProperties() },
  { user_id: userId },
);
```

#### Python

```python
amplitude.track(BaseEvent(
    "Checkout Completed",
    user_id=user_id,
    event_properties={"order_id": order_id, **ai.context_properties()},
))
```

Inside a session, the helper returns `[Agent] Session ID`, `[Agent] Trace ID`, and `[Agent] Agent ID`, plus `[Agent] Peer Agent ID` and `[Agent] Peer Session ID` when an untrusted peer started the session. Outside a session, it returns an empty object, so it's safe to spread into any event. It never adds a user ID. When the outcome completes in another service or later, pass a propagated context: `context_properties(context=extracted)`.

The outcome then joins in both directions. In the receiver's project, the checkout shares `[Agent] Session ID` with the receiver's agent session, and that session's `[Agent] Peer Trace ID` holds the caller's trace ID. In the caller's project, the tool call's `[Agent] Peer Session ID` names the receiver's session.

### Coding agents

Coding agents carry the parent agent without code:

- **Claude Code**: Subagent log records carry `agent_id` and `parent_agent_id`, so subagents nest under the agent that started them. Refer to [Claude Code](https://amplitude.com/docs/amplitude-ai/agent-analytics/integrations/claude-code).
- **Codex**: The Codex hook forwarder reads each subagent thread's parent from the session transcript. Refer to [Codex](https://amplitude.com/docs/amplitude-ai/agent-analytics/integrations/codex).
- **Buzz**: The forwarder sets each worker's parent to the coordinator that delegated to it. Refer to [Buzz](https://amplitude.com/docs/amplitude-ai/agent-analytics/integrations/buzz).

