---
title: Amplitude agent context v1
description: "Wire-format spec for Amplitude agent context v1: the signed envelope agents pass over A2A, Agent Client Protocol, and HTTP, with its fields, HMAC-SHA256 signing, verify results, trust levels, and identity modes."
product: general
lang: en
last_updated: 2026-11-09
token_estimate: 4835
---
# Amplitude agent context v1

Amplitude agent context v1 is the envelope that one agent sends another so both sides record correct Agent Analytics data. It carries the caller's session, agent, and trace over Agent2Agent (A2A), Agent Client Protocol (ACP), and HTTP. Every event on both sides still has a user: by default the envelope doesn't share the caller's user ID, and the receiver records its own. The caller can choose to send a per-recipient pseudonym or, with signing, the real user ID. A receiver trusts the envelope only when its HMAC-SHA256 signature verifies against a key the receiver holds. The receiver links an unverified envelope to its own session and never merges the two.

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

This page is the normative wire format. The Amplitude AI SDK for Python and Node implements it, and both SDKs produce byte-identical envelopes. To instrument agents with the SDK, refer to [Across processes](https://amplitude.com/docs/amplitude-ai/agent-analytics/multi-agent#across-processes).

## Extension URI

The URI of this page is the A2A extension URI:

```text
https://amplitude.com/docs/amplitude-ai/agent-analytics/agent-context-v1
```

Agents declare it in the AgentCard's `capabilities.extensions` with `required: false`, so clients that don't send the envelope still work. The AI SDK's `declare_a2a_extension()` (`declareA2aExtension()` in Node) adds that entry. Clients that activate the extension list the URI in the `X-A2A-Extensions` header. Implementations compare the URI as an exact string and never fetch it.

In this spec, ACP always means the Agent Client Protocol, which editors and orchestrators use to drive coding agents. Agent context v1 defines no mapping for the Agentic Commerce Protocol. To join commerce outcomes such as a checkout to an agent session, put the agent's IDs on the outcome event. Refer to [Join outcomes to the agent session](https://amplitude.com/docs/amplitude-ai/agent-analytics/multi-agent#join-outcomes-to-the-agent-session).

## Envelope fields

The logical envelope has six context fields and four signing fields. Every transport decodes to this same envelope before signing or verifying.

| Field | Type | Description |
| --- | --- | --- |
| `session_id` | string | The sender's `[Agent] Session ID`. |
| `parent_agent_id` | string | The sending agent's ID. On the receiver, it becomes the parent agent when trusted, or the peer agent when not. |
| `user_id` | string | The end user, as an account ID or a pairwise ID. Present only when `identity_type` is `account` or `pairwise`. |
| `identity_type` | string | `account`, `pairwise`, or `none`. An absent value means `none`. |
| `actor_type` | string | `agent` or `human`: who drives the sender's session. Receivers honor it only on a verified envelope. |
| `trace_id` | string | The sender's exact `[Agent] Trace ID`, such as a UUID or `req-123`. Senders include it whenever the session has a trace. |
| `alg` | string | Signature algorithm. v1 defines only `HS256`. |
| `kid` | string | ID of the signing key. Matches `[A-Za-z0-9._-]{1,64}`. |
| `iat` | integer | Issued-at time, in Unix seconds, from 0 to 2^53 − 1. |
| `exp` | integer | Expiry time, in Unix seconds, from 0 to 2^53 − 1. Defaults to `iat` plus 300. `exp` minus `iat` must be greater than 0 and at most 86,400. |

Format rules:

- **IDs**: `session_id`, `parent_agent_id`, `user_id`, `trace_id`, and `alg` are printable ASCII, 1 to 128 characters, with no leading or trailing space.
- **Absent fields**: Omit them. Never send `null`.
- **Unknown keys**: Receivers drop them before verification. A peer can add its own keys without breaking the signature, and those keys never count as signed.
- **Signing fields**: An envelope carries all four signing fields plus a signature, or none of them.

## Signing

The signature covers the canonical JSON of the logical envelope, with every present field from the table and nothing else:

1. Keep only the envelope fields. Drop unknown keys and the signature itself.
2. Serialize as JSON with keys sorted, `,` and `:` as separators, no whitespace, and UTF-8 encoding. Non-ASCII characters stay unescaped. String values stay strings, and `iat` and `exp` are integers.
3. Compute HMAC-SHA256 over those bytes with the secret for `kid`.
4. Encode the 32-byte MAC as base64url without padding. The result is 43 characters.

Receivers recompute the signature and compare it in constant time. Because `alg` is inside the signed bytes, changing `alg` after signing fails as `bad_signature`.

Secrets are random values of at least 32 bytes. The AI SDK rejects shorter secrets and malformed key IDs when you configure them.

> **Warning:** What a signing key means
>
> Any service that holds a signing key can vouch for any user and any session in your project. Treat key holders as part of your trust boundary, the same as database credentials. Share keys only between services you operate, and never ship a key to a browser or mobile app. Give each counterparty its own key ID and secret, so a leaked key vouches only for that pair of services and you can revoke it without rotating the others.

### Compact signature format

ACP and HTTP carry the signing fields and the signature in one string, with exactly these five fields in this order:

```text
alg=HS256;kid=<kid>;iat=<iat>;exp=<exp>;sig=<sig>
```

`iat` and `exp` are decimal integers with no leading zeros. A value that doesn't parse in this exact shape makes the whole envelope `malformed`.

## Transport encodings

Each transport maps the logical fields one to one, so the same envelope produces the same signing input on every transport.

| Logical field | A2A, inside `com.amplitude/context` | ACP `_meta` key | HTTP header |
| --- | --- | --- | --- |
| `session_id` | `session_id` | `com.amplitude/session-id` | `x-amplitude-session-id` |
| `parent_agent_id` | `parent_agent_id` | `com.amplitude/parent-agent-id` | `x-amplitude-agent-id` |
| `user_id` | `user_id` | `com.amplitude/user-id` | `x-amplitude-user-id` |
| `identity_type` | `identity_type` | `com.amplitude/identity-type` | `x-amplitude-identity-type` |
| `actor_type` | `actor_type` | `com.amplitude/actor-type` | `x-amplitude-actor-type` |
| `trace_id` | `trace_id` | `com.amplitude/trace-id` | `x-trace-id` |
| `alg`, `kid`, `iat`, `exp`, signature | Separate keys `alg`, `kid`, `iat`, `exp`, and `sig` | `com.amplitude/sig`, in the compact format | `x-amplitude-context-sig`, in the compact format |

- **A2A**: One object under the `com.amplitude/context` key of `Message.metadata`. A value that isn't an object is `malformed`.
- **ACP**: Flat keys in the request's `_meta`.
- **HTTP**: Request headers. Header names are case-insensitive.

### W3C trace context

On ACP and HTTP, senders also send a W3C `traceparent` (ACP's reserved `_meta` key, or the `traceparent` header) when the trace ID is exactly 32 hex characters once dashes are removed, and not all zeros. `traceparent` isn't an envelope field and is never signed, because OpenTelemetry instrumentation and proxies rewrite it in transit. A signed envelope still verifies after its `traceparent` changes or disappears, and a receiver takes its trace only from the signed `trace_id`. A2A carries no `traceparent`.

A valid `traceparent` matches `<version>-<32 hex trace ID>-<16 hex span ID>-<2 hex flags>` in lowercase hex. Version `ff` and an all-zero trace ID are invalid.

### Responses

A receiver can return its own context to the caller, so both sides can link the two sessions. Responses use the same envelope and signing:

- **A2A**: `metadata` on the returned Message or Task.
- **ACP**: `_meta` on the result.
- **HTTP**: Response headers.

Response envelopes never carry `actor_type`, and their identity defaults to `none`. A caller attaches a response context to the call that produced it, never to the caller's session.

## Verify results

A receiver checks an envelope in this order and records the first result that applies:

| Result | Condition | Trust |
| --- | --- | --- |
| `none` | No envelope on the message. On ACP and HTTP, an unsigned message that carries only `traceparent` or the `trace_id` key is ordinary traced traffic, not an envelope: the receiver continues that trace. | `none` |
| `malformed` | The envelope breaks a format rule, or the compact signature doesn't parse. | `untrusted` |
| `unsigned` | The envelope has no signing fields. | `untrusted` |
| `unsupported_alg` | `alg` isn't `HS256`. | `untrusted` |
| `unknown_kid` | The receiver holds no key for `kid`. | `untrusted` |
| `bad_signature` | The signature doesn't match. | `untrusted` |
| `expired` | The current time is more than 60 seconds after `exp`. | `untrusted` |
| `not_yet_valid` | The current time is more than 60 seconds before `iat`. | `untrusted` |
| `ok` | Every check passes. | `trusted` |

Trust follows from the verify result, and nothing sets it on its own. The receiver records both: `[Agent] Context Trust` for simple chart filters, and `[Agent] Context Verify Result` for debugging. A wrong key or clock shows up as a named result rather than a silent `untrusted`. The AI SDK also logs each non-`ok` result once per process as a warning, with the key ID but never the secret.

### Clock skew and TTL

Verification allows 60 seconds of clock skew in both directions. An envelope is valid from `iat` minus 60 seconds through `exp` plus 60 seconds. The default lifetime is 300 seconds and the maximum is 86,400 seconds.

Receivers don't keep a nonce store. Inside its validity window, a replayed envelope attributes events to the same session it already named. Keep lifetimes short to limit that window.

## Receiver behavior

The verify result decides what the receiver adopts:

- **Trusted (`ok`)**: The receiver joins the sender's session. It adopts `session_id`, `user_id`, and `parent_agent_id`, and uses the signed `trace_id` as its `[Agent] Trace ID`, so both sides record the same Trace ID string. It never takes the trace from `traceparent`.
- **Untrusted (any other result except `none`)**: The receiver keeps its own session, user, and trace ID. It records the sender's agent, session, and trace ID (`trace_id`; on an unsigned envelope, the trace ID from `traceparent` when `trace_id` is absent) as `[Agent] Peer Agent ID`, `[Agent] Peer Session ID`, and `[Agent] Peer Trace ID`, and ignores `user_id`. Its identity type is `none`. A `malformed` envelope records only the verify result.

The W3C `traceparent` that OpenTelemetry propagates is separate from these rules, so your own distributed tracing still forms one trace.

`[Agent] Actor Type` depends on the transport:

| Transport | Actor Type |
| --- | --- |
| A2A | Always `agent`, because A2A is agent to agent. |
| ACP | The signed `actor_type` when the result is `ok`, otherwise `human`. |
| HTTP | The signed `actor_type` when the result is `ok`, otherwise `human`. |

An unsigned `actor_type` claim never changes Actor Type.

### Unsigned HTTP during the deprecation window

HTTP context propagation predates signing. While `accept_unsigned_http` is on, a receiver still adopts unsigned HTTP headers as before and records `[Agent] Context Verify Result` as `unsigned`. The SDK logs a deprecation warning once per process. Until the next major SDK release, `accept_unsigned_http` is on by default only for a receiver with no signing keys. A receiver with signing keys treats unsigned HTTP as link-only, as A2A and ACP already are, so a request can't skip verification by leaving out the signature. A receiver whose key configuration is invalid also treats unsigned HTTP as link-only, so a configuration mistake never makes it adopt unsigned context.

On unsigned HTTP, an `x-trace-id` that isn't 1 to 64 letters, digits, or `._:-`, starting with a letter or digit, is ignored rather than making the envelope `malformed`, as before. On a signed envelope, `x-trace-id` is the signed `trace_id` and follows the ID rules. A signed HTTP request carries only the headers in the transport table, plus `traceparent`.

## Identity modes

The sender chooses how much of the end user's identity leaves the process:

| SDK option | `identity_type` on the wire | What the receiver gets |
| --- | --- | --- |
| `none` (default for A2A and ACP) | `none` | No user ID. |
| `pairwise` | `pairwise` | A stable pseudonym unique to that receiver. |
| `raw` | `account` | Your real user ID. Allowed only on a signed envelope. The SDK raises an error rather than send a raw ID unsigned. |

### Pairwise IDs

A pairwise ID gives each counterparty a stable ID for a returning user that it can't reverse or join with any other counterparty's ID:

```text
pw<version>_ + first 26 characters of lowercase, unpadded base32(
  HMAC-SHA256(salt[version], user_id + "\n" + canonical_recipient)
)
```

The canonical recipient identifies who receives the ID:

- **An `http` or `https` URL, such as an AgentCard URL**: Lowercase the scheme and host, and drop the user info, the default port (80 for `http`, 443 for `https`), the query, and the fragment. Remove a trailing `/` from the path. Path case stays as is. For example, `https://Seller.Example.com:443/agent/?x=1` becomes `https://seller.example.com/agent`.
- **Anything else**: `agent:` followed by the trimmed string. For example, `seller-agent` becomes `agent:seller-agent`.

Each salt has a version, and the version is part of the ID (`pw1_`, `pw2_`). Rotating to a new salt changes every pairwise ID a counterparty sees. It's a deliberate break in continuity, and the version prefix makes it visible so a counterparty can bridge the old and new IDs. For how pairwise IDs affect privacy and deletion, refer to [Pairwise IDs and deletion](https://amplitude.com/docs/amplitude-ai/agent-analytics/privacy-modes#pairwise-ids-and-deletion).

## Key rotation

Each key has a `kid`. Receivers accept every key they hold, and senders sign with one active key. Use a distinct `kid` and secret for each counterparty, so you rotate or revoke one relationship at a time. To rotate without dropping trust:

1. Add the new key to every receiver.
2. Switch senders to the new key as the active key.
3. Remove the old key after the longest envelope lifetime you issue has passed.

The AI SDK reads keys from the `AMPLITUDE_AI_CONTEXT_KEYS` environment variable, a JSON object of key ID to secret, and the active key ID from `AMPLITUDE_AI_CONTEXT_ACTIVE_KID`. Pairwise salts come from `AMPLITUDE_AI_PAIRWISE_SALTS`, a JSON object of version to salt, and the SDK mints with the highest version. If any of these variables is invalid, the SDK logs an error and runs with no keys, so every inbound envelope is untrusted until you fix it. `amplitude-ai doctor` reports the problem.

A `ContextPolicy` passed in the client configuration replaces the environment variables. It applies to the client's sessions and, outside a session, to the whole process. A process has one such policy: a second client with a different policy replaces it and logs a warning. To use different keys for one call, pass the policy to that call.

## Worked example

This example uses the test key `k1`, with the secret `k1-secret-0123456789abcdef0123456789`. Never use it outside tests.

The logical envelope:

```json
{
  "session_id": "sess-buyer-1",
  "parent_agent_id": "buyer-agent",
  "identity_type": "none",
  "trace_id": "4bf92f35-77b3-4da6-a3ce-929d0e0e4736",
  "alg": "HS256",
  "kid": "k1",
  "iat": 1800000000,
  "exp": 1800000300
}
```

The signing input, the canonical JSON as one line:

```text
{"alg":"HS256","exp":1800000300,"iat":1800000000,"identity_type":"none","kid":"k1","parent_agent_id":"buyer-agent","session_id":"sess-buyer-1","trace_id":"4bf92f35-77b3-4da6-a3ce-929d0e0e4736"}
```

The signature:

```text
9psFj4AidjGvzDdvHO7cePh0ang9m4eN0sfsNU12vC8
```

To reproduce it with the Python standard library:

```python
import base64, hashlib, hmac, json

envelope = {
    "session_id": "sess-buyer-1",
    "parent_agent_id": "buyer-agent",
    "identity_type": "none",
    "trace_id": "4bf92f35-77b3-4da6-a3ce-929d0e0e4736",
    "alg": "HS256",
    "kid": "k1",
    "iat": 1800000000,
    "exp": 1800000300,
}
signing_input = json.dumps(envelope, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
mac = hmac.new(b"k1-secret-0123456789abcdef0123456789", signing_input.encode("utf-8"), hashlib.sha256)
print(base64.urlsafe_b64encode(mac.digest()).rstrip(b"=").decode("ascii"))
```

The same envelope on each transport. ACP and HTTP add the unsigned `traceparent`; the signature is the same on all three:

#### A2A

```json
{
  "com.amplitude/context": {
    "session_id": "sess-buyer-1",
    "parent_agent_id": "buyer-agent",
    "identity_type": "none",
    "trace_id": "4bf92f35-77b3-4da6-a3ce-929d0e0e4736",
    "alg": "HS256",
    "kid": "k1",
    "iat": 1800000000,
    "exp": 1800000300,
    "sig": "9psFj4AidjGvzDdvHO7cePh0ang9m4eN0sfsNU12vC8"
  }
}
```

#### ACP

```json
{
  "com.amplitude/session-id": "sess-buyer-1",
  "com.amplitude/parent-agent-id": "buyer-agent",
  "com.amplitude/identity-type": "none",
  "com.amplitude/trace-id": "4bf92f35-77b3-4da6-a3ce-929d0e0e4736",
  "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
  "com.amplitude/sig": "alg=HS256;kid=k1;iat=1800000000;exp=1800000300;sig=9psFj4AidjGvzDdvHO7cePh0ang9m4eN0sfsNU12vC8"
}
```

#### HTTP

```text
x-amplitude-session-id: sess-buyer-1
x-amplitude-agent-id: buyer-agent
x-amplitude-identity-type: none
x-trace-id: 4bf92f35-77b3-4da6-a3ce-929d0e0e4736
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
x-amplitude-context-sig: alg=HS256;kid=k1;iat=1800000000;exp=1800000300;sig=9psFj4AidjGvzDdvHO7cePh0ang9m4eN0sfsNU12vC8
```

A receiver that holds `k1` and checks the envelope between Unix time 1799999940 and 1800000360 gets `ok`. It joins session `sess-buyer-1` with `buyer-agent` as the parent agent and `4bf92f35-77b3-4da6-a3ce-929d0e0e4736` as the trace ID, even if a proxy rewrote `traceparent` on the way. A receiver without `k1` gets `unknown_kid`, keeps its own session, and records `buyer-agent` and `sess-buyer-1` as the peer agent and peer session.

## Event properties and OpenTelemetry attributes

Receivers record the result as seven `[Agent]` properties. OpenTelemetry instrumentation sets the matching span attributes.

| Property | OpenTelemetry span attribute | Values |
| --- | --- | --- |
| `[Agent] Peer Agent ID` | `amplitude.peer.agent.id` | The sender's agent, when untrusted. |
| `[Agent] Peer Session ID` | `amplitude.peer.session.id` | The sender's session, when untrusted. |
| `[Agent] Peer Trace ID` | `amplitude.peer.trace.id` | The sender's trace ID, when untrusted. |
| `[Agent] Context Trust` | `amplitude.context.trust` | `trusted`, `untrusted`, or `none`. |
| `[Agent] Context Verify Result` | `amplitude.context.verify.result` | One of the [verify results](#verify-results). |
| `[Agent] Actor Type` | `amplitude.actor.type` | `agent` or `human`. |
| `[Agent] Identity Type` | `amplitude.identity.type` | `account`, `pairwise`, or `none`. |

For how these properties appear on events, refer to [Agent context properties](https://amplitude.com/docs/amplitude-ai/agent-analytics/taxonomy#agent-context-properties).

