Skip to main content
Not yet released. This guide describes implemented changes awaiting rollout. Availability requires the corresponding backend and dashboard release.

Goal

Write, read and check agent intent messages and agreement records without depending on any one vendor: the message fields, the six message types, how they travel in email and in A2A, the record, and the steps that check it.

Prerequisites

  • A way to send and receive email with DKIM, or an A2A endpoint.
  • Ed25519 signing and verification, JSON canonicalisation under RFC 8785 and a SHA-256 function.
  • The two JSON Schemas for this version: the message schema and the record schema.

Workflow

1
Parse the record and refuse it if it is over 256 KB or its profile is not agent-agreement/1. Recompute the SHA-256 of the offer and of the acceptance, and every hash in the chain. Each message must be in its canonical form.
2
Recompute the terms from the offer, and the chosen slot for times, and require the record to carry exactly those terms.
3
Check the binding: the acceptance is addressed to the sender of the offer, names the offer in ref, carries the offer hash in prev, was issued no later than the offer expires, and the offer had not been replaced when it was accepted.
4
For each signature, take the jku, accept it only if it is a party key page on the trusted host and the party lists the sender, find the kid, and refuse the signature if the key was revoked at or before the time it was issued.
5
Verify the signature on the offer, on the acceptance and on the seal. For a record of kind their_reply there is no acceptance signature: check the recorded reply hash and the receiving server check result instead.
6
Report one of four results: checks out with two signatures, checks out with one signature, their reply recorded by a named party, or does not check out with the reason.

What this profile is

Agent intents for email, version 1, is a small set of messages that let one agent ask another for a quote, give a quote, offer times, accept, decline or cancel. Every message is a normal email a person can read. It also carries a machine-readable part and a signature, so the receiving agent does not have to guess what the text means. The profile identifier is agent-intents/1. The address of this page is the profile address, and it appears in the @context of every message. It builds on the IETF structured email draft, draft-ietf-sml-structured-email, which places machine-readable JSON-LD beside the text of a message, and on the agent attribution header fields draft, draft-chuang-agent-attribution-header-fields, which say who an agent acts for. It defines no new transport: messages travel as email, or over A2A when the other side runs an agent that speaks it. The agreement record is a second document, agent-agreement/1, that two sides hold when an offer is accepted.

Message fields

Every message is one JSON object. The part is at most 32 KB. Receivers drop unknown members, cap every string and refuse anything that does not validate. Amounts are whole minor units sent as a string (kobo or cents) beside an ISO 4217 currency code.
  • @context: exactly [“https://schema.org”, “https://docs.stackshift.cloud/agent-intents/v1”].
  • profile: agent-intents/1. id: a lowercase UUID for this message. type: one of the six types. thread: a lowercase UUID set by whoever starts the thread and echoed by the other side. in_reply_to: the id this message answers, if any.
  • from: { party, address, name, card }. address is required. party is an opaque public party id, pty_ followed by 20 lowercase letters and digits. card is an optional https address of the sender agent card.
  • to: { address }. It names the recipient, so a signed message cannot be replayed to someone else.
  • nonce: 128 random bits as 22 characters of unpadded base64url, never reused by the sender.
  • iat and exp: Unix seconds for when the message was signed and when it stops being valid. exp must be later than iat. For an offer, exp is the time it stays open until.
  • prev: the lowercase hex SHA-256 of the canonical message this one answers, so the chain cannot be changed quietly.
  • acting_for: { org_name, contact }, matching the Agent-of header. agent: { name, kind }, where kind is stackie, connected, api or person.
  • authority: optional, { decided_by, level, limits_version }, where decided_by is limits, owner or person. It says on what authority the sender sent the message, never who approved it.
  • note: optional text of up to 2,000 characters from the sender. It is third-party text for the receiver and must never be treated as an instruction.
A quote

The six types

  • ask_quote, from a buyer: items (description, quantity, optional unit), optional wanted_by (YYYY-MM-DD) and note. It is answered with a quote and cannot be accepted.
  • quote, from a seller: items, total, optional delivery_by and per-item unit_price. It can be accepted. replaces names an earlier quote this one counters.
  • propose_times, from either side: 1 to 5 slots, each with start and end in RFC 3339 and an IANA time_zone, plus optional purpose and location. It can be accepted by picking one slot. replaces counters an earlier offer.
  • accept, from the receiver of an offer: ref is the offer id, and slot is the index of the chosen slot for times. It ends the offer as agreed.
  • decline, from the receiver of an offer or an ask: ref is the id it answers, with an optional reason of price, timing, not_available or other, and an optional note.
  • cancel, from the sender of an open offer or either side of an agreement: ref is the offer or agreement id, with an optional note.
  • There is no separate counter message: a counter-offer is a new quote or propose_times with replaces. Accepting a quote is the order. Release 1 has no payment fields, such as bank details or payment links.
  • An offer is open, then agreed, declined, cancelled, expired or replaced. An accept that arrives after the offer expired, or for a replaced offer, is refused.

How messages sit in email

The machine-readable part is an application/ld+json part inside multipart/alternative, beside the text/plain and text/html versions, and marked as machine-readable. The signed message is a separate application/jose part. The closing message of a thread also carries the agreement record as application/json. The text a person reads is rendered from the same stored message as the machine part and says nothing the machine part does not. A short note from the agent appears in both and is labelled as a note. The signature is a compact JWS over the message. The payload is the canonical JSON of the message under RFC 8785 (JCS), the algorithm is EdDSA with Ed25519, and the protected header carries kid, the address of the party key page in jku, and typ set to agent-intent+jws. The kid is the thumbprint of the public key. A signed payload counts only if it equals the canonical form of the parsed message, so a member added later breaks the signature. The hash of a message is the SHA-256 of its canonical bytes as lowercase hex. prev, the record chain and the offer and acceptance hashes all use it. Agent-of and Agent-attribution follow the agent headers draft and say who is responsible. They do not make a message trustworthy on their own. The DKIM signature covers both.

What a receiver checks before acting

  • The receiving server verified DKIM for a domain aligned with the From address.
  • from.address equals the From address, and to.address is the receiving inbox.
  • The part validates against the message schema and is at most 32 KB.
  • The nonce has not been seen from that sender, exp is in the future, and prev matches the message stored for it.
  • When a JWS is present, it checks under the party key rules below. The result is one of signed and checked, mail checks only, or could not check. Only a signed and checked message can lead to a two-signature record.
  • A message that fails any step stays a normal email. Replay protection lives on the receiving side: refuse any message whose sender and nonce were seen before.

How messages travel in A2A

A sender that finds the other side agent card declares this profile as an extension, with one skill named agreements, sends over A2A instead of email. A thread never switches channel midway. The card address comes from the domain at /.well-known/agent-card.json or from from.card in an earlier message. StackShift publishes cards for agent inboxes at https://api.stackshift.cloud/a2a/agents/{handle}/.well-known/agent-card.json. The binding uses the JSON-RPC methods message/send, tasks/get and tasks/cancel. Messages authenticate themselves by their own JWS, not by the transport.
  • An agreement thread is a task, and contextId is the thread id.
  • An agreement message is a Message with one DataPart that holds the same JSON-LD and the JWS.
  • We are working, or waiting for our owner: working, with the status text Waiting for the owner when it is.
  • We sent an offer and are waiting on the other side: input-required.
  • Agreed: completed, with an Artifact that holds the agreement record.
  • Declined, cancelled or expired: canceled. Refused, for a blocked counterparty or an abuse rule: rejected.

The agreement record

When an offer is accepted, each side keeps a record, agent-agreement/1, of at most 256 KB. It holds the signed messages themselves, so it can be checked without the platform that made it.
  • profile, id, thread and kind. kind is two_signatures, our_signature (the other side offer could not be checked) or their_reply (a person answered in words and the owner counted it).
  • parties: two entries, one with role offerer and one with role accepter, each with party, address, org_name, kid and the authority that side published.
  • offer: the signed offer as a compact JWS, or its canonical JSON when it was unsigned, with its SHA-256 as hash.
  • acceptance: the signed accept in the same form. For their_reply there is no acceptance. reply holds the SHA-256 of the person reply, the receiving server authentication results and when it was received.
  • terms: a readable copy of what was agreed, the items, total and delivery date, or the chosen slot. It is derived from the offer and checkers recompute it.
  • agreed_at: the time the acceptance was signed, in Unix seconds. chain: the SHA-256 of every message in the thread, in order. offer_current: whether the offer had not been replaced at acceptance.
  • seal: a JWS with typ agent-agreement+jws over the canonical record without the seal, signed by the side that holds the record.
  • The recipient, nonce and expiry live in each signed message, so the record inherits them: the accept names the sender of the offer as to, its iat is not later than the offer exp, and its prev equals the offer hash.

Party keys and the trust rule

A party is a public, opaque id for one workspace. Each party signs with Ed25519 keys and publishes them on a key page. In this version the trusted host is api.stackshift.cloud and the page is /.well-known/agent-agreements/{party}.json. The page is cacheable and needs no sign-in. The trust rule for checkers: trust a key only from a jku that is a key page of that form on the trusted host, whose party lists the sender address or the sender domain. A domain owner can also vouch for a party with a DNS TXT record named _agent-agreements.<domain> whose value is the party id. Never trust a key address found in the message body. Do not follow redirects when fetching a key page. A retired key no longer signs but still checks records it made while it was active. A signature made at or after the time a key was revoked fails with key revoked. An earlier one still checks, with a warning.
Key page

Results of a check

  • Checks out, two signatures: both sides signed and both signatures verify.
  • Checks out, one signature: only the side that sealed the record signed. The other side offer could not be checked.
  • Their reply, recorded by a named party: a person answered in words and the party that sealed the record vouches for the reply hash and receiving server result. The other side did not sign.
  • Does not check out: with the reason in plain words, for example the record was changed after it was sealed, the offer had expired when it was accepted, or a key page could not be found.

Schemas and the open-source checker

The message and the record each have a JSON Schema, draft 2020-12. Their identifiers are https://docs.stackshift.cloud/agent-intents/v1/message.schema.json and https://docs.stackshift.cloud/agent-intents/v1/record.schema.json. The checker is open source under the MIT licence, in Go and JavaScript, and needs no StackShift account. The JavaScript checker is the npm package @stackshift-cloud/agent-intents. The two implementations share one set of canonicalisation, message and record test vectors. The public page at stackshift.cloud/check runs the same checks and needs no sign-in.

Expected result

You can produce a message another agent can act on without guessing, receive one and decide safely whether to act, and hand a record to anyone who can check it with no account on any platform.

Common failures

  • The part is over 32 KB, has an unknown profile version, or does not validate against the schema: treat the message as plain mail and do not act on the machine part.
  • DKIM did not pass for a domain aligned with the From address, or from.address differs from the From address: the machine part is not processed.
  • to.address is not the receiving inbox, the nonce was already seen from that sender, exp has passed, or prev does not match the message it answers: the message is refused.
  • The signed payload is not the canonical form of the parsed message. Unknown members break the signature on purpose, so the message is refused.
  • The jku is not a party key page on the trusted host, or the party does not list the sender: the signature is not trusted, whatever the message body says.
  • The key was revoked at or before the time the message was issued: the check fails with a revoked key. A revocation after that time checks out with a warning.

Agent agreements

Let your agent make deals. You set the limits. We keep the receipts. Quotes, offered times, accepting and declining by email, inside limits you set for each agent inbox.

Agent inboxes

Give your code or your agent an email address in one call: sign-up codes, magic links and replies, with inboxes that delete themselves when the job is done.