mcp server
a2h-gateway
Self-hosted gateway for A2H (Agent-to-Human protocol): agents ask people for approval over email, Telegram or push, and get a signed answer with passkey proof. Go, JSON Schemas, conformance suite.
Set up
Runs on your computer. Pick your agent and operating system.
Before you start
- Docker Desktop
{
"mcpServers": {
"a2h-gateway": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"A2H_GATEWAY",
"-e",
"A2H_API_KEY",
"-e",
"A2H_TO",
"-e",
"A2H_PRINCIPAL",
"ghcr.io/andreaacanfora/a2h-gateway:0.2.1"
],
"env": {
"A2H_GATEWAY": "<your A2H_GATEWAY>",
"A2H_API_KEY": "<your A2H_API_KEY>",
"A2H_TO": "<your A2H_TO>"
}
}
}
}same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12
Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs
README
synced Nov 8, 2026a2h-gateway
A self-hosted gateway for A2H, the Agent-to-Human protocol: AI agents and scripts ask a person for approval or information, the person answers from email, Telegram or a push notification, and the agent gets a signed answer with proof of who decided. A single Go binary with SQLite.
$ a2h ask -to mailto:me@example.com -why "Rolls back if health checks fail." "Deploy v2.3 to production?"
Waiting for an answer from mailto:me@example.com (interaction 01a1bbcf-…)…
Approved (email_link.v1) at 2026-11-08T15:37:50Z.
$ echo $?
0
A2H was published by Twilio Labs in February 2026 with a specification, an OpenAPI schema and a proof-of-concept demo. Its roadmap lists a reference gateway and formal JSON Schemas as next steps. This project is an independent implementation of both, not affiliated with Twilio.
What it does
- The whole agent-facing API of A2H 1.0: discovery (
/.well-known/a2h),POST /v1/intent, status, cancel, nonces, JWKS. - All intents: AUTHORIZE (approve or decline), COLLECT (forms with text, choices, numbers and dates, validated), INFORM, RESULT, ESCALATE.
- Channels: email (SMTP), Telegram bot, push via ntfy
(self-hosted or ntfy.sh), a phone app paired with a QR code (
app), plus a console channel for development. Retries with the spec's backoff, and fallback channels. - Proof of decision:
- every RESPONSE is signed (ES256 detached JWS over JCS, keys in the JWKS);
- requests with
assurance.level: HIGHmust be approved with a passkey (WebAuthn), with the challenge bound to the interaction as section 1.11.4 requires.
- Replay protection:
- idempotency on
message_id; - timestamp window and single-use nonces;
- single-use, unguessable links that expire with the interaction.
- idempotency on
- Webhooks signed with HMAC (
X-A2H-Signature), retried 10 s / 60 s / 5 min, and polling as fallback. - JSON Schemas for every A2H message, in
schemas/. - MCP server (
a2h mcp): agents in Claude, Cursor and other MCP clients ask you for approval as a tool. - Conformance suite to test any A2H gateway:
a2h conform.
Quick start
Download a binary for Linux, macOS, Windows or FreeBSD from the
releases, use the
Docker image ghcr.io/andreaacanfora/a2h-gateway, or install with Go:
go install github.com/AndreaAcanfora/a2h-gateway/cmd/a2h@latest
# Development: no authentication, links printed in the log.
A2H_BASE_URL=http://localhost:8080 A2H_DEV=1 a2h serve
In another terminal:
A2H_GATEWAY=http://localhost:8080 a2h ask -to console:me "Delete 3 unused files?"
The gateway logs a link (url=http://localhost:8080/i/…): open it,
approve, and a2h ask exits with 0.
In production
docker run -d -p 8080:8080 -v a2h-data:/data \
-e A2H_BASE_URL=https://a2h.example.com \
-e A2H_API_KEYS="deploy-bot:$(openssl rand -hex 24)" \
-e SMTP_HOST=smtp.example.com -e SMTP_USERNAME=a2h@example.com -e SMTP_PASSWORD=… \
-e TELEGRAM_BOT_TOKEN=123456:ABC… \
-e A2H_PUSH=1 \
ghcr.io/andreaacanfora/a2h-gateway
Put it behind HTTPS: people open its links, and passkeys only work on a secure origin.
Telegram: create a bot with @BotFather, then
send your bot any message so that it may write to you. Your chat ID is in
https://api.telegram.org/bot<token>/getUpdates (message.chat.id).
Messages get an action button when A2H_BASE_URL is a public host name;
with localhost the link is in the text, since Telegram refuses such
buttons.
| Variable | Meaning |
|---|---|
A2H_BASE_URL | Public URL people open (required) |
A2H_LISTEN | Listen address, default :8080 |
A2H_DATA_DIR | Database, signing key and link secret, default ./a2h-data |
A2H_API_KEYS | name:key pairs, comma-separated; agents send X-A2H-API-Key |
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM | Email channel (STARTTLS or TLS) |
TELEGRAM_BOT_TOKEN | Telegram channel; addresses are tg:<chat_id> (see below) |
A2H_PUSH, NTFY_TOKEN | Push channel (ntfy); addresses are topic URLs |
A2H_CONSOLE | Console channel (links in the log) |
A2H_DEV | Development mode: no auth, http:// webhooks, console. Never expose it. |
Asking from an agent
Any HTTP client works:
curl -s https://a2h.example.com/v1/intent -H "X-A2H-API-Key: $KEY" -H 'content-type: application/json' -d '{
"type": "AUTHORIZE",
"message_id": "'$(uuidgen)'",
"agent_id": "did:web:booking-agent.example.com",
"principal_id": "usr_alice",
"channel": { "type": "email", "address": "mailto:alice@example.com",
"fallback": [{ "type": "push", "address": "https://ntfy.sh/alice-a2h-7f3k" }] },
"render": { "title": "Book flight?", "body": "Book the non-refundable flight to Lisbon for 412 EUR?" },
"explanation_bundle": { "why": "The fare expires in 30 minutes." },
"ttl_sec": 1800,
"callback": { "url": "https://agent.example.com/a2h/webhook", "secret": "whsec_…" }
}'
Then wait for the webhook, or poll GET /v1/status/{interaction_id} until
the state is ANSWERED, EXPIRED, CANCELLED or FAILED.
From Go, the a2h package does all of this and verifies the answer:
c := a2h.NewClient("https://a2h.example.com", key)
sent, _ := c.Send(ctx, msg)
status, _ := c.Wait(ctx, sent.InteractionID, 2*time.Second)
resp, err := c.VerifyResponse(ctx, status.Response, msg.MessageID) // checks responds_to and the JWS
if err == nil && resp.Decision == a2h.Approve { /* go ahead */ }
Use it from AI agents (MCP)
a2h mcp is a Model Context Protocol
server: Claude Code, Claude Desktop, Cursor and other MCP clients get tools
to ask you before acting.
| Tool | What it does |
|---|---|
request_approval | Ask you to approve or decline an action; returns the verified decision |
ask_human | Ask you to fill in a short form (text, numbers, dates, choices, yes/no) |
notify_human | Send you a message (progress, result, alert) |
check_request | Follow up on a request that is still waiting |
cancel_request | Withdraw a request that has not been answered |
It is listed in the official MCP Registry
as io.github.AndreaAcanfora/a2h-gateway, so MCP clients that browse the
registry can install it; or configure it by hand.
Claude Code:
claude mcp add a2h \
-e A2H_GATEWAY=https://a2h.example.com -e A2H_API_KEY=… -e A2H_TO=tg:123456789 \
-- a2h mcp
Claude Desktop (claude_desktop_config.json), Cursor and others:
{
"mcpServers": {
"a2h": {
"command": "a2h",
"args": ["mcp"],
"env": { "A2H_GATEWAY": "https://a2h.example.com", "A2H_API_KEY": "…", "A2H_TO": "mailto:me@example.com" }
}
}
}
Then tell the agent when to ask, e.g. "ask me with request_approval before deleting anything or spending money".
- The recipient is configuration. The model can write the question but cannot choose whom it goes to, so an agent cannot message other people.
- Answers are verified against the gateway's signature before the tool reports a decision. An answer that fails verification is reported as a failure, never as an approval.
- Long waits: a tool call waits up to 10 minutes (
-max-wait) and sends progress notifications meanwhile. If the person has not answered by then, the tool says so and the agent continues withcheck_request. - High stakes:
require_passkey: truemakes the approval need a passkey.
Passkeys for high-stakes approvals
Requests with "assurance": {"level": "HIGH"} (or required_factors
containing passkey.webauthn.v1) cannot be approved with a click: the
person must confirm with a passkey (Face ID, Touch ID, Windows Hello, a
security key). Only the operator can register passkeys, from the server:
a2h enroll -principal usr_alice
# Send this link to usr_alice. It works once and expires in 24h0m0s:
# https://a2h.example.com/enroll/…
The RESPONSE then carries the WebAuthn assertion, the challenge nonce and the public key, so an agent can verify the person's signature itself.
Answer from your phone
Pair a phone app such as nod and requests arrive there: approve or decline with your fingerprint or face, fill in forms, see what you decided. The app signs each answer with a key that never leaves the phone, and the signature travels in the RESPONSE.
a2h pair -principal usr_alice # shows a QR code to scan in the app
a2h ask -to app:usr_alice "Deploy v2.3 to production?"
The push that wakes the app carries no content (it goes through UnifiedPush, for example ntfy, with no Google services needed). docs/app-api.md describes the API, for anyone writing another app.
Conformance
a2h conform -gateway https://gateway.example.com -key $KEY -to mailto:a2h-test@example.com
PASS MUST §1.4 discovery document
PASS MUST §1.3 RESPONSE type from an agent is rejected
PASS MUST §1.5 unsupported profile is rejected
PASS MUST §1.10.2 duplicate message_id returns the original interaction
PASS MUST §1.7 cancel a waiting interaction
…
conforming to the agent-facing API of A2H 1.0
The suite sends real intents to the address you give, then cancels them.
Where the spec was unclear
A2H 1.0 leaves some details open or describes them two ways. The choices made here are listed in docs/implementation-notes.md, as feedback for the next version of the spec.
Development
go test -race ./... # unit and integration tests
go build -o e2e/a2h ./cmd/a2h && (cd e2e && npm install && npm test) # passkeys in Chromium
License
MIT. A2H is © Twilio Labs, under the MIT License.