MCP connects agents to tools and A2A connects agents to agents. Nothing defines how an agent finds the right human, does several tool calls all-or-nothing, or proves exactly what a human approved. Here is a draft spec that tries.
Picture an agent booking a business trip. It holds a flight seat, reserves a hotel, adds a calendar event, and charges the corporate card. Finance has to approve anything over $500.
Today that is four separate tool calls and a Slack message. If the hotel is sold out after the card is charged, or the approver says no after the seat is booked, you clean it up by hand. And when the approver clicks "Approve", nothing records which amount, recipient, or card they agreed to.
I've been writing a draft protocol for this gap. It's called HATP (Human-Agent Transaction Protocol). This post covers the core idea, the parts of the spec, and what works today.
The gap
| Protocol | What it connects |
|---|---|
| MCP | agent → tools |
| A2A | agent → agent |
| AP2 | agent → payments |
| IETF HEM / CHEQ drafts, Twilio A2H | agent → a known human, with no role-based routing or transaction binding |
None of these define:
- how to find the right human by role, skill, spending authority, workload and availability;
- how to run several tool calls on different servers as one all-or-nothing action; or
- how to make a human's approval part of that action, with a signature that covers the exact arguments.
The core idea: a human approval is a vote in a two-phase commit
Databases solved "all or nothing across several systems" decades ago with two-phase commit (2PC). Phase 1: each participant prepares and votes yes or no. Phase 2: if all voted yes, everyone commits; otherwise everyone aborts.
HATP treats the human approver as one more participant. Their "Approve" is a yes vote, signed over a SHA-256 digest of the whole plan. If they say no, or don't answer by the deadline, the transaction aborts and the holds are released. No effect is left behind.
Initiator Coordinator Airline Hotel Calendar Payments HumanGW Bob
│ begin ─▶│ │ │ │ │ │ │
│ │─prepare───────▶│ hold seat (lease 20m) │ │ │
│ │─prepare────────────────▶│ hold room │ │ │
│ │─prepare─────────────────────────▶│ validate │ │ │
│ │─prepare────────────────────────────────────▶│ validate │ │
│ │─prepare(finance)───────────────────────────────────────▶│─ask──▶│
│ │◀── yes ×4 ─────────────────────────────────────────────│◀approve (passkey)
│ │◀── yes + signed approval────────────────────────────────│
│ │ log COMMIT
│ │─commit calendar → charge card → commit holds
│◀ record │ COMMITTED
Honest guarantees: participant tiers
Real tools can't all do proper 2PC. So each operation declares what it can actually guarantee:
| Tier | Prepare | Commit | Example |
|---|---|---|---|
hold |
Places a hold with a lease | Converts the hold; must succeed while the lease is valid | Seat hold, card authorization |
compensate |
Dry run only | Does the effect and returns an undo token | Calendar event, CRM update |
final |
Dry run only | Irreversible | Card capture, sending an email, a wire |
A plan may contain at most one final step. The coordinator commits in a fixed order: compensate steps first, then the single final step, then the holds. Running the one irreversible step while every hold is still guaranteed is the old XA "last resource commit" trick. If anything fails before the holds commit, the only effects left are ones that can be undone.
Finding the right human
HATP adds addresses for people and agents:
human://acme.com/bob a person
human://acme.com/role/finance-approver?quorum=2-of-3 any 2 people holding a role
human://acme.com/pool/security-leads?skill=sox a group, filtered by skill
agent://acme.com/travel-bot an agent
The Human Card
A2A gives every agent an Agent Card that says what it can do. HATP gives every person a Human Card. Here's a shortened example (the full schema is in the spec):
{
"id": "human://acme.com/bob",
"displayName": "Bob Chen",
"roles": ["finance-approver"],
"skills": ["travel-policy", "sox-controls"],
"authority": [
{ "action": "approve.spend", "maxAmount": { "value": 10000, "currency": "USD" } }
],
"channels": ["slack", "sms", "voice"],
"availability": { "timezone": "America/New_York", "status": "available" },
"sla": { "decideSeconds": 900 },
"capacity": { "maxOpen": 10, "open": 2 },
"delegates": ["human://acme.com/carol"],
"issuer": "https://directory.acme.com",
"signature": { "alg": "Ed25519", "kid": "acme-directory-1", "sig": "…" }
}
What each part is for:
-
rolesandskillslet an agent ask for "a finance approver who knows SOX" instead of hard-coding a name. -
authoritysays what this person may approve, for example spend up to $10,000. A $25,000 request is never routed to Bob. -
availability,slaandcapacitylet routing prefer someone who is online, not overloaded, and likely to answer before the deadline. -
delegatessays who takes over if Bob doesn't decide within the SLA. -
channelslists channel types only. Bob's phone number and Slack ID stay inside the Human Gateway; agents never see them. -
signature: the company's directory signs every card, so an agent can't invent an approver or give someone more authority.
The card guides routing, but it isn't the security boundary. The Human Gateway checks authority again against company policy when the request arrives.
Routing
A Directory resolves an address to ranked candidates using these cards. The Human Gateway then delivers the request and enforces separation of duties: the person the agent acts for can't approve their own spend. It escalates to a delegate when someone misses their SLA, and records the whole routing chain in the approval, so auditors see who was asked as well as who decided.
Evidence anyone can verify
- Every vote, receipt and approval is signed with Ed25519 over its canonical JSON (RFC 8785), and each one includes the plan digest.
- An approval records who decided, how they authenticated (the spec recommends WebAuthn/passkeys; the demo simulates the click), and who was asked before them.
- The coordinator emits a hash-chained Transaction Record. Anyone with the public keys can check offline that every party agreed to the same plan and that no event was removed or reordered.
Gateways must render the approval screen from the structured plan, not from text the agent wrote, and label agent-written text as such. That shuts off the most obvious prompt-injection path: an agent describing a $10,000 wire as "a small refund".
Designed as an MCP extension
An existing MCP server becomes a participant by advertising one capability and implementing a few extra methods:
{ "capabilities": { "experimental": { "hatp": {
"version": "0.1",
"roles": ["participant"],
"operations": { "book_seat": { "tier": "hold", "maxLeaseSeconds": 1800 } }
} } } }
Methods: hatp/directory/resolve, hatp/txn/{begin,prepare,quote,commit,abort,compensate,renew,status,voted}, and hatp/lock/{acquire,renew,release} with fencing tokens.
Status: what works today
- Spec v0.1 (draft): Discovery, Transactions, Evidence.
- JSON Schema for all the core types.
- TypeScript reference SDK: coordinator with a durable decision log, participant wrapper, Directory, Human Gateway, signing and offline record verification. It currently runs in a single process; the JSON-RPC/MCP transport is not wired up yet.
- Tests cover all-yes commit, commit ordering, a no vote releasing every hold, human rejection, SLA escalation to a delegate, separation of duties, 2-of-N quorum, rollback when the irreversible step fails, approval-first mode catching a price jump at hold time, lease renewal before commit, record tampering, and unsigned-card rejection.
- Not yet implemented: locks, deferred (pending) votes, and continuous lease renewal while waiting for humans.
Next (v0.2): asynchronous asks outside transactions, and SIP-style live calls where any human or agent can dial any other (voice with humans, a structured data channel between agents), including turning a spoken "yes" into a passkey-confirmed approval.
I'd like feedback on
- Is "approval as a 2PC vote" the right model? The spec also has an
approval-firstmode: humans approve quoted prices before any holds are placed, and the transaction aborts if a held price moves beyond a set tolerance. Should that be the default instead? - Would you implement
hold/compensate/finalin your MCP server? - How should a Human Gateway in one company trust a coordinator in another?
Comments and issues welcome.
probita-ai
/
hatp
HATP: Human-Agent Transaction Protocol. Agents find the right human, run multi-tool actions all-or-nothing, and treat approvals as signed votes.
HATP — Human-Agent Transaction Protocol
An open protocol that lets AI agents find the right human, run multi-step actions as one all-or-nothing transaction across tool servers, and make a human approval a signed vote in that transaction.
"Book the flight and hotel, add the calendar entry, charge the card — but only if a finance approver agrees within 15 minutes. Otherwise, nothing happens."
| Path | What |
|---|---|
| spec/hatp-v0.1.md | Protocol spec v0.1: Discovery, Transactions, Evidence, MCP binding |
| spec/schemas/hatp.schema.json | JSON Schema for all core types |
| sdk/ | TypeScript reference SDK, hatp-protocol on npm (zero runtime dependencies, Node ≥ 23.6) |
| mcp/ | MCP binding, hatp-mcp: participants and Human Gateways as MCP servers |
| demo/ | Trip-booking demos: a browser approver, and tool servers over MCP |
Human Cards
Agents find people the way they find other agents. A2A publishes an Agent Card for each agent; HATP publishes a Human Card for each person who can make decisions. The…

Top comments (4)
Modeling the human approval as a signed vote over a digest of the exact plan is the part most "human-in-the-loop" designs skip, and it's the part that actually matters. The common failure mode isn't that nobody approved — it's that someone approved a thing and the agent then executed a slightly different thing, because the approval was bound to a Slack message, not to the arguments. Binding the signature to the plan hash closes that gap cleanly.
Where I'd expect the hard part to live is the "prepare" phase holding real resources. 2PC's classic weakness is the coordinator dying after prepare, leaving participants holding locks — and a held flight seat or a card authorization has a TTL you don't control. So the approver deadline has to be shorter than the shortest participant hold, and you need a defined behavior when a hold expires mid-vote. Does HATP let a participant signal its own prepare-TTL back to the coordinator so the human deadline can be computed from the tightest one, rather than set as a flat timeout?
Good question. The answer is partly. A participant's prepare vote returns its real leaseExpiresAt, which it can cap below what was asked for. Holds can be renewed up to a limit the participant sets. Before committing, the coordinator checks that every lease still has enough time left, and renews or aborts if not. If a hold expires while the human is still deciding, the participant releases it and the transaction aborts before anything runs. There's also an approval-first mode that holds nothing while the human decides: the human approves quotes, and the holds are placed afterwards.
The gap you found is real, though. In v0.1 the human deadline (decideBy) is a flat value. It isn't worked out from the shortest hold. For v0.2 I plan to:
add a holdHorizon to the prepare vote: the latest time the participant will hold, renewals included
set the human deadline from the shortest horizon, minus time for the commit phase and a safety margin (it can only get shorter than what was signed)
tell the approver early when a hold dies, instead of only noticing at commit time
I'd choose the mode per transaction from the clock rather than make either one the default. A hold tier guarantee lasts only as long as its lease, and the human's decision time comes out of that lease. In your example Bob's card has decideSeconds 900, and if Carol's SLA is the same, escalation after a miss puts the worst case near 30 minutes before anyone commits. The 20 minute seat lease is gone by then. Renewal before commit helps only if the airline grants it, and because card capture runs before the holds commit, a seat that can't be confirmed leaves you refunding a step the plan declared final.
So the coordinator could add up the routing chain's worst case, each candidate's SLA in escalation order, and compare it with the shortest lease minus the time commit needs. If it fits, run 2PC. If it doesn't, switch to approval-first or don't place the holds yet. I'd also put each lease's expiry inside the signed plan digest, so the approval records the window it was given, and a commit after that window fails verification instead of running.
Agreed: pick the mode per transaction based on the clock. Adding up the escalation chain's worst-case SLAs is a sharper test than what I had in mind, so I'll put that in v0.2. If it fits inside the shortest lease minus the commit time, run 2PC; if not, use approval-first.
One correction: the card capture can't run against an expired seat hold. Before any commit step, the coordinator checks every lease and renews any that are running short. If the airline refuses, the transaction aborts before the capture, so there's nothing to refund.
On signing the lease expiry: in parallel mode the human signs before the holds come back, so their expiry times aren't known yet. In approval-first, the quote's validUntil is already signed. Your idea points to a third mode, though: place the holds first, then have the human sign a digest that includes their expiry times. I'll add that too.