Skip to main content
This page records a message that one agent sends to another. When the message leaves one run and crosses your own transport, nothing inside either run records it. Two calls fix that: the sender writes a sent record, the receiver writes a received record, and anyone who holds both can check that they match. It works over any transport (HTTP, a queue, gRPC, a framework’s own channel) and needs no framework. When both sides have recorded their hand-offs, Hand over and verify a case takes the records to one verdict a third party can reproduce.
This page uses @sanning/anchor and sanning-anchor 0.16.0, the current release of each SDK. Where a call is new in 0.15.0, this page says so.

Before you start

Each side of a hand-off needs the following: Two agents in two organizations need two API keys, two signing seeds, and one exchange of public keys. Two agents in one organization can share the API key and still need a seed each. Install the SDK and the open kernel together:
The SDK records and the kernel reads. Installing the kernel yourself means the code that checks a confirmation or reads a case imports the version you chose, not the one the SDK depends on.

Build each agent’s anchorer

Each agent needs its own anchorer, with its own signing seed, built as Anchor from your agent describes. Read the anchorer’s public key and give it to the agent on the other side:

Send a message

Create one hand-off helper per case, then call send with the message and the receiver’s public key.
Replace RECEIVER_PUBLIC_KEY with the receiving agent’s public key, and transport with however your agents talk to each other. send signs a handoff.sent record in your process, writes it to your own log store, and anchors it. It returns the bytes to send (wireBytes) and an attachment to send beside them (attachment). The attachment is plain JSON that holds the signed record, which the receiver checks on arrival. A case id is at most 128 characters of letters, digits, _, ., : and -. createHandoff refuses anything else, such as a space. It cannot tell an opaque id from a customer’s name, so keeping it opaque is up to you.

Receive it

On the receiving side, check who sent the message, then pass what arrived to receive before your agent acts on it.
Replace SENDER_PUBLIC_KEY with the sending agent’s public key, in lowercase. Either case of a key is the same key. receive verifies the sender’s record with the open kernel, in your process, with no call to Sanning. Then it records handoff.received, committing to the message this side holds, and returns that message. outcome records what your agent did with it: accepted, rejected or failed. It is required: this SDK never signs a decision on your behalf, and a missing or invalid value raises HandoffOutcomeRequiredError before anything verifies or is written. expectedSender (expected_sender in Python) refuses a sender you did not name, before anything is recorded. receive verifies the signature and does not, on its own, decide who you deal with: a key you have never seen verifies exactly as a known one does. Pass the counterparty’s public key here and a message signed by any other key raises HandoffUnexpectedSenderError, naming the key it actually carried. The record receive writes carries the outcome you passed, so a check you make afterwards cannot change what the record says. The key in the attachment is only a claim until receive verifies it, and a forged claim fails there with HandoffUnverifiedError.

Confirm the hand-off to the sender

The receiver’s handoff.received record is itself signed, so it can travel back as the confirmation. Send back the record receive wrote, then let the sender’s own handoff helper check it:
confirm (from 0.15.0) checks the receiver’s record with the open kernel: that it verifies, was signed by the receiver, names this sender, and commits to this message at this sequence. It never throws on a bad or forged confirmation, because a confirmation is untrusted by definition: it returns { confirmed, reasons, outcome, receiverPublicKey, readings } (confirmed, reasons, outcome, receiver_public_key, readings in Python), and reasons names every check that failed. A reply that says “accepted” with no signed record behind it is not a confirmation, and confirm reports it as unconfirmed rather than raising.

What receive checks, and what it refuses

receive throws rather than hand your agent a message it cannot stand behind. Each refusal has its own error, with the same name in both languages: An altered message leaves a record, on purpose. If it left nothing, a reader would see one sent record with no counterpart, which reads as an absence rather than as tampering. The rejected record commits to what this side actually held, so the pair forms and fails, and the failure names the record. The replay guard lasts as long as one helper. A new process, or a second helper for the same case, does not see what an earlier one received. RecordNotDurableError on receive is retryable, and the retry hands back the message. The record for a receive call is written before the buffering gate is asked whether it is durable yet. Retrying the identical delivery (the same sender, the same sequence, the same message) asks the gate again and, once the record is durable, returns the message your agent was already owed, writing no second record. A different message arriving at that same sequence still raises HandoffReplayError, never RecordNotDurableError. Identity does not decide whether a record verifies. receive does not look up who a key belongs to. expectedSender narrows who you accept before anything is written; it is not a roster check. received.senderPublicKey (sender_public_key in Python) gives you the key, and whether it is enrolled and not revoked is a question you answer on your own terms.

What the record commits to

event.message_hash is SHA-256 over the canonical form of the message object (JCS, RFC 8785), never over the bytes on the wire. A transport that parses and re-serializes your JSON changes the bytes and not the object, so an honest hand-off still matches. A transport may change key order, whitespace, string escapes and number spelling, and add one leading byte order mark, and the hand-off still matches. Bytes that are not UTF-8, a repeated member name, or nesting deeper than a parser holds read as altered. The raw-byte hash is recorded beside it as event.message_bytes_hash, and it is a diagnostic only. Nothing pairs or grades on it, and the two sides of an honest hand-off can differ there. send refuses an integer outside plus or minus 253 - 1 with UnrepresentableValueError, before it spends a sequence number. JCS writes every number as a double, so a reader that keeps the integer on the wire would see a number nobody supplied. Send an exact large id as a string.

Send a file

A file travels as a descriptor inside the message: its media type, its SHA-256 and its length. The bytes never enter the record and are never sent to Sanning.
Put the descriptor in the message’s parts, and send the file beside the message however you like. A different descriptor changes the message, so the pair fails.
receive takes no file bytes, so it does not compare the bytes that arrive beside a descriptor with its content_hash. Hash the file yourself and compare before your agent acts on it.

Numbering within a case

Each sender numbers its own messages in a case, starting at 1, and the receiver records the sender’s number unchanged. Two agents in one case each start at 1 and do not collide, because a number is read against the key that signed it. A number is spent even when the record fails to write, so a gap can open in one sender’s numbers. No check in either kernel reports that gap: pairing compares a record’s sequence against its counterpart’s, and never against the numbers around it, so a missing number is not something a reader is told about. To resume a case after a restart, pass startSequence (start_sequence in Python).

Keep working when Sanning is unreachable

Nothing crosses to the other agent before its record is durable. What durable means depends on whether the anchorer buffers:
  • Without a buffer, the default, a record is durable once Sanning acknowledged it. If Sanning is unreachable, send throws the network error and hands back nothing to send, so the hand-off waits on Sanning.
  • With a buffer, a record is durable once it is written to a directory you name. send returns, and the SDK delivers the record to Sanning afterwards, retrying while Sanning is unreachable.
Name a buffer directory to turn buffering on:
Name a directory that outlives the process. A path inside a container that is thrown away on restart loses whatever the buffer had not delivered. recordingStatus() (recording_status() in Python) reports the mode (direct or buffered) and how many records the buffer still holds. With a buffer, close() makes one delivery pass and throws only for a record that is lost, not for one still waiting in the buffer. To stop recording at the first record that cannot be made durable, pass failClosed: true (fail_closed=True in Python).

Hand over and verify a case

Close each run, bundle one pack per agent, and read both as one case.

What a hand-off record does not prove

Integrity, not accuracy, and the other limits to state to a reader.

The A2A 1.0.0 mapping

Where each part of an A2A message ends up in the record.