Coalition Protocol v0.1 (draft)¶
Status, conformance language and versioning¶
This is a working draft of Coalition Protocol v0.1, an extension to A2A for sessions shared by many agents. It will change in response to implementers until v1.0 declares it stable.
- Status: draft, October 2026. Published with the reference implementation under Apache 2.0.
- Conformance language: MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119 and RFC 8174.
- Versioning: the extension URI carries the major version (
/coalition/v0). Minor versions only add optional fields and message types; anything that breaks a v0 implementation gets a new URI. Every envelope carriesext_version. - Normative dependencies: A2A protocol 1.0 (bug-fix release 1.0.1); JSON Schema 2020-12; JWS (RFC 7515), JWK for Ed25519 (RFC 8037) and JSON Canonicalization Scheme (RFC 8785) for signatures; RFC 3339 timestamps; ULIDs for identifiers; ristretto255 (RFC 9496) and RFC 9380 for PSI.
- Fixed identifiers: the extension URI is
https://coalitionkit.dev/ext/coalition/v0; every schema$idstarts withhttps://coalitionkit.dev/schemas/coalition/v0/.
Roles
| Role | Who plays it | Must implement |
|---|---|---|
| Host | The runtime running a Coalition (Coalition Host and Gardener) | Ordering, lifecycle, scopes, authorization, projections, receipts, the Steward hook |
| Member | Any participant's agent | Joining, offers, decisions, commitments, scope handling |
| Owner | The person or organization a member speaks for, holding an owner key | Consent: signing every decision |
| Vault | The private store behind a member | Typed answers and the PSI exchange |
| Steward | The independent checker | Verdicts on messages, plans and minted artifacts |
| Edge | An adapter that turns outside input into observations | Observation messages with sources |
Three keys, three jobs. The agent key is the key of the member's signed card; it signs the member's envelopes. The owner key (Ed25519) is bound to the participant at join and signs consent; the agent key can never stand in for it. The host key is the key of the host's signed card, pinned by every member; it signs everything the host originates, every projection and every receipt.
Binding to A2A¶
The Coalition Protocol adds no transport of its own: a Coalition is a set of A2A tasks that share one contextId, and every Coalition message travels as a data part inside an A2A message.
Extension URI: https://coalitionkit.dev/ext/coalition/v0. Every call carries the A2A-Version: 1.0 header; a host treats a call without it as A2A 0.3 and refuses it.
Declaring support. A conforming agent lists the extension in its Agent Card under capabilities.extensions, using A2A's AgentExtension fields (uri, description, required, params):
{
"capabilities": {
"streaming": true,
"extensions": [{
"uri": "https://coalitionkit.dev/ext/coalition/v0",
"description": "Takes part in multi-party Coalitions",
"required": false,
"params": {
"roles": ["member"],
"vault": "local",
"psi": { "scheme": "ecdh-ristretto255", "library": "coalitionkit.psi" },
"surfaces": ["web", "mcp_app"]
}
}]
}
}
Signed cards. Every card used in a Coalition MUST carry at least one entry in A2A's signatures list: a JWS whose protected header names alg, typ (JOSE) and kid, over the card canonicalized with JCS after removing the signatures field and every field left at its default value. The card lists its endpoints in supportedInterfaces (binding JSONRPC, version 1.0). A card is untrusted until verified: the host pins the key it verified at join, never follows a jku URL from the card, and MUST reject unsigned cards, expired or revoked keys, and cards whose owner proof it cannot verify. Members pin the host's card key the same way, from the invitation they joined by.
Mapping
| Coalition concept | A2A construct |
|---|---|
| Coalition | A shared contextId equal to the Coalition ID |
| Exchange | One A2A task per exchange on the member's agent. The member's server assigns task IDs; once a task reaches a final state, the host starts a new task in the same contextId |
| Host to member | Messages and streamed task updates on that task |
| Member to host | Messages sent to the host's own A2A endpoint, which has its own signed card |
| Coalition message | A Part whose data is the envelope, with media type application/vnd.coalitionkit.envelope+json; the message's extensions list names the extension URI |
| Acknowledgement | Two such parts, in order: the accepted event in the form the sender may see (original or projection), then its coalition.receipt; only the receipt when the sender is outside the event's scope |
| Input from a person | The member's task waits in TASK_STATE_INPUT_REQUIRED; the answer continues it with the same task and context IDs |
| Coalition end | Open member tasks move to a final state when the Coalition dissolves |
Members MUST activate the extension on each call with the A2A-Extensions header carrying the extension URI (not X-A2A-Extensions, which some SDK docs still show), and the host MUST ignore Coalition data parts on calls that did not activate it. The extension is declared with required: false, so an agent without it can still reach the host's other skills.
The message envelope¶
Every Coalition message is one envelope. The sender fills and signs the envelope without seq, ts and prev_hash; the host assigns those three when it accepts the message. The host takes the sender from the authenticated connection and refuses an envelope whose sender or on_behalf_of differs from it. Envelopes the host itself originates (charter, need, query, propose, tension, dissolve, error, projection, receipt) carry the host's card URL as sender, the literal host as on_behalf_of, and are signed with the host key.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://coalitionkit.dev/schemas/coalition/v0/envelope.json",
"type": "object",
"additionalProperties": false,
"required": ["ext_version", "coalition_id", "event_id", "type", "sender", "on_behalf_of", "scope", "payload", "sig"],
"properties": {
"ext_version": { "const": "0.1" },
"coalition_id": { "type": "string", "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" },
"event_id": { "type": "string", "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" },
"type": { "type": "string", "pattern": "^coalition\\.[a-z_]+$" },
"sender": { "type": "string", "format": "uri", "description": "URL of the sender's signed Agent Card" },
"on_behalf_of": { "type": "string", "description": "Participant ID of the owner the sender speaks for" },
"scope": { "$ref": "scope.json" },
"causation_id": { "type": "string", "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" },
"correlation_id": { "type": "string", "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$" },
"payload": { "type": "object", "description": "Validated against the schema for this type" },
"sig": { "type": "string", "description": "Compact JWS (detached payload) over the JCS-canonical envelope without sig, seq, ts and prev_hash" },
"seq": { "type": "integer", "minimum": 1, "description": "Set by the host" },
"ts": { "type": "string", "format": "date-time", "description": "Set by the host: when it accepted the envelope" },
"prev_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Set by the host" }
}
}
Signature form. sig is a compact JWS with a detached payload, <protected>..<signature>; the protected header holds exactly alg: EdDSA, typ: JOSE and kid. The verifier rebuilds the payload as the JCS form of the envelope without sig, seq, ts and prev_hash. Every other signature in this document (consent_sig, owner_proof) has the same form over its own JCS payload.
Rules
- The log keeps every original signed envelope exactly as the sender signed it, plus the host fields. The log is the only source of truth; anything else the host delivers is derived from it (see Projections).
seqcomes from a per-Coalition counter row incremented in the same transaction as the append, so the log has no gaps; a database sequence would leave gaps on rollback.prev_hashis the SHA-256 of the JCS form of the previous accepted envelope in the same Coalition (all fields,sigincluded), so any gap or edit in the log is detectable. Theprev_hashof seq 1 is the genesis hash (see The authenticated log boundary).- An event is committed to the log before it is delivered, projected or answered; nobody sees a change the log cannot rebuild.
- Signatures and hashes are computed over JCS (RFC 8785), so a number re-encoded on the way (200 as 200.0) still verifies. Every integer in a payload lies between 0 and 2^53 - 1, which is what JCS can carry exactly. Money is an integer in minor units (cents), never a float.
- Every payload schema sets
additionalProperties: false. A member therefore cannot attach extra fields, such as reasons, that the protocol does not define. - Object identifiers the host mints (needs, tasks, plans, tensions) are ULIDs. A member mints the ULID of each commitment it creates, in its signed
commit; the host rejects acommitment_idalready used in the Coalition, in any state, withnot_authorized. Identifiers a participant mints (offer_id,query_idanswered by it,match_id) are strings of 1 to 128 characters.
Idempotency is authenticated¶
The host looks up every submission by (coalition_id, event_id) before it does anything else that depends on the content. It then compares the authenticated sender and the submission digest, the SHA-256 of the JCS form of the resubmitted envelope without its host fields (seq, ts, prev_hash; sig stays in):
| Lookup finds | Same authenticated sender and same digest | Result |
|---|---|---|
| Nothing | n/a | Process normally |
| An accepted event | Yes | Return the acknowledgement again, in the form the sender may see (see Acknowledgements and receipts); append nothing |
| An accepted event | No (other sender, or other bytes) | Reject with duplicate_mismatch |
duplicate_mismatch discloses nothing about the original: the same answer, with an empty detail, whichever of the two differed and whether or not the sender could ever see the original. A sender that really wants to change a message sends it under a new event_id.
Acknowledgements and receipts¶
The host acknowledges every accepted envelope with the accepted envelope (now carrying seq, ts and prev_hash) and a host-signed coalition.receipt with payload {coalition_id, seq, head_hash}, where head_hash is the SHA-256 of the JCS form of the accepted envelope at seq, which is also the value the next envelope's prev_hash must hold. A receipt is scoped to its recipient, signed with the host key and never appended to the log. The sender SHOULD keep every receipt. A host MUST NOT acknowledge an event it has not committed.
The acknowledgement obeys scope. The first part of an acknowledgement is the event as the sender would receive it as any recipient, by the delivery form rule under Projections: the original when the sender is in the event's scope and nothing is redacted or viewer-specific for it; a projection when the sender is in scope and a field is redacted or the event is viewer-specific; and nothing (the receipt alone) when the sender is outside the event's scope. A duplicate gets the same form, computed again from the stored event, and the same receipt. The receipt carries only seq and head_hash, which disclose nothing of the content.
The authenticated log boundary¶
The genesis: prev_hash of seq 1 is the SHA-256 (lowercase hex) of the JCS form of {"coalition_id", "host_card_url", "created_at"}, where host_card_url is the host's card URL and created_at is the RFC 3339 time the host created the Coalition. created_at is also carried in every coalition.charter, so a member can recompute the genesis hash.
A reader that holds the whole log, or a replay in which events outside its scope are replaced by stubs {seq, prev_hash, hash} (the stub's hash being the SHA-256 of the JCS form of the original), checks four things: seq is gap-free from 1; seq 1 links to the genesis hash; each prev_hash equals the hash of the entry before it; and, for every receipt it kept, the log reaches that receipt's seq and the entry there hashes to the receipt's head_hash. A projection stands in for its source entry through the stub it carries (see Projections): the reader MUST verify the projection's host signature against the pinned host key first, and a projection that fails is an error, not a gap; only then do the chain and receipt checks use its source.prev_hash and source.hash, never a hash of the projection's own envelope. In coalitionkit_contracts, verify_chain and check_receipts take the host key and verify each projection before it enters the chain. The first three catch gaps and edits inside the log. Only the receipts catch an edited last entry, an edited tail whose later prev_hash values were recomputed, and a truncation: a host that cuts the log back, or rewrites its tail, cannot produce a log that fits a receipt it signed earlier. A member can detect only what its own receipts cover.
Projections¶
Anything delivered with a field redacted, or projected for a particular viewer, is a projection: an envelope of type coalition.projection signed by the host key. It never carries the sender's original JWS and is never appended to the log. Its payload is:
| Field | Meaning |
|---|---|
source |
event_id, seq, type and on_behalf_of of the logged event it is derived from, plus prev_hash (its prev_hash) and hash (the SHA-256 of the JCS form of the original entry, the value the next entry's prev_hash holds). Signed with the projection, these are the stub {seq, prev_hash, hash} for chain checks |
payload |
The source payload after redaction or per-viewer projection. It is not validated against the source type's schema, because redaction removes required fields |
redacted |
Optional list of the payload. paths removed |
Recipients verify a projection against the host key they pinned, never against the sender's key. The host delivers the original envelope only when no field is redacted for that recipient and the event is not viewer-specific; otherwise it delivers a projection. scope.redact applies to every recipient except the host and the Steward, so any event with a non-empty redact is delivered to everyone else as a projection. A per-member coalition.propose (each member sees only its own assignments) is likewise a projection. The projection's own scope is the recipient. The delivery form is one function of three facts: a recipient outside the scope gets no delivery (a sender gets only its receipt); inside the scope, a redacted or viewer-specific event is a projection; any other event is the original.
Message types¶
Twenty types in v0.1: eleven that members, vaults and edges send and nine that only the host sends. Payload fields listed are the required ones; each type has its own JSON Schema in schemas/coalition/v0/.
| Type | From, to | Required payload fields | Notes |
|---|---|---|---|
coalition.charter |
Host to members | goal, roles, rules, deadline, deciders, revision, created_at; optional pack, budget, count_trusted |
deciders (non-empty) are the participants who decide plans; an updated charter carries revision + 1 and members re-accept it |
coalition.join |
Member to host | card_url, owner_key, owner_proof, roles_requested, surfaces, vault (local or hosted) |
owner_key is an Ed25519 JWK; owner_proof binds it to the participant (see Owner authority). The host replies with granted roles |
coalition.leave |
Member to host | none (a reason is optional and never required) | Withdraws the member's open offers |
coalition.observe |
Edge to host | source (voice, camera, chat, device, document), content, optional evidence_ref |
Scoped to who may see the source |
coalition.need |
Host to members | need_id, description, requirements, window |
Publishing a need creates a task with the same ID in status open |
coalition.offer |
Member to host | offer_id, target (a need or task), capacity, cost, valid_until |
cost has minutes, money and effort; no free text |
coalition.query |
Host to vault | query_id, kind (feasible, cost, availability), subject; optional deadline_ms, task_id |
deadline_ms defaults to 10,000. A query asked in planning a task carries its task_id |
coalition.answer |
Vault to host | query_id, result (yes, no or a cost) |
The only thing a vault ever returns |
coalition.match |
Vault to vault, relayed by the host | match_id, task_id, peer, round (setup, request, response), blob, reveal (overlap or count) |
The host relays opaque blobs and cannot read them (see Private set intersection). task_id is the task the exchange concerns |
coalition.propose |
Host to members | proposal_set_id, plans (each with plan_id, revision, assignments and objective scores), decision_deadline |
Each member sees only its own assignments (a projection). The tasks it covers are those its assignments name |
coalition.tension |
Host to deciders | tension_id, revision, task_ids (non-empty), options, tradeoffs, deciders, decision_deadline |
task_ids are the tasks the tension holds in proposed |
coalition.decide |
Member to host | subject, choice, consent (true or false), decided_at, consent_sig |
consent_sig is by the owner key |
coalition.commit |
Member to host | commitment_id, task_id, status (pending or withdrawn); optional revision |
commitment_id is a ULID the member mints when it creates a commitment. Never approved: approval is an owner-signed coalition.decide |
coalition.ripple |
Any participant | change, invalidates (task IDs) |
Scoped like the change it reports |
coalition.say |
Member to host | to (room or a participant ID), text (1 to 280 characters), intent (ask, answer, trade, caveat, agree, disagree); optional re (the event ID it answers) |
One line an agent says to the room or to one seat. Public to the room; to names who it is for, not who may see it. It changes no task and binds no one |
coalition.done |
Member to host | task_id, evidence (each with kind, ref, verified_by) |
|
coalition.dissolve |
Host to members | reason (completed, deadline, cancelled, budget), skills_written |
Final message of a Coalition |
coalition.projection |
Host to members | source, payload; optional redacted |
Host-signed; see Projections |
coalition.receipt |
Host to the sender | coalition_id, seq, head_hash |
Host-signed; see Acknowledgements and receipts |
coalition.tool |
Host to members | seat, agent, tool, args, result, status (started, done, failed), ms, refs |
What the host saw an agent do: a model call, a private set intersection round, a Steward review, an outside agent's tool call or A2A message. See Tool activity |
Errors travel as coalition.error with a code, a detail and the ref of the event they answer (see Errors). coalition.error is a payload type the host sends but not one of the twenty.
Tool activity¶
coalition.tool lets everyone in a Coalition watch the agents work. The host records what it observes and nothing a member claims: a member, vault or edge that sends coalition.tool is refused with not_authorized.
seatis the participant the activity belongs to, orhostfor the host's own work (the Gardener, the Steward).agentis a display label such ashosted,mcp,a2a,gardener,steward,vaultordevice.toolnames the call as<family>:<name>, for examplemodel:offer,psi:compare,steward:review,mcp:make_offer,a2a:offer.- A call is recorded as one event with
status: "started"and one withdoneorfailed; the second carries the first'sevent_idas itscausation_id, andmsis the measured duration (0 on a start). A host MAY record an instant activity, such as a Steward review, as a singledoneevent. argsandresultare one line each, at most 200 characters. The host builds them from public facts only: never a value from a vault, a contact detail, a key, a bearer token or the text of a prompt. Before recording, the host runs its deterministic outbound policies over both lines and replaces a line that matches any of them withwithheld.refslists the event IDs the activity produced or read; it may be empty.- The scope is
{"audience": "coalition"}. Tool events carry no authority: no lifecycle state changes because of one, and a host MAY stop recording them (for example under load) without changing the outcome of the Coalition.
Subjects and revisions¶
Everything a consent, a revision check or a decision is about is named with one shape, everywhere (decide, consent requests, stale-subject checks):
{ "kind": "plan" | "tension_option" | "commitment" | "charter" | "task", "id": "<ULID>", "revision": 1 }
kind |
id |
choice in a decision |
|---|---|---|
plan |
plan_id |
The plan_id |
tension_option |
tension_id |
An option_id of that tension |
commitment |
commitment_id |
The commitment_id |
charter |
the coalition_id |
The coalition_id |
task |
task_id |
The task_id |
For every kind except tension_option, choice MUST equal the subject id; consent carries the yes or no.
The host assigns revisions. An object starts at revision 1 and the revision goes up by 1 on every change to it. The producers expose the current revision: propose in each plan's revision, tension and charter in revision, and commit through the consent request and the projection delivered to its decider (a member omits revision when it creates a commitment, which starts at revision 1, and gives the revision it is withdrawing when it withdraws one). Recording a decision compares the decision's revision with the current revision atomically, in the same transaction that records it. Any other revision, older or newer, fails with stale_subject. A change to the subject supersedes every pending consent request for an older revision.
Owner authority¶
Agent authority and owner authority are separate.
- At join, the member sends
owner_key, an Ed25519 JWK{kty: "OKP", crv: "Ed25519", x, kid}, andowner_proof: a compact JWS (detached payload) made with the owner key over the JCS form of{"coalition_id", "participant", "card_url", "owner_key"}, whereparticipantis the envelope'son_behalf_of. The host verifies the proof under the key it carries, checks that the key differs from the agent key, and pins it for that participant. Join is refused withschema_invalidorbad_signatureotherwise. The host MUST reject a join whose owner key (compared by RFC 7638 JWK thumbprint) is already pinned to another participant in the same Coalition, with errornot_authorized. - Every
coalition.decidecarriesdecided_atandconsent_sig: a compact JWS (detached payload) by the OWNER key over the JCS form of{"coalition_id", "participant", "subject", "choice", "consent", "decided_at"}, whereparticipantis the envelope'son_behalf_of. The host verifies it against the pinned owner key of that participant, and a consent signed for another participant fails; a failure isbad_signature. The agent's envelope signature alone never makes a decision count, and aconsent_sigmade with the agent key fails. - Through MCP, the server's hosted owner key signs a decision only after the person answers an MCP elicitation for that exact subject and revision (see The CoalitionKit MCP server).
Target authorization¶
A message must pass two independent tests: the scope rules (who may see what) and the authorization matrix below (who may send which type about which objects). Being able to see an object, or being named in a scope, never authorizes an action on it. The host checks the matrix after the stage and task-status check and before it applies the message. A failure answers not_authorized, except for decide, which answers not_a_decider.
| Type | Who may send | About which objects |
|---|---|---|
join |
An agent with a valid signed card, named by the invitation it joined by, that has never been a member | Itself; on_behalf_of is its owner. A participant that left does not rejoin in v0.1 |
leave |
A member | Itself only |
observe |
An Edge, or a member | Evidence it holds; its scope may be no wider than who may see the source |
offer |
A member | A need or task that is open or negotiating and visible to the sender. The offer counts only once the sender has accepted the current charter revision |
answer |
The vault of the participant the query was addressed to | Exactly one open query_id, once. A second answer is not_authorized; a late one is deadline_exceeded |
match |
One of the two parties of the exchange | Its own match_id, addressed to the peer fixed by setup. setup names a task_id in negotiating; the host records (match_id, task_id, responder, requester) at setup, and every later round must repeat that task_id and those parties or it is not_authorized. setup only from the responder, request only from the requester, response only from the responder, each round once and in order |
commit (create, pending) |
The agent of the assignee's owner | A task that the decided plan assigns to its owner and that is decided; one active commitment per task; a commitment_id not used before in the Coalition |
commit (withdrawn) |
The agent that created it | Its own commitment, pending (task decided) or approved (task committed) |
decide |
A decider named for the subject: for plan, the charter's deciders; for tension_option, the tension's deciders; for commitment and task, the owner of the assignee; for charter, each member for itself |
The named subject at its current revision; one answer per decider and revision. Approval of a commitment exists only as an owner-signed decide |
done |
The agent of the assignee | A task assigned to its owner whose commitment is approved (task committed) |
ripple |
A member | Tasks visible to the sender only; every ID in invalidates must be one |
charter, need, query, propose, tension, dissolve, projection, receipt, tool (and error) |
The host only | Rejected from any member, vault or edge |
Admission by short code and knock¶
A host MAY admit a participant by means other than a one-use invitation link, as long as every admission still ends in a one-use invitation redeemed by the join above; the matrix does not change.
- Short code. A host MAY give a Coalition one many-use short code. Presenting it makes the host issue and redeem a one-use invitation for the presenter, so each joiner is a separate participant. The host MUST refuse the code once the opener stops it, the charter deadline has passed, every seat the charter plans is filled, or the Coalition is dissolved, and MUST rate-limit attempts per code and per caller. A refusal MUST NOT name any member or seat holder.
- Knock. A host MAY let a stranger knock on a Coalition the opener has chosen to list. The knock gives the stranger a secret that only they hold; the opener answers yes or no. A yes yields exactly one one-use invitation, handed to the knocker once and only on presenting that secret, compared in constant time. A no admits nothing.
- A host erases every short code, knock and arrival record when the Coalition dissolves.
Visibility scopes and disclosure¶
Every envelope says who may see it, and the narrowest reading wins. A host MUST NOT deliver an event, or project it onto a surface, for anyone outside its scope.
{ "audience": "participants", "participants": ["p2", "p5"], "roles": [], "redact": ["payload.window"] }
audience |
Who sees it |
|---|---|
coalition |
Every member |
roles |
Members holding one of the listed roles |
participants |
Only the listed participants |
self |
Only the sender's own owner |
host |
The host and the Steward only |
Disclosure ladder: what a participant reveals rises one step at a time, and only as far as their own policy allows.
| Level | What others learn | Carried by |
|---|---|---|
| 0 | The signed card: who the agent speaks for and what roles it can play | coalition.join |
| 1 | Yes, no or a cost for a specific question | coalition.answer, coalition.offer |
| 2 | The overlap of two private sets, or only its size | coalition.match |
| 3 | The plan they are part of | coalition.propose, scoped per member |
| 4 | Details needed to do an assigned task (an address, a time) | coalition.commit and coalition.done, scoped to the assignees |
Rules
- There is no default scope:
scopeis required, and a sender that means only its own owner saysself. - Anything the host derives (a plan, a summary, a tension's text, a surface, a lesson written at dissolve) takes the narrowest scope of the events it came from, and the host refuses to widen it.
- Redaction does not survive derivation. A redaction path names a field of its own event, and a derived output renames and rewrites fields. So an output derived from a source that has a non-empty
redactlist is scopedhostand carries noredact, unless the host built it from that source's redacted projection only (the payload with the paths already removed). Such a source counts as projected: it keeps its audience and contributes no redaction paths. A derived scope never carriesredact. - Scopes apply on every path out of the host: deliveries, AG-UI snapshots and deltas, MCP reads, log replays, exports and traces.
- Business and machine participants receive only events scoped to them and the tasks assigned to them.
- The Steward sees every event but emits only verdicts; it never forwards content.
- Redaction removes the listed fields before delivery to anyone except the host and the Steward, and the delivery is then a projection signed by the host.
The narrowest scope of several sources¶
The narrowest scope of its sources is the intersection of the resolved principal sets of the sources, resolved at derivation time, not when a source was written:
Source audience |
Resolved principal set |
|---|---|
coalition |
Every member now |
roles |
Members who hold any listed role now |
participants |
The listed participants who are members now |
self |
The source sender's owner (the source's on_behalf_of) |
host |
Nobody |
An empty intersection becomes host. A source with a non-empty redact list that is not projected makes the result host (see Redaction does not survive derivation). The derived scope is written as coalition when every source is coalition, as roles when every source lists the same roles, and otherwise as participants with the sorted intersection (or host when it is empty). The reference function is resolve_narrowest in coalitionkit_contracts; the cases are in spec/vectors/scope.json.
Lifecycle rules and ordering¶
The host is the single writer of a Coalition's log, so its seq is the order. The Coalition has one stage; every task has its own status, so a ripple re-opens only the tasks it touches.
Two levels. The stage governs Coalition-level messages only: join, leave, observe, decide about the charter and dissolve. The task status governs every message about a task (answer, match, offer, propose, tension, decide, commit, done, ripple) in any stage from Gather to Live, whatever the stage says. A message rejected by either level gets stage_mismatch.
Stages
| Stage | Coalition-level messages accepted from members and edges | Messages about tasks | Moves on when |
|---|---|---|---|
| Germinate | join, leave, decide (charter) |
None | The charter's required roles are filled, or the organizer starts it |
| Gather | join, leave, observe, decide (charter) |
By task status | The Gardener has enough needs and offers to plan |
| Negotiate | leave, observe, decide (charter) |
By task status | A proposal set passes the Steward |
| Decide | leave, observe, decide (charter) |
By task status | Every named decider has answered, or the decision deadline passes |
| Act | leave, observe, decide (charter) |
By task status | Every commitment is approved or withdrawn |
| Live | leave, observe, decide (charter) |
By task status | All tasks are done, or the deadline passes |
| Dissolve | leave |
None | Terminal |
Task status moves open → negotiating → proposed → decided → committed → done, or to withdrawn from any status before done. A task starts open when its need is published.
| Task status | Accepted about the task | Moves to |
|---|---|---|
open |
offer, ripple |
negotiating when the host starts planning it (see below) |
negotiating |
answer, match, offer, ripple |
proposed when a plan or tension covering it passes the Steward |
proposed |
decide (plan, tension option or task), ripple |
decided when every named decider has consented |
decided |
commit (pending, or withdrawn for a pending commitment), decide (commitment or task), ripple |
committed when the assignee's owner approves the commitment; negotiating when the pending commitment is withdrawn |
committed |
done, commit (withdrawn), ripple |
done |
done, withdrawn |
Nothing | Terminal |
Planning starts without a message of its own. A task moves from open to negotiating at the first host-originated query carrying its task_id, or propose or tension naming it, whichever the log holds first; replaying the log gives the same status. A propose or tension in open passes through negotiating in the same transaction.
The host also sends query, propose and tension about a task in the statuses where they apply (negotiating for queries and proposals; negotiating or proposed for tensions). Any refusal in proposed or decided, a withdrawn commitment, a ripple that invalidates the task, or an expired decision sends it back to negotiating. In that same transaction the host marks the task's active commitment (pending or approved) superseded and closes every consent request about the task, its plans and its commitments. A superseded commitment is not active, so the replan can create a new commitment (a new commitment_id, revision 1) that needs a fresh owner-signed approval; a decision on a superseded commitment is stale_subject. A task not done when the Coalition dissolves is closed as withdrawn; a done sent in Dissolve is stage_mismatch.
Rules
- Members MUST treat
seqas the total order and MAY reorder their local view by it. - A refused consent, a withdrawn commitment or a ripple sets the affected tasks back to
negotiating; the Coalition keeps its stage. Only when every open task is back tonegotiatingdoes the Coalition itself return to Negotiate. - The host MAY send
coalition.dissolvefrom any stage on cancellation, deadline or budget exhaustion. - Only a decider named for the subject may send
coalition.decidefor it (see Target authorization). - Decisions are per decider. The first answer wins per (Coalition,
subject.kind,subject.id,subject.revision, decider). A later answer from the same decider for the same key is not recorded and getsnot_authorized. A subject is decided when every named decider has consented. For atension_optionevery decider must consent to the samechoice: consents naming different options leave no option decided (the tension is split) and count as a refusal. Any refusal sends the subject's tasks back tonegotiating. Answers from different deciders arrive inseqorder, are all recorded, and the last consent decides the subject. When one plan of a proposal set is decided, the other plans of that set are superseded. A tension whose options name plans (anoption_idequal to aplan_id) decides the chosen plan when the option is decided: that plan is decided with its assignments, for all of its tasks (including those the plans agreed on and the tension therefore did not hold), and the other plans of its set are superseded, so no separate decision on the plan is needed. - A decision names the revision of the subject it answers and the host compares it atomically as described under Subjects and revisions; a decision on any other revision is refused with
stale_subject. - A commitment made by an agent for an absent owner stays
pendinguntil that owner's owncoalition.decide, with the owner'sconsent_sig, approves it. The member'scommitcarriespendingorwithdrawn;approvedis a host-side state reached only through that decision. - Delivery is at least once; receivers deduplicate by
event_id. - Decisions expire.
proposeandtensioncarrydecision_deadline, an absolute RFC 3339 time that applies to every plan or option in them. Acommitment,taskorchartersubject has no deadline of its own; it ends with the Coalition'sdeadline. A decision the host accepts after the deadline (itstsis later thandecision_deadline) is not recorded and getsdeadline_exceeded. A subject that is undecided when its deadline passes is expired: its consent requests close, its tasks go back tonegotiatingand no commitment is created. Expiry is a pure function of the log and the clock: before applying any event, and before answering any read, the host applies the expiry of every subject whose deadline is earlier than that event'sts(or the read's time), in the same transaction. A new revision of a subject carries its owndecision_deadline.
Charter acceptance¶
A member accepts a charter with coalition.decide on a subject of kind charter at the charter's current revision, owner-signed like any decision. It is allowed in every stage before Dissolve. A member's offers and commitments count only after it has accepted the current charter revision. A new charter revision suspends the member's open commitments (a host-side flag) until the member accepts the new revision; a suspended commitment cannot be reported done. An answer that refuses the charter leaves the member without counting offers or commitments.
Errors, timeouts and retries¶
Errors are messages, never silence. Every rejected envelope gets a coalition.error scoped to its sender, and a true duplicate gets its original acknowledgement back. When a rejected envelope has no valid event_id, ref is a ULID the host mints for the purpose.
| Code | Meaning | Sender should |
|---|---|---|
schema_invalid |
The envelope or payload fails its JSON Schema | Fix and resend with a new event_id |
bad_signature |
The envelope signature does not verify against the sender's pinned card key, or a consent_sig or owner_proof does not verify against the owner key |
Re-sign; check key rotation |
sender_mismatch |
sender or on_behalf_of differs from the authenticated connection |
Drop the message |
unknown_participant |
The sender has not joined, or has left (any type but join) |
Join first |
extension_not_activated |
Coalition data arrived on a call without the A2A-Extensions header |
Resend with the header |
stage_mismatch |
The type is not accepted in the current stage, or the task is not in the right status | Wait for the stage, or drop the message |
scope_violation |
The scope names someone the sender may not address, or is wider than its sources | Narrow the scope |
not_authorized |
The sender may not send this type about this object (see Target authorization) | Drop the message |
not_a_decider |
A decision from someone not named for the subject | Drop the message |
stale_subject |
The decision answers a revision other than the subject's current one | Read the current revision and decide again |
duplicate_mismatch |
The event_id is already taken in this Coalition by another sender or by different bytes; detail is empty |
Send under a new event_id |
steward_blocked |
The Steward refused it; detail gives the policy ID |
Revise, or ask a human |
deadline_exceeded |
The answer arrived after its query's deadline, or a decision after its decision_deadline |
Nothing; the sender was counted as abstaining, or the subject expired |
rate_limited |
The sender exceeded its rate | Back off as told in detail.retry_after_ms |
budget_exhausted |
The Coalition's model budget is spent | Nothing; the Coalition continues without model calls |
pack_violation |
A pack policy forbids the action | Revise |
Check order. The host checks in this order and stops at the first failure: (1) extension_not_activated; (2) sender_mismatch, from sender, on_behalf_of and event_id, which it reads before schema validation; (3) the idempotency lookup, answering a replay or duplicate_mismatch; (4) rate_limited; (5) schema_invalid; (6) unknown_participant, except for join, which is checked instead against the invitation and the authenticated connection (not_authorized if either fails, or if the sender is or was a member) and registers the participant atomically with its acceptance; (7) bad_signature, including consent_sig, and for join the card signature and owner_proof; (8) stage_mismatch, stage then task status; (9) not_authorized or not_a_decider; (10) scope_violation; (11) deadline_exceeded for a decision past its decision_deadline, then stale_subject, both at recording time inside the transaction; (12) pack_violation, then steward_blocked. deadline_exceeded and budget_exhausted apply where their conditions arise.
Timeouts: each coalition.query MAY carry deadline_ms; when it is missing the deadline is 10,000 ms (a pack may change the default), and silence counts as abstaining. Decisions carry decision_deadline in their tension or proposal (see Lifecycle rules, rule 9).
Retries: senders retry transport failures with exponential backoff and jitter, reusing the same event_id and the same bytes. The host retries delivery to a member up to a configured limit, then marks it unreachable and notifies its owner through another surface.
Private set intersection (PSI)¶
coalition.match carries a two-party PSI between two vaults. The host relays; it cannot read a blob. This section describes the reference implementation (packages/vault, psi.py) byte for byte.
Scheme. ECDH-PSI over the ristretto255 group (RFC 9496), with a fresh secret scalar per party and exchange. Group operations are libsodium's. The two parties are the requester, who learns the result, and the responder. Both pin the other's Ed25519 signing key through the authenticated identity layer before the exchange; a key taken from the relay is never trusted. Every coalition.match message carries peer, the participant ID of the party the message is addressed to, and task_id, the task the exchange concerns; a receiver refuses a message whose peer is not its own participant ID or whose task_id differs from the one in setup. task_id is bound by the sender's envelope signature and by the host's exchange record (see Target authorization), not by the context digest, so the digest and the vectors' blobs are unchanged.
Context digest. C = SHA-256 of the JCS form of the JSON array ["coalition-psi-v1", coalition_id, match_id, requester, responder, reveal], where requester and responder are participant IDs. The order of the pair is part of the binding; swapping the roles, or changing reveal, gives a different C.
Element mapping. An element is an exact Unicode string, not normalized, of at most 4,096 UTF-8 bytes. H(x) is hash_to_group of the RFC 9380 suite ristretto255_XMD:SHA-512_R255MAP_RO_ with the domain separation tag DST = "CoalitionKit-v0.1-PSI-ristretto255_XMD:SHA-512_R255MAP_RO_" (ASCII), applied to the message C || JCS(x) (JCS of a JSON string). Concretely, expand_message_xmd with SHA-512 and 64 output bytes (b0 = SHA-512(0x00 x 128 || msg || 0x00 0x40 0x00 || DST || len(DST)), b1 = SHA-512(b0 || 0x01 || DST || len(DST)), len(DST) as one byte), then libsodium's crypto_core_ristretto255_from_hash on b1. Because C is part of the message, points from different exchanges never coincide.
Rounds. The responder sends setup, the requester sends request, the responder sends response; each once and in this order. A party that receives a message in any other state refuses it.
| Round | Sent by | Body (before framing) |
|---|---|---|
setup |
Responder | Pr, the responder's 32-byte channel public point (a fresh random channel scalar times the base point) |
request |
Requester | Pq (32 bytes, the requester's channel public point), then AEAD(list of H(x_i)^a) |
response |
Responder | AEAD(list of request points ^b, then list of H(y_j)^b) |
a and b are fresh random scalars. Each party shuffles its own input uniformly before use.
Blob. The blob of every message is the unpadded base64url (RFC 4648 section 5) encoding of:
| Field | Bytes |
|---|---|
ASCII CKP1 |
4 |
Round: setup = 0, request = 1, response = 2 |
1 |
| C | 32 |
| Round body | variable |
| Ed25519 signature by the sender's signing key over all preceding bytes | 64 |
A receiver rejects a blob longer than 94,000 characters; a match_id, peer, reveal or round that differs from the exchange it holds; base64url that is not canonical (re-encoding must give the same blob); a signature that does not verify under the pinned key; and a magic, round or C that differs from the expected one.
List. A list is an unsigned 16-bit big-endian count n, then n entries, each an unsigned 16-bit big-endian length (always 32) followed by a 32-byte canonical ristretto255 encoding. A receiver rejects a count above 1,024, a length other than 32, a truncated list, a non-canonical or invalid encoding, the identity element, a duplicate point and trailing bytes after the last list. The decrypted request body is one list; the decrypted response body is two lists, the first with as many points as the requester sent.
Channel. The relaying host sees every blob, so the request and response lists are encrypted and the host cannot compare matching tokens. Let S be the ristretto255 scalar multiplication of one party's channel scalar by the other's channel public point (both sides compute the same S; a public point that is invalid or the identity is refused).
- Request key
K1= SHA-256(ASCIIcoalition-psi-channel-v1|| C || S). - Response key
K2= SHA-256(ASCIIresponse||K1). - Encryption is ChaCha20-Poly1305 (RFC 8439) with a 12-byte all-zero nonce and the 16-byte tag appended to the ciphertext. Each key encrypts exactly one message, so the fixed nonce is never reused.
- The associated data is C || ASCII(round) || SHA-256(transcript). For
requestthe transcript is the ASCIIblobof thesetupmessage; forresponseit is the ASCIIblobofsetupfollowed by the ASCIIblobofrequest. The signature binds each body to its context and round; the associated data binds the encrypted lists to the whole earlier exchange. - The requester derives
K1from thesetupbody; the responder derives it from the first 32 bytes of therequestbody, and the rest of that body is the ciphertext.
Limits and reveal. Each side contributes at most 1,024 elements, and a party refuses duplicate elements in its own input. The longest blob is a response with 1,024 elements on each side: 93,068 characters. For reveal: overlap the responder returns the request points raised to b in the order it received them; the requester raises the responder's points to a, finds which of its own points match, and learns exactly those elements of its own set. For reveal: count the responder shuffles that first list independently before returning it, so the requester learns only the size of the intersection. In both cases the requester also learns the responder's set size, the responder learns the requester's set size, and the host learns both sizes from the blob lengths. The result reaches the requester only; a responder that wants the overlap runs a second exchange with the roles swapped. A responder that declines sends nothing and the requester treats silence as abstaining after the deadline.
Threat model. v0.1 PSI is secure against semi-honest (honest-but-curious) participants only. If both vaults follow the protocol, neither vault nor the relaying host learns any element outside the intersection; the host learns the set sizes. A malicious requester in count mode could blind each element with a different scalar and so learn which elements matched, not only how many. Therefore a vault MUST accept reveal: count only from a requester that the charter names in count_trusted, and otherwise refuse with not_authorized; overlap is the default and is always available to a requester the vault's own policy accepts. A zero-knowledge proof that one blinding scalar was used for all request points is deferred to v0.2. Low-entropy elements can be guessed by the peer by hashing candidates, so a vault decides which of its sets it will put into an exchange at all. The encrypted channel keeps the relaying host from comparing tokens or testing guesses against them, and the signatures keep it from altering a message undetected.
Known-answer vectors are in spec/vectors/psi.json. An implementation MUST reproduce them.
The CoalitionKit MCP server¶
Any MCP host (Claude, ChatGPT, Cursor, Muse Code) joins a Coalition through this server with the same rights and limits as an A2A member. It targets MCP spec 2026-07-28 and turns each tool call into a signed Coalition envelope on the participant's behalf.
| Tool | Input | Result | Becomes |
|---|---|---|---|
list_coalitions |
none | Coalitions this participant belongs to | none (a read) |
join_coalition |
invite (link or Coalition ID), roles |
Granted roles and the charter | coalition.join |
my_view |
coalition_id, device |
A plain-text summary, with the surface's data as structured content | none (a read) |
pending_questions |
coalition_id |
Open queries addressed to this participant | none (a read) |
answer_question |
coalition_id, query_id, result |
Accepted or an error | coalition.answer |
make_offer |
coalition_id, target, capacity, cost |
The offer ID | coalition.offer |
commit |
coalition_id, task_id, and either status: pending (create) or status: withdrawn with commitment_id |
The commitment ID and its status | coalition.commit, with the same rules as A2A |
report_ripple |
coalition_id, change, invalidates |
Accepted or an error | coalition.ripple |
accept_charter |
coalition_id |
The recorded acceptance | coalition.decide on the charter at its current revision |
decide |
coalition_id, subject, choice, consent |
The recorded decision | coalition.decide |
approve_commitment |
coalition_id, commitment_id |
The approved commitment | coalition.decide on the commitment |
report_done |
coalition_id, task_id, evidence |
Accepted or an error | coalition.done |
leave_coalition |
coalition_id |
Confirmation | coalition.leave |
web_search |
coalition_id, query |
Web results (title, url, snippet), or steward_blocked when the host refuses the query |
none (a read of the open web through the host) |
Resources: coalition://{id}/charter, coalition://{id}/my-tasks and coalition://{id}/log?since={seq}, each filtered to the caller's scopes. The 2026-07-28 spec is stateless, so every read is authorized on its own; nothing carries over from an earlier call. Tool results are rebuilt from an allowlist of fields, because hosts copy structured content into the text the model reads. A log read returns originals only where nothing is redacted for the caller, projections elsewhere (each carrying its source's prev_hash and hash), and stubs for events outside the caller's scope.
MCP Apps: one versioned view, ui://coalitionkit/surface-v1.html, renders the participant's live surface inside the host, so a Claude or ChatGPT user sees the same screen as a web user. The Coalition ID travels as tool data, never in the URI, because hosts cache views by URI; a breaking change to the view ships under a new URI. The view is one bundled HTML file under the default closed CSP, every tool it calls is authorized again on the server, and every tool also returns readable text for hosts that render no views.
Auth: MCP's OAuth-based authorization; each access token maps to exactly one participant. The server holds two hosted keys for that participant and never a shared key: the agent key signs envelopes, and the hosted owner key signs consent.
Writes need a person: decide, approve_commitment and accept_charter ask the person through MCP elicitation inside the tool call (multi-round tool results in the 2026-07-28 spec). The server's hosted owner key signs the consent_sig only after the person has answered an elicitation for that exact subject and revision, and records only the answer given there. For approve_commitment and accept_charter, whose input carries no revision, the server reads the subject's current revision when it builds the elicitation, shows it to the person, and signs for exactly that revision; if the revision changes before the answer, the elicitation is void and the host's stale_subject check stands. A confirmation flag in the tool's arguments is never trusted, because a model or an MCP App view can set it. commit and report_ripple need no elicitation: neither carries owner authority.
Surfaces: AG-UI and A2UI¶
Surfaces use AG-UI 1.0 for the live stream and A2UI v0.9.1 (the current stable release; messages carry "version": "v0.9") for the components. A2UI v1.0 is a release candidate, and moving to it changes behaviour as well as names (deletion, themes, duplicate surfaces), so v0.1 stays on v0.9 and the move needs a separate protocol revision.
Hosted member conveniences. With the same viewer bearer token and origin rules as /v1/agent/answers, POST /v1/agent/offer accepts {need_id, cost?, window?, ext?}. It signs and submits coalition.offer as that viewer’s hosted member and returns {seq, offer_id} or the host refusal code. Capacity is one; cost defaults to zero minutes and money with low effort; the offer expires at the need window’s end, or the supplied window’s end. Core validation precedes pack extension validation (schema_invalid). Private extension properties are refused with HTTP 422 before submission. A member with their own agent receives HTTP 409, “your agent sends offers”. These conveniences create only member messages that the member could send itself.
POST /v1/agent/vault accepts {values: {key: value}}, merging values into only the authenticated member’s encrypted hosted vault state. GET /v1/agent/vault returns {keys: [...]} only, never values. Non-hosted members receive 409. No participant selector is accepted. Values stay in the vault: they never enter the log, projections, or Steward input, and are erased with hosted state at dissolution.
Hosted rooms. A server MAY run hosted seats itself. It uses only what a member or the host could send. A helper is a hosted member whose card and labels say it is an agent; it holds its own keys, is never named in deciders, and its owner key signs only its own charter acceptance and its own commitments. When seats remain open, the owner MAY begin: the host issues a charter revision whose deciders are the seated people, and each of them accepts it as usual. A room bound to a pack that declares task_templates takes their titles, at most six and in order, as its needs, with each template's description as the need's detail, and the host does not draft the templates a second time when every seat has signed. A plan from the pack's allocation strategy that gives one person a larger share of the tasks than the Steward's fairness cap allows gives way to plans inside the cap. A result is a coalition.observe with source: "document" whose content is a JSON document holding result, need_id, kind, title, source, sources, components and data; the components pass the same host validation as any tree. kind is place, schedule, budget, list, draft or image. The viewer's result surface carries data as its data model, every number written as a string, so a tree may bind to it. An image result's data is {hash, alt, prompt, width, height}: hash is the SHA-256 of a picture the host keeps, and GET /v1/room/media/{hash} serves it, with the viewer bearer token, only to viewers of that Coalition; its data model adds image.src, that path. The host builds the prompt from public facts only (the goal and the need) and runs the deterministic outbound policies on it before it leaves the host; a match holds the call and is written down as a stop. coalition.done cites it with evidence of kind result whose ref is that event's ID. An offer made by a hosted seat means "I could do this need, and it would take me about this long": cost.minutes is that person's own estimate for that one need, capped per offer by the most time they said they would give to any one job, never summed over offers; cost.effort says how well the job fits (low for what they prefer, high for what they would rather not do). Offers are alternatives; the plans choose among them. A host MAY pass the planner a third objective, the total effort of the chosen offers, so plans can differ on time, evenness and preference. The times everyone can do are compared with the PSI messages (query/answer, then match setup, request and response, run as a chain by the first person) and only the shared times are published, in a coalition.observe whose content is {"common": 1, "task_id", "slots", "compared"}; how many of a person's own times stayed private is shown to that person only. A set a bound pack declares under compare is compared the same way and published as {"common": 1, "set", "values" or "count", "compared"}; how many of a person's own values stayed private is shown to that person only. A change a person says is a coalition.ripple on only the needs it touches; a need that is already done cannot ripple, so the host publishes a successor coalition.need and the older result is marked replaced. A minted device MAY take a hosted seat the same way as a helper, only after its Mint attack suite passed and its owner signed consent_payload(manifest_hash, "approve", {}) with their own owner key; its card and labels say it is a device, it is never named in deciders, and the minted MCP server it fronts receives no keys, tokens or store access. A device seat offers only on the needs that require it, and only on a hold it has already taken through the minted hold_slot tool; the hold is confirmed or released with the owner's signature over that one operation once the plan is decided. Its readings are coalition.observe with source: "device" whose content is {"device", "readings": [{"field", "value", "unit", "threshold", "over"}]}, and a reading that crosses its template threshold is a coalition.ripple from that seat on the needs that name the device. Before plans, and again after a change, the agents talk in a Talk stage of at most two rounds: in each round every seat's agent reads the thread and the offers and either sends one coalition.say or passes, and may then revise its own offers. A hosted seat's agent sees only public events and its own vault, and the host refuses to send a line that holds one of that seat's own private values unless the person asked it to say those words; the refusal is written down as a stop. The stage waits a bounded time for agents a person brought, then the plans follow. A message the Steward holds on a hosted seat's behalf is written down as a stop (seat, recipient, policy, explanation) and is not sent again with the same words. POST /v1/room/begin, /v1/room/brief, /v1/room/say, /v1/room/done, /v1/room/mint, /v1/room/mint/approve and /v1/room/mint/frame take the viewer bearer token and act only as that viewer's hosted member. POST /v1/room/voice (a mono 16-bit PCM WAV of at most 20 seconds) and POST /v1/room/photo (a PNG, JPEG or WebP image with question_id or purpose: "say") take the same token and only read: they return words, and for a question an answer in that question's closed kind, that the person may change and MUST confirm through /v1/room/brief or /v1/room/say before anything is saved or sent. The audio and image bytes are held only for the call and never enter the log or the store. A sentence the person said aloud and confirmed is sent with via: "voice" and its coalition.observe carries source: "voice" (source: "camera" for one read from a photo); a change said aloud is an ordinary coalition.ripple. The feed snapshot MAY carry stage, cost_meter and a per-viewer room key; these carry no authority, and values from a viewer's vault appear only in that viewer's own room.mine, including room.mine.inputs, the viewer's own recent voice and photo readings. A hosted room records its agents' model calls, comparison rounds, Steward reviews and outside agents' MCP and A2A calls as coalition.tool events (see Tool activity).
Standing room. A public host MAY keep one standing room: when none is open it creates a Coalition from a fixed goal on its own clock, optionally bound to a pack, owned by the host's own hosted seat, with its short code listed publicly. Visitors join it by that code; on such a host POST /v1/genesis/start requires a start code and POST /v1/genesis/draft the admin token, so a visitor cannot start a room. The host begins the room once people have had time to arrive, dissolves it with reason completed when its time is up (vaults erased, lessons written as for any dissolve), and then forms a new one. GET /v1/standing returns the open standing room's goal, code, seats, the time left and its model spend, and never a token. A host MAY cap the model spend of each room: past the cap the room's model calls are not sent, the rules answer in their place, and the room says so once in its activity.
Web search. A host MAY let agents search the web. Search is off unless the host names a provider. A query MUST be screened on the host before it leaves: the deterministic core policies run on it as they would on a message to the whole Coalition, and a query holding a street address or any private value a member's vault holds is refused and sends nothing. A hosted seat's query is built from public facts only (the goal and the need). An outside agent searches through the MCP tool web_search under the same screen. A result built from a search keeps the pages in data.web as {title, url, snippet}, each an http(s) URL the search itself returned, and a named place carries its url and provenance: "search"; a screen shows only http(s) links from it. The feed MAY list the searches in room.search; they hold only the screened queries.
Two sessions per viewer. A feed session carries state and surfaces and never interrupts. An agent session carries messages to that person and their consent requests. A consent request outlives any connection: it is stored on the host, the first answer recorded per decider wins, and the interrupt is only how it is shown. The person's answer is turned into an owner-signed coalition.decide; a surface never sends a decision the owner key did not sign. The feed sends a keep-alive comment every 15 seconds and rotates every 10 minutes; reconnecting starts a new run with a fresh snapshot, since AG-UI has no resume by event ID.
| Coalition event | AG-UI event | What the renderer does |
|---|---|---|
| A viewer connects | RUN_STARTED, then STATE_SNAPSHOT of this viewer's own projection, on the feed session |
Draws the fallback layout at once |
| An accepted event that changes this viewer's projection | STATE_DELTA (RFC 6902 JSON Patch) computed on the viewer's projection, never on the shared state |
Updates only the changed fields; flashes them as a ripple |
| A new or changed composition | A2UI createSurface once per surface, then updateComponents and updateDataModel, carried as ACTIVITY_SNAPSHOT on the feed session |
Replaces the fallback with the composed tree |
A consent request (coalition.tension, a plan to accept, a commitment to approve, a new charter revision) |
A consent record on the host, shown as an interrupt on the deciding person's agent session only | Shows the decision card; the answer resumes with a new run that covers every open interrupt |
| A Gardener planning step | STEP_STARTED, STEP_FINISHED on the feed session |
Shows that agents are working, and on what |
| A message from an agent to a person | TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END on the agent session |
Streams the text into the surface's message area |
| A tool call visible to this viewer | TOOL_CALL_START, TOOL_CALL_END |
Feeds the wiring overlay |
| A failure | RUN_ERROR |
Keeps the last good surface, shows the error quietly and reconnects with a new run |
The v0.1 catalog is a closed custom catalog, coalitionkit/v0_9; renderers refuse any other catalog ID. Its normative definition is spec/catalog/coalitionkit-v0_9.json: 201 components in 11 families (foundations; shell, doors and templates; conversation and calls; forms and flows; coalition and people; needs, offers, tasks and plans; places and time; privacy and consent; live and proof; mint and machines; channels, ending and memory), each with its typed props, events, states and the doors it renders on. Every prop declares a type (string, number, integer, boolean, datetime, id, enum with its values, binding, subject, scope, child, children, array, object or any), whether it is required, and may instead be a data binding {"path": ...}; numbers are bound {value, event_ref}; consent components carry a required subject {kind, id, revision} and a decision_deadline. The host validator loads this file and enforces the types; values held in a member's vault never appear in a tree. The core catalog is domain-agnostic. A Discipline Pack may add components for its own discipline as a pack catalog extension, accepted only while that pack is active; a pack component never replaces or renames a core component.
Components the protocol itself depends on, and their key props:
| Component | Shows | Key props |
|---|---|---|
| Card, Panel, Grid | One object, a group, a layout | title, fields, status, provenance, children |
| List, Table | Several objects | items or rows, columns, sort |
| Timeline, Gantt | Tasks and commitments over time | items with start, end, assignee |
| Form, ChoicePicker | Input from a person | fields or options, submit_event |
| ConsentCard, ApprovalButton | A consent request | subject (kind, id, revision), consequence, decision_deadline |
| PlanCompare | Plan universes side by side | plans, objectives, rejected |
| TensionCard | A conflict and its trade-offs | options, tradeoffs, deciders |
| Progress, Meter | A value against a goal or range | value, min, max, unit |
| MapView | Places | points, routes, areas |
| MediaWithMasks | An image or video frame with segmentation masks | media_ref, masks, labels |
| EvidenceCard | Proof that a task was done | kind, ref, verified_by |
| DecisionLog | Who decided what, and why | entries |
| WiringDrawer | The protocol message behind any element | event_ref |
Every component has an accessible label and a right-to-left layout, and declares the doors it renders on: wide, compact, message, voice, lens (glasses) and mcp-app. The host validates every tree before sending it: known components only, one root, no orphans or cycles, at most 500 components and a depth of 24, no HTML in props, and every number bound to the event it came from. Renderers show values as text only. A tree that fails validation is replaced by the fixed layout.
Discipline Pack format¶
A pack is a folder of data the loader validates before a Coalition can use it. A pack that fails validation never loads, and the loader's report says which rule it broke.
Key in pack.yaml |
Required | Meaning |
|---|---|---|
name, version |
yes | Unique name and semantic version |
core |
yes | The range of core versions it works with, such as >=0.1,<0.2 |
roles |
yes | Role IDs and what each may do: charter, offer, decide, decide_own, commit |
vocabulary |
no | Words shown on screens for core objects (task, resource, need) |
schemas |
no | JSON Schemas for the ext object of needs and offers (keys need, offer); the keys task and resource are accepted for later versions and not applied in v0.1 |
policies |
no | Steward policy files |
skills |
no | SKILL.md files loaded by agents that join |
views |
no | Component order per role and device |
mint |
no | Extra Mint templates, each with its own tests |
connectors |
no | MCP servers the pack needs, run out of process |
strategy |
no | Allocation strategy and fairness settings (see Strategy names below) |
suggest |
no | How a host may offer this pack for a goal typed in plain language: keywords (lowercase words or short phrases) and examples (whole example goals), at most 50 of each. A host that suggests packs MUST also offer "no pack", and a suggestion never binds a pack by itself |
lessons |
no | Lesson templates (Markdown with {{placeholders}} from a fixed set) the host fills from the log at Dissolve and writes as skills |
compare |
no | Named private sets every seat is asked for and the room compares by PSI (see Compared sets below) |
public |
no | Questions every seat is asked whose answers are published to the room, such as the city a person is in (see Public questions below) |
evals |
yes | At least one scripted scenario |
acceptance |
no | Relative path to the whole-journey acceptance YAML described below |
Compared sets. compare lists at most four named private sets. The room asks every seated person for each one, keeps the answers in each seat's vault and compares them by PSI, so the room learns only what the set's mode allows:
compare:
- id: colours # lowercase letters, digits and hyphens, starting with a letter; at most 32
label: Colours we all like
question:
text: "Which of these colours would you be happy with?"
kind: choice # choice, slots or text
options: [red, blue, green, yellow, white]
why: "Only the colours everyone picked are shown." # optional
mode: overlap # overlap (default): the shared values; count: only how many
min_seats: 3 # optional; default every seated person
question.kind is one of the closed brief kinds. choice needs two to twelve distinct options and lets a person pick any number of them; slots offers the room's time slots; text takes a short list separated by commas or new lines, and each value is trimmed, its spaces collapsed and lowercased before it is compared. slots and text take no options. amount is refused, because a set is matched value by value. Ids are distinct. The loader refuses any other shape and says which rule it broke.
Public questions. public lists at most two questions whose answers are not private. The room asks every seated person each one beside the agent's own questions; when a person answers, the host publishes the answer to the whole room as a coalition.observe from that person's seat with source: "chat" and the content "<label>: <answer>". A question marked search: true adds the person's answer to the words of that seat's web searches on place needs: each seat whose answer differs searches once with the need's title and its own answer only, and the hits of every seat are kept together for the need's result.
public:
- id: city # same rule as a compared set id
label: City
question: { text: "Which city are you in?", kind: text } # text, or choice with options
search: true # optional; default false
Tell people the answer is public: the brief shows the question's why line, which by default says the whole room sees the answer. A seat whose own agent acts over MCP gives its answer by writing the vault key public:<id> (a string) through POST /v1/agent/vault. A published answer is no longer private, so web search does not refuse a query for holding it.
Core policies. The Steward ships no-regulated-advice, no-contact-before-consent, no-private-field-in-outbound, fairness-cap-exceeded and no-contact-details-in-room (blocks a phone number or email address written into a message to the whole Coalition). Each has a known-answer example the Steward must pass before it starts.
A policy is checked deterministically before the Steward's model audit:
id: no-regulated-advice
applies_to: [coalition.observe, coalition.offer, coalition.propose]
when:
any_text_matches_category: [medical_dosage, legal_advice, investment_advice]
then: escalate
explain: "Coordinate the task; leave the advice to a qualified person."
An eval scripts participants, their private vault contents and the outcome. Each participant MAY name a role declared by the pack. If omitted, the default is the first declared role whose abilities include both offer and decide; if no such role exists, the loader refuses the scenario with an explanatory error:
scenario: three-people-one-booking
charter: "Find two hours next week when all three are free and book a room."
participants:
- id: p1
vault: { free: ["Tue 10-12", "Wed 14-16"], canary: "C-7Q2X" }
- id: p2
vault: { free: ["Wed 14-16", "Thu 09-11"], canary: "C-M41P" }
- id: p3
vault: { free: ["Wed 14-16"], canary: "C-0ZK9" }
expect:
decision: { slot: "Wed 14-16" }
canaries_leaked: 0
max_messages: 60
The loader rejects a pack that declares a policy action other than allow, redact, block or escalate (quarantine acts on a participant, not a message, so it stays with the core); references a component outside the catalog; asks for vault access; points at a file outside its own folder; or changes any protocol schema rather than extending it. The core runs every example and eval in a pack through its own schemas as well as the pack's, so a pack cannot pass with data the core would refuse.
A pack MAY mark an extension property with "x-private": true. Such values belong in the member’s vault and MUST NOT be included in a transmitted ext. Viewer feed pack metadata includes only the pack’s need and offer extension schemas under schemas; private annotations remain visible to forms. Referenced schemas are bundled through the same closed registry used for validation, inlining sibling files and approved core ids with their x-private annotations, without exposing pack file paths. Cyclic or unresolvable references cause the loader to refuse the pack before installation. A viewer without a bound pack receives no pack metadata.
Binding a pack to a Coalition. A host installs packs from folders it is given and validates each with the loader; a pack that fails is not installed and the host reports the loader's reasons. A Coalition uses a pack only when its charter names it in pack: {name, version}, and only an installed pack with that exact name and version. The binding is fixed for the life of the Coalition: a new charter revision may not change pack. When a charter names a pack, the host applies it as follows, and nothing else changes:
| Key | At run time |
|---|---|
roles |
The roles the charter may use and what each may do. A member's message needs an ability its role grants; otherwise not_authorized |
vocabulary |
Words delivered to surfaces with each viewer's projection; never part of any signed payload |
schemas |
A pack adds fields to needs and offers only inside their ext object. The core schema validates the payload, then the pack's extension validates ext; failing it is schema_invalid. Without a bound pack, ext must be absent. A pack schema may reference only files in its own folder and the core schemas by their https://coalitionkit.dev/schemas/ ids |
policies |
Checked deterministically at step (12) of the check order, after every core check and before the Steward: block is pack_violation with the policy id; escalate holds the message (it does not count), tells the sender it was held with the policy id, and asks the sender's person to review it, who may revise and resubmit; redact adds the fields the policy matched (for a text condition, each string field the text matched in) to the event's scope.redact before it is stored; allow changes nothing. The most severe matching action wins |
strategy |
The Gardener's allocation strategy and fairness settings for this Coalition |
task_templates |
Needs the Gardener may draft first from the charter |
views |
Component order per role and device for composed surfaces; components from the core catalog or the pack's catalog extension |
skills, lessons |
Skills are offered to every agent that joins (an MCP resource coalition://{id}/skills and the join reply). At Dissolve the host fills each lesson template from the log (placeholders: goal, members, tasks, allocation, decisions, changes, policy_outcomes, duration) and stores the results as skills, scoped no wider than the events they came from, listed in skills_written, and offered to agents that join later Coalitions bound to the same pack. A Coalition without lesson templates writes one generic lesson from the same placeholders |
mint |
Templates added to the Mint for this Coalition only; each template's tests run before a minted agent is enrolled |
compare |
Each seated person's agent asks the declared question beside its own questions, and the answer stays in that seat's vault. A seat whose own agent acts over MCP gives its values by writing the vault key compare:<id> (a list of strings) through POST /v1/agent/vault. After the offers the host compares each set with the PSI messages (query/answer, then match rounds chained by the first seat that holds values) over the elements "<id>:<value>", and publishes one coalition.observe with source: "document" whose content is {"common": 1, "set", "values", "compared"} for overlap or {"common": 1, "set", "count", "compared"} for count. The exchanges between the vaults the host keeps use reveal: overlap; in count mode only the number leaves the host. With fewer than min_seats seats holding values nothing is published. Plans and results may use only the published values |
public |
Each seated person is asked the declared question; the answer is published once as a coalition.observe from that seat (source: "chat", content "<label>: <answer>"). With search: true the answer joins that seat's web search words for place needs |
connectors |
Programs the operator runs out of process for this Coalition. A connector gets only a one-use invitation and joins like any business agent with a signed card; it never receives host settings, keys, tokens or access to the store or to vaults |
Acceptance journey. The optional acceptance key in pack.yaml is a relative path to a YAML file. coalitionkit pack accept <folder> [--live] [--json] drives an in-process host from Genesis through Dissolve. All structural objects forbid extra keys. Member roles must exist in the pack; member IDs are p1 through pN in join order. Need indices are zero-based task-template or Gardener need indices. Offers are member messages validated against core and pack schemas. Exactly one distinguishing case is specified:
goal: "<the sentence typed in Genesis>"
utc_offset_minutes: 60 # optional
members: # 2..12
- role: <role id>
vault: { <key>: <value>, canary: "<string>" }
absent: false # optional; agent answers, owner must still approve commitments
businesses: # optional; business invitation and signed card
- role: <role id>
card: { name: "<display name>" }
offers:
- member: p1
need: 0
offer: { ... }
expect:
allocation: { 0: p1 } # optional
fairness: { max_share_ratio: 2 } # optional; plan objective scores
commitments_signed_by_owner: true
agent_only_yes_refused: true
distinguishing: # exactly one of these three
minted_reading: { template: "<mint template name>", value: "<value>", changes_needs: [0] }
# absent_answered: { member: p3, answer: yes }
# business_holds: { business: 0, need: 0 }
change:
by: p2
invalidates: [0]
affected: [0]
untouched: [1]
policy_probe:
by: p1
type: coalition.offer # or coalition.observe
payload: { ... }
outcome: block # block | escalate | redact | allow
policy_id: <id>
dissolve:
reason: completed
lessons: 1 # minimum number of skills_written
canaries_leaked: 0
max_messages: 200
Each assertion reports a named pass/fail step and reason. Canary scanning covers the log, every viewer's projection and replay, Steward inputs, agent channels and connector exchanges. Live member agents use the gateway and report calls, tokens, latency and cost; --live without COALITIONKIT_META_API_KEY reports not run (no key).
Strategy names. strategy.allocation names one of the host's built-in strategies, and strategy.fairness may set only the keys that strategy reads:
| Name | What it does | Fairness keys |
|---|---|---|
auction |
Each task goes to the offer with the lowest cost plus a fairness weight times the bidder's share after winning (Contract-Net) | weight (0 to 10), max_ratio (hours per share unit, greater than 0), minute_value (whole number of at least 1, the value of a minute against money) |
match |
Reserved for a later version; not usable by packs in v0.1 (the host cannot read PSI overlaps) | none |
vote |
Reserved for a later version; not usable by packs in v0.1 (the host has no ballots) | none |
Any other name, or a fairness key the named strategy does not read, is refused when the pack is installed.
A pack never widens a scope, never reads a vault, never changes a core schema, and never sees another Coalition. The kit contains no pack: packs are data the host is given at run time.
Conformance tests¶
An implementation claims conformance at one of four levels by passing every test at that level with the published runner: coalitionkit conform --target <card-url> --level member.
| ID | Test | Level |
|---|---|---|
| C-01 | Card declares the extension and carries a valid signature | Member |
| C-02 | Joins with an owner key and proof, receives the charter and acknowledges it | Member |
| C-03 | Every sent envelope validates against its schema and verifies; every consent_sig verifies against the owner key and fails against the agent key |
Member |
| C-04 | Never addresses anyone outside the scope it was given | Member |
| C-05 | Sends no field a payload schema does not define | Member |
| C-06 | Answers queries only with yes, no or a cost, before the deadline | Vault |
| C-07 | Completes a PSI exchange that reveals only the overlap, reproducing spec/vectors/psi.json, and refuses count from a requester the charter does not name |
Vault |
| C-08 | Leaks none of 20 planted canaries under scripted extraction attempts | Vault |
| C-09 | Assigns gap-free seq and a valid hash chain from the genesis hash under concurrent senders; every acknowledgement carries a verifying receipt; an edited tail entry and a truncated tail are each caught by the receipts a member kept |
Host |
| C-10 | Rejects out-of-stage messages with stage_mismatch. Planning starts at the first host query, propose or tension naming an open task. A pending commitment can be withdrawn while its task is decided, which sends the task to negotiating and closes its consent request. Two-task scenario: while task A is back in negotiating (a refusal or ripple), answer, match and offer about A are accepted and done about A is stage_mismatch; at the same time task B is committed, so done about B is accepted and answer about B is stage_mismatch |
Host |
| C-11 | Delivers nothing outside its scope, checked by an observing member; delivers a host-signed projection, never the sender's JWS, whenever a field is redacted for the recipient, and the original otherwise; a replay mixing originals and projections verifies the chain and the kept receipts through the projections' prev_hash and hash; an acknowledgement and its duplicate leak no redacted field and give a sender outside the scope only the receipt |
Host |
| C-12 | Calls the Steward before delivering proposals and honors a block | Host |
| C-13 | Returns the original acknowledgement for a duplicate event_id from the same sender with the same digest, and duplicate_mismatch (with an empty detail) for another sender or other bytes; the duplicate acknowledgement has the same scope-filtered form as the first |
Host |
| C-14 | Refuses an envelope whose sender differs from the authenticated connection |
Host |
| C-15 | Records the first answer per (subject kind, id, revision, decider) and refuses a stale revision with stale_subject; with two deciders answering concurrently it records both and decides only after the second consent; a refusal sends the tasks to negotiating; two deciders consenting to different tension options decide nothing and send the tasks to negotiating; a decision after decision_deadline is deadline_exceeded, and an undecided subject at its deadline returns its tasks to negotiating with no commitment; a replan supersedes the task's active commitment and needs a new commitment and a fresh owner approval; a second answer from the same decider is not recorded (not_authorized); keeps a consent open across a disconnect; refuses a decision whose consent_sig the pinned owner key does not verify |
Host |
| C-16 | Gives every derived output the narrowest scope of its sources: the intersection of the resolved principal sets at derivation time, roles resolved to the members holding them then, self resolved to the source sender's owner, an empty intersection becoming host, any unprojected source with a redaction list making the output host, projected sources contributing none; reproduces spec/vectors/scope.json |
Host |
| C-17 | Renders every catalog component from AG-UI snapshots and deltas | Surface |
| C-18 | Shows a consent request and records only the person's own answer | Surface |
| C-19 | Shows nothing outside the viewer's scope, from the first snapshot on | Surface |
| C-20 | Applies the authorization matrix: each row of Target authorization is rejected with not_authorized (or not_a_decider) when the sender is the wrong party or the object is not its own, even where the scope allows it; host-only types are rejected from members; a first-time join from an invited participant is accepted (not unknown_participant); a match round for another task_id, or for a task not negotiating, is rejected; a commit reusing a commitment_id is not_authorized |
Host |
| C-21 | Counts a member's offers and commitments only after it accepted the current charter revision and suspends its open commitments on a new revision | Host |
The runner prints a signed report that implementers may publish with their release.