docs / agent-messaging-protocol / concepts

Concepts

Envelopes and canonical JSON

Every message on the wire is one Envelope: a frozen, signed record with amp="0.1", a UUID id, a type, sender (serialized as "from" on the wire), to, session_id, seq, created_at, a base64 body, and sig. The Ed25519 signature covers a domain-separated view of every field except sig. Bodies are base64: ratchet ciphertext for session traffic, sealed JSON for handshake frames. The envelope cap is 1 MiB with no chunking — larger payloads travel out-of-band via amp.ref/1 pointers.

The model is declared extra="allow": unknown fields from a newer peer are preserved and included in the signed canonical payload, so additive wire fields never break cross-version signature agreement.

Everything signed or used as a KDF salt is serialized as canonical JSON: keys sorted by Unicode code point, no insignificant whitespace, UTF-8 NFC strings, no NaN/Infinity, and no floats — durations are integer milliseconds (ttl_ms) or strings. One logical payload has exactly one byte representation.

Signatures are domain-separated: the signing input is uint16be(len(tag)) || tag || canonical_json(payload) with tag = "fg-amp/v1/" + context (contexts: envelope, group-roster, relay-pull, relay-ack, relay-ws). Identity artifacts are signed under the separate fg-agent-id/v1 domain and never re-signed — an AMP artifact and an identity artifact can never be confused, even if their payloads canonicalize identically.

Identity

Self-certifying addresses

An address is amp:key:<base58(ed25519_pub)> — the address is the signing key. Anyone can verify a signature against a sender address with no lookup, and nobody can squat a name. An optional DID form did:amp:<base58> exists, with a DID Document carrying publicKeyMultibase entries for both the Ed25519 signing key and the X25519 agreement key.

Owner keys vs agent keys

Agents hold hot, rotatable keys that live on the wire. Owners hold cold keys that stay off it, and authorize agents via signed delegation chains (Delegation.grant(...)DelegationChain). Every session verifies both sides' chains during the handshake and exposes the results:

session.require_scope("negotiate", owner=acme_address) checks the handshake-verified chain — never claims made inside messages. One sharp edge: scopes are only a real gate when the policy pins trusted_issuers. Without that, a peer can self-sign a chain granting itself any scope, and scopes are merely advisory (a warning is logged). The card's operator field must match the verified chain root or the initiation is refused.

Agreement prekey ring

Initiators seal the first knock to the X25519 agreement prekey in the responder's current card. node.rotate_prekey() mints a fresh prekey (returning a new card to re-register); the node keeps one previous prekey for a grace window, and dropping the old private key makes past knocks unrecoverable — forward secrecy for the handshake itself, independent of identity-key secrecy.

Revocation

Each node carries a RevocationRegistry. A key-revoked sender is refused outright, regardless of policy. Delegation chains are re-verified fresh against current revocations on every initiate, accept, and resume — revoked or expired authority is never reinstated by a stale session.

Handshake

initiator                                    responder
initiate() ── handshake.initiate (sealed) ──▶ verify sig + card + chain
                                              policy engine
          ◀── handshake.accept (sealed) ───── derive session key
verify responder card + chain                 session established

The HandshakeInitiate frame — sealed to the responder's agreement prekey — carries the session id, the initiator's card and delegation chain, mode, purpose, payload types, ttl_ms, an ephemeral X25519 key, capability offers, an optional PQ KEM key, and an optional witness block. Its transcript_salt() is the SHA-256 of its canonical JSON (an absent witness is omitted, not null, keeping key derivation byte-stable against the golden vectors). The HandshakeAccept adds the accepted payload types and PQ ciphertext, and carries the responder's own chain — identity verification is mutual. Rejects are plaintext {session_id, reason}, and only the addressed peer may reject a pending handshake.

Guards on establishing frames (handshake.initiate, session.resume): a 300-second clock-skew freshness window plus bounded seen-id caches block replays. Pending initiations are capped at 1024; deferred knocks (wait=False) carry a monotonic deadline equal to their TTL and are reaped by forget_expired_initiations().

Capability negotiation takes the intersection of both offers. The PQ token is stripped from session.capabilities unless a KEM secret was actually exchanged — an app gating on CAP_PQ_ML_KEM_768 is never lied to.

Encryption

Primitives: Ed25519 signatures, X25519 agreement, optional ML-KEM-768, ChaCha20-Poly1305 AEAD, HKDF-SHA256, SHA-256. Any all-zero X25519 shared secret (low-order point) is rejected.

Two constructions:

  1. Seal / open_sealed — anonymous-sender confidentiality to a static X25519 key: ephemeral-static ECDH → HKDF-SHA256 → ChaCha20-Poly1305. Wire format is ephemeral_pub(32) || nonce(12) || ciphertext, with the ephemeral key doubling as HKDF salt and AEAD AAD (info string amp/0.1/seal). Used for handshake and resume payloads, before a session key exists.
  2. Session key — ephemeral-ephemeral ECDH bound to the handshake transcript: HKDF(x25519_shared + pq_shared, salt=transcript, info="amp/0.1/session"). Concatenating the ML-KEM shared secret makes the key hybrid — secure if either the classical or the post-quantum primitive holds (harvest-now-decrypt-later defense). An empty pq_shared reproduces the classical-only key byte-for-byte. The salt binds both handshake ephemerals (SHA256(initiate.transcript_salt() + responder_ephemeral_pub)), so the key commits to the full handshake, not just the initiator's contribution.

The double ratchet, AMP variant

Session traffic runs a double ratchet: a symmetric HKDF chain per direction gives a fresh AEAD key per message (forward secrecy); each message header carries the sender's current X25519 public key, and a new header key mixes a fresh DH secret into the root and derives new chains (post-compromise security after each direction turn).

Where AMP departs from vanilla Signal: either party may send first, including simultaneously. The responder→initiator direction is seeded with a symmetric chain-0 so the responder can send immediately; the initiator takes an immediate send-side DH step. The sender's DH key changes only inside a receive-triggered step — never on send — which makes simultaneous sends safe. (Wiring detail: the accept-receiver is the ratchet initiator; the accept-sender is the responder.)

Decryption is peek/commit: decrypt_prepare returns a message key and an apply callback, and state mutates only after decrypt-and-validate succeeds — a bad frame can never desync the ratchet. Decode is strictly in-sequence (the reorder buffer serializes it), so no skipped-message-key store exists. burn() zeroes root and chain keys on close; close frames authenticate with a key derived from the initial root, so a close verifies regardless of ratchet position.

Wake semantics

AMP delivery is pull-based — the recipient long-polls the relay — which only works while the recipient is running. Wake closes the gap for sleeping agents:

A wake is a hint, not a delivery guarantee — the poll loop stays the source of truth, so a dropped ping costs latency, never correctness. On the caller side, use a non-blocking initiate so a sleeping peer doesn't stall you: pending = await node.initiate(card, wait=False); session = await pending.wait().

Resume

Persistent sessions (SessionMode.PERSISTENT) export to a SessionStore as a SessionRecord containing no key material — just the peer card, counters, transcript head, verified owner/scopes, and witness posture. Resume:

  1. re-authenticates both sides with identity keys and re-verifies the delegation chain fresh — expired or revoked authority is not reinstated, scopes are recomputed;
  2. checks that the transcript head and sequence counters mirror;
  3. mints a fresh session key (post-compromise recovery), salted by SHA256(transcript_head + nonce).

The per-attempt nonce is echoed in the accept and blocks replaying a captured accept from an earlier attempt (the session id is stable across resumes). A live session with the same id is superseded only after full validation.

Transcript hash chain

Both parties maintain a tamper-evident SHA-256 chain over canonical envelopes: head' = SHA256(head + canonical_json(envelope.to_wire())), genesis 32 zero bytes. Comparing heads (session.transcript.head or transcript_matches(expected)) proves both sides hold an identical, untampered history — without exchanging it. The head survives session close as evidence even after key material is burned.

Reliability layer

Under the crypto, sessions run a real transport discipline:

An unfillable gap deterministically closes the session so a resume can recover it. The contract: no acknowledged message is ever lost. Everything is observable via session.stats.

Contact policy

Consent is enforced in code by the PolicyEngine, which runs on every initiation after signature, card, and chain verification — deliberately not an LLM decision: refusing unwanted contact must not be promptable. ContactPolicy is a frozen record with four modes (OPEN, CREDENTIALED, ALLOWLIST, CLOSED) plus per-peer rate limits (LRU-bounded), a session ceiling, a TTL cap, participant-kind and session-mode gating, accepted payload types, and optional human approval via approval_fn. Convenience constructors: ContactPolicy.open(), .credentialed(scopes, trusted_issuers=...), .allowlist(addresses=..., operators=...).

Typed bodies — the app layer

A typed body is a schema-validated payload whose content_type is <name>/<version> with an integer version — amp.task/1. The integer is what distinguishes it from a free-form MIME type: text/plain stays in the untyped fallback tier and never hits the registry. Bodies are validated at both pipe ends — outbound before encrypt, inbound after decrypt. Unknown typed types deliver opaque, unless the sender marked metadata.critical, in which case they are rejected. BodyRegistry extends immutable-by-copy; default_registry() is process-wide with the six built-ins.

Body Purpose Lifecycle enforcement
amp.task/1 Work lifecycle: request → accept/reject → progress → complete/fail, requester-only cancel TaskTracker — illegal transition raises TaskLifecycleError without mutating state
amp.payment/1 x402 payment profile: quote → authorization → settled/failed PaymentTracker + spend caps (below)
amp.mcp/1 One MCP JSON-RPC message verbatim, with an opaque mcp_session correlator McpBridge correlates calls/responses; bridges layer per mcp_session
amp.receipt/1 Application-level ack (distinct from transport receipts) References exactly one of task_id / message_id
amp.ref/1 Pointer to an external artifact (uri, kind, content_hash) The out-of-band path for >1 MiB payloads
amp.claim/1 Knowledge claim with pedigree (statement, confidence, evidence refs)

Payments follow the "AMP carries, x402 settles" principle: the x402 payment-requirements/payload objects ride verbatim and unvalidated under the x402 field. What AMP does enforce is authority: an outbound authorization is cap-verified against the sender's own delegation chain (SpendAuthority) before it is even encrypted, and inbound against the peer's handshake-verified chain; SpendLedger tracks cumulative authorized amounts per asset so total<= caps hold across authorizations. Violations raise SpendRejectedError.

Witnessed posture

A negotiated middle ground between sealed E2E and auditability (CAP_WITNESSED_V1). Both parties name the same witness — an address plus X25519 key — inside the signed, sealed handshake. The initiate's witness block feeds the transcript salt, so the agreement is cryptographically bound into the session key; a divergent or missing block is a handshake reject. On every send, the sender additionally seals the plaintext to the witness as a signed witness.copy envelope bound to (session_id, seq, sender) — both by the envelope signature and by a sealed inner duplicate, so splicing is detectable. A sender that cannot produce the witness copy refuses to send (seq and ratchet untouched). The posture survives resume via SessionRecord.witness, and the sealed default remains byte-for-byte unchanged when no witness is negotiated. The spec is deliberately honest about what is cryptographically bound versus merely detectable-not-preventable.

Groups

create_group(member_cards) builds a full mesh of pairwise encrypted sessions — every message is E2E to every member individually. The roster is a founder-signed GroupInfo (with epoch and roster_digest); founder-only add_member / remove_member bump the epoch, and members receive GroupEvents (joined, left, removed, epoch, equivocation). Exclusion on removal works by tearing down the pairwise sessions — there is no shared group key to rotate. There is no cross-member message ordering. MLS-style groups are roadmap.

Security model, condensed

What is enforced: sender authenticity on every envelope (verified before the body is touched, with session traffic pinned to the peer's card address); confidentiality against relays and on-path parties; forward secrecy (ratchet + prekey ring + burn on close); post-compromise security (DH turns + fresh key on resume); optional PQ hybrid; code-enforced consent; mutually verified delegation authority; spend caps; replay resistance (freshness windows, bounded seen-caches, per-session sequencing, canonical base64 signature spelling so one signature has exactly one wire form); relay hardening (audience-bound signed pull/ack, single-use pull guard, rate limits, SSRF-guarded wake); domain-separated signatures; tamper-evident transcripts; witnessed auditability without relay plaintext.

What is not guaranteed — repeat it until it sticks: sender authenticity is not content safety; Sybil resistance is rate-limiting only; the relay sees the social graph; wake is a hint; 1 MiB envelopes; one key per identity.

Deployment fine print