Skip to content

telegraphic-dev/a2a-exposed

v0.7.0MIT

Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox, wake webhooks, peer pairing, or a public façade for a private A2A agent.

Changelog

All notable changes to the a2a-exposed CLI (called a2a-over-webhook up to 0.3.x), Worker template, and skills. Versions follow semver; a v* tag publishes the CLI to npm (see the README's Releases).

Unreleased

  • Control plane sign-in from the CLI. npx -y a2a-exposed@latest login --control-url https://control.example (and signup, the same flow for a first account) starts a device-code sign-in. The page is /app/device. Approval requires typing the code from that terminal; opening the link is not enough. The session is saved as CONTROL_TOKEN and is not printed. Login has to be on; otherwise the command stops. Apply D1 migration 0004_device.
  • Control plane sign-in. GitHub, Google, and Cloudflare sign-in follow the provider URL in the sign-in response, including when that response is JSON and has no Location header. A Turnstile rejection on that sign-in returns to /app?error=turnstile. /app shows a message for auth, turnstile, unavailable, invite, and email. An OAuth callback that cannot be completed redirects to /app?error=auth. A provider token request that does not return cannot hold that callback open.

0.7.0 - 2026-10-10

Upgrade: npx -y a2a-exposed@latest deploy (no new D1 migration). Leave WAKE_TARGET_POLICY unset and a self-hosted inbox still posts to the configured webhook. The control plane in control/ is not in the npm package. Deploy that directory with cf and apply its D1 migrations (0001_auth, 0002_tenants, 0003_oidc) with cf d1 migrations apply. With login unset it serves a neutral page.

  • Wake targets. WAKE_TARGET_POLICY=public-https refuses a wake that is not https, that carries credentials, or whose host is private, loopback, link-local, CGNAT, or a metadata address. A hostname is checked over DNS-over-HTTPS; if that check cannot be completed, the wake is not sent. The connection is made to an address from that check, with TLS for the original hostname, so a later DNS answer cannot point the request at a private address. A redirect is followed only when it stays on the same origin and passes the same check. A cross-origin redirect is not followed, so wake credentials are not sent to another host. A forbidden target is never requested. Leave the setting unset and a self-hosted inbox still posts to whatever WAKE_WEBHOOK_URL the operator configured.
  • Control plane assets. Every request runs the Worker (runWorkerFirst), so Accept: text/markdown can return the Markdown twin instead of the HTML asset. That response, and the HTML asset for the same URL, send Vary: Accept. text/markdown;q=0 is not a request for Markdown. A prerender with SITE_URL unset deletes a sitemap this script wrote earlier, and leaves a sitemap supplied by the overlay.
  • Control plane issuer. When login is on, /.well-known/openid-configuration publishes this control plane as an OpenID Connect issuer (authorization code, PKCE S256, RS256). Creating an agent registers a confidential client for that agent's /device/oidc/callback and /oauth/authorize/oidc/callback and pushes approval (issuer, client id, client secret, and the owner's account id as the allowed subject). The API response and the name directory do not include the client secret. A browser that is not signed in is sent to /app and brought back. With login unset, discovery stays 404. When ISSUER is set, login uses that origin even if SITE_URL names another host; a request to the other host does not sign in or publish discovery. The token endpoint refuses a form larger than 4 KiB before it parses the body.
  • Control plane skeleton. control/ is a Hono Worker plus Workers static assets, built and deployed with cf. With no configuration it serves a neutral page ("Inbox: sign in / create an agent") and tells the operator that no login provider is configured. /app and /api are noindex; robots.txt disallows only those two paths. Brand components, Markdown pages, and public/ files are an overlay (control/OVERLAY.md). The npm package does not include control/. No login, billing, or tenant API in this change.
  • Control plane tenants. POST /api/v1/tenants creates an agent for the signed-in account when the data-plane Durable Object and name directory are bound. The body must be application/json. A browser Origin or Sec-Fetch-Site from another host is rejected, so a page on a tenant host cannot create an agent with the session cookie. The owner token is returned once. The same database batch writes the tenant and an outbox row; the push updates tenant:<name> and calls pushConfig. A push the object already has is ignored. A failed push stays in the outbox and the five-minute schedule retries it. With those bindings unset the route is 404, and limits stay empty.
  • Control plane login. Better Auth on D1 at /api/auth/*, off until AUTH_SECRET, D1, and a provider are set. Providers are GitHub, Google, Cloudflare (user-details.read only), and email via the send_email binding. Accounts link only when the provider marks the email verified, so Cloudflare does not auto-link. The session cookie is host-only __Host-a2a_session. INVITES_REQUIRED=1 requires an invite code for the first account. An email invite is stored against the magic-link token hash, so the link works in another browser. Turnstile is optional.

0.6.0 - 2026-10-10

Upgrade: npx -y a2a-exposed@latest deploy (applies D1 migration 0008_oidc_txns: short-lived OpenID Connect sign-in state). A deployment that set --cli-command / WAKE_CLI_COMMAND keeps its own command. Leave the new approval settings unset and /device stays the password page.

  • Tenant context, no change for self-host. Handlers take a TenantContext built from the Worker bindings instead of reading env.* themselves. With the new settings unset, owner checks and outbound peer-token encryption are the same operations as before (existing peers sync ciphertext still decrypts; the owner API still uses OWNER_TOKEN). Optional gates (TENANCY, TENANT_SECRETS_KEY, TENANT_DOMAIN, TENANT_DIRECTORY, TENANT_DO, DATA_REGION, QUOTAS, USAGE_SINK, WAKE_TARGET_POLICY, SIGNUP_URL, BRANDING, APPROVAL_OIDC_*, APPROVAL_METHODS) are empty by default. TENANCY=host without a TENANT_DO binding still uses env.DB and OWNER_TOKEN. A hosted tenant context (used when per-tenant storage is on) checks a SHA-256 owner-token hash in constant time and derives the peer-token key from TENANT_SECRETS_KEY plus the tenant id. Quotas, usage, wake-target policy, branding and the signup link are parsed and reserved, not enforced. Approval OIDC stays off until its issuer, client id, client secret and allowlist are all set (below). init does not set any of this.

  • Per-tenant SQLite, only when hosted. With TENANCY=host the Worker entry is src/hosted.ts, which binds TENANT_DO (one SQLite Durable Object per tenant) and TENANT_DIRECTORY (KV). <name>.<TENANT_DOMAIN> is resolved from the directory; unknown names are 404 before an object is created, and an object with no pushed config is 404 and writes nothing. Config arrives through pushConfig (a stale or reordered version is ignored), then the same inbox code runs against that object's database. The first time a directory entry has no region, DATA_REGION (eu, fedramp, or default) is written onto that entry; later requests use the stored region, so changing DATA_REGION does not move a tenant. Leave TENANCY unset and the build is the previous single-tenant Worker on D1: same entry, no Durable Object export, same cron flush. STORAGE_BACKEND=d1 and STORAGE_BACKEND=do run the existing Worker tests against those backends. Usage push is not in this change.

  • Hosted wake flush is a per-tenant alarm. With TENANCY=host, each Durable Object runs the same flush and housekeeping as the minute cron (debounced wakes, old rate rows, wake budget, pairing rows, expired OAuth codes, expired OpenID Connect sign-in rows) on its own alarm, and schedules the next one for the earliest row that still needs it. The Worker minute cron stays a no-op in that mode, so there is no shared database to flush. A 429 or 503 wake is requeued after the response; the alarm is set in that same insert, so a webhook that answers after the response still lands on Retry-After rather than the rate-row timer. A pending wake whose debounce is longer than the in-request wait (about 25 seconds) arms the same way when that row is written, so a quiet tenant is flushed at the debounce deadline. Self-host is unchanged: A2A_ENABLE_CRON=1 still flushes env.DB every minute, and requests still flush opportunistically. The daily snapshot cron, when the backup bucket is bound, writes the snapshot and returns before that flush. No D1 migration.

  • Inbox export and optional daily snapshots. npx -y a2a-exposed@latest export prints a JSON dump (GET /owner/export); export --sql prints the same tables as SQL. The dump is the application tables, read in one transaction so a concurrent write cannot tear a task away from its history (a D1 batch; a hosted tenant uses the Durable Object's transactionSync). Peer-token hashes are included. Outbound peer tokens are ciphertext under the owner token (self-host) or the tenant secrets key (hosted), and that secret is not in the file. The dump includes oidc_txns. A text value that contains a NUL byte is written with char(0), so the SQL script does not contain a raw NUL. The owner token stays a Worker secret. d1_migrations and the hosted tenant_config row are omitted. Putting a saved dump back into an inbox is a follow-up and is not in this change. On a hosted tenant the same SQL is exportSql() on the Durable Object. bookmarkForTime reads a bookmark when the storage runtime provides one. restoreBookmark schedules that bookmark and returns { ok: true }. The Durable Object resets on a later turn (ctx.abort() only after that result is delivered), so the next session serves the restored database. The reset does not fail the call. A2A_BACKUP_BUCKET=1 adds an R2 binding BACKUP_BUCKET and a daily cron (0 3 * * *) that writes tenants/<id>/<YYYY-MM-DD>.sql and deletes dated objects 30 or more days old. A hosted run lists every directory page; a listing that stops early fails instead of skipping the remaining tenants. Unset: no binding and no extra cron. That daily cron returns before the minute flush. Export adds no D1 migration.

  • Optional OpenID Connect approval. With APPROVAL_OIDC_ISSUER (https), APPROVAL_OIDC_CLIENT_ID, the Worker secret APPROVAL_OIDC_CLIENT_SECRET and APPROVAL_OIDC_ALLOWED_SUBJECTS all set, /device and /oauth/authorize add an identity-provider button beside the password. The start is authorization code + PKCE (S256) and requests the openid and email scopes so the provider returns email and email_verified; state and nonce are stored with the user code and the CSRF token that passed the form check, and are not put in the identity provider's URL as the user code. The callback checks issuer, audience, nonce, expiry and the allowlist (RS256 only), then approves the same way a password does. A sub match is enough. An email match also needs email_verified true. A token with more than one audience, or any azp claim, must set azp to this client id. Deny still needs no login. Unset settings leave both pages as they are today. APPROVAL_METHODS=password keeps the password even when an issuer is set. APPROVAL_METHODS=oidc hides the password only once that config is complete; a partial config keeps the password so the operator is not locked out. Leaving APPROVAL_METHODS unset offers both when the config is complete. npx -y a2a-exposed@latest pair set-oidc writes the non-secret settings into config.env. The client secret is read from APPROVAL_OIDC_CLIENT_SECRET or --secret-stdin and is not saved or printed; the next npx -y a2a-exposed@latest deploy uploads that environment variable. status and pair list name the issuer host when OpenID Connect is on. In hosted mode the same fields come from the tenant's pushed approval object (including its client secret); Worker-level APPROVAL_OIDC_* does not approve a tenant.

  • npx is the one way to run the CLI: npx -y a2a-exposed@latest <command> everywhere: README, both skills and their references, the Worker's default wake hint (WAKE_CLI_COMMAND default), the Claude Code wake text, the 401 pairing hint (data.pairing.cli), the landing page and consent/pairing pages, and the CLI's help and every "run ..." / "next step" line it prints. Agent sandboxes (e.g. Claude Code cloud sessions) block a forced npm i -g a2a-exposed as untrusted code; npx needs no install. @latest rather than a pinned @0.5: deploy copies the Worker template from the CLI it runs, so an older or pinned copy would redeploy an older Worker over a newer one, and wake hints run long after setup should match the current release. A global install stays an optional speed-up on your own machine (npm i -g a2a-exposed@latest, then a2a-exposed <command>). The outdated-version notice now says run npx -y a2a-exposed@latest deploy (with a global install, npm i -g a2a-exposed@latest first); under npx @latest it doesn't appear, because the running copy is the newest.

  • Skills without a global install: npx -y skills add telegraphic-dev/a2a-exposed into the current project is the documented path (--global optional), or the agent reads the SKILL.md files straight from GitHub.

  • Claude Code: don't set up from a cloud session. The README quick start, the setup skill (first thing) and the Claude Code wake reference now say so up front: installs are blocked as untrusted code, tunnels as an ingress risk, there are no Cloudflare credentials and the network allowlist blocks the Worker. Run setup on a laptop or from another agent, add <Worker URL>/mcp as a connector, and the routine only consumes through the connector. The CLI fallback in a routine uses npx instead of a global install in the setup script.

0.5.0 - 2026-10-09

Upgrade: a2a-exposed deploy (applies D1 migrations 0006_mcp: OAuth clients and codes, MCP grants, synced outbound peers; and 0007_cimd: cached client metadata documents). Nothing changes for peers.

  • MCP protocol version 2026-07-28 alongside 2025-11-25, 2025-06-18 and 2025-03-26 (Claude and other clients still using the initialize handshake keep working; the behaviour is chosen per request). The new version is stateless: the version, clientCapabilities and clientInfo come from each request's _meta, and the MCP-Protocol-Version, Mcp-Method and (for tools/call, also Base64-encoded) Mcp-Name headers are required and must match the body (-32020 HeaderMismatch, HTTP 400). New server/discover (supported versions, capabilities, instructions); every result carries resultType: "complete" and the server info in _meta; tools/list keeps its deterministic order and adds ttlMs (1 hour) and cacheScope: "private"; subscriptions/listen is acknowledged with no notification types and closed at once; initialize, ping and other unknown methods get -32601 with HTTP 404; Mcp-Session-Id and Last-Event-ID are ignored; GET and DELETE stay 405. An unknown protocol version (in _meta or the header, either era) gets -32022 UnsupportedProtocolVersion with supported and requested. Not implemented: multi-round-trip requests (input_required), tasks, logging, resources, prompts and list-change notifications (none of the tools need them).

  • OAuth: Client ID Metadata Documents (client_id_metadata_document_supported): a client may use an https URL as its client_id; the Worker fetches the document (public https hosts only, never the Worker itself; every A/AAAA record must be a public address, checked over DNS-over-HTTPS, and the request goes over a connection to that checked address with TLS verified for the host, so DNS rebinding can't redirect it; no redirects, no credentials, 5 s, 5 KB, 30 lookups per hour per IP), checks that it names its own URL, lists valid redirect URIs and uses no client secret, caches it per Cache-Control (errors are not cached), and the consent page shows the host that publishes it. Dynamic client registration stays for older clients (deprecated in MCP) and now accepts and echoes application_type (native or web). The authorization server metadata advertises authorization_response_iss_parameter_supported (the redirect already carried iss, RFC 9207).

  • Fix: an authorization request without the optional resource parameter failed with a server error; the code is now bound to the Worker's MCP URL.

  • Remote MCP server (connector) at /mcp: Claude and other MCP clients can use the inbox as a connector, with no CLI, Node or owner token in the client. MCP Streamable HTTP (stateless JSON, POST /mcp; protocol versions 2025-11-25, 2025-06-18, 2025-03-26), tools inbox, show_task, history, mark_working, reply (with state), send, poll_outbound, list_peers, pairing_requests (read-only); every description carries the untrusted-peer-content and never-approve-pairings rules. Auth is OAuth 2.1 per the MCP authorization spec: protected-resource metadata (/.well-known/oauth-protected-resource/mcp, 401 with WWW-Authenticate: Bearer resource_metadata=...), RFC 8414 metadata (now also listing authorization_endpoint, registration_endpoint, revocation_endpoint, S256), dynamic client registration (public clients, https or loopback redirects, rate limited, capped), authorization code with PKCE S256, resource checked against the MCP URL, rotating refresh tokens, RFC 7009 revocation. The consent page (/oauth/authorize) needs the owner's approval password (same lockout counters as /device), shows the client's claimed name and redirect host, warns on loopback redirects, and has no scripts, a strict CSP and CSRF protection. MCP tokens live in their own table: never accepted as peer or owner tokens, never in a URL, listed by token list (mcp-<client>) and ended by token revoke. Scope: inbox, reply, send to synced peers, read-only listings; no token or pairing administration. On by default for an inbox (inert until you approve a client with your password); --mcp off disables it; never in proxy mode. /health reports mcp. The approval-password link (pair set-password --web) now also works with --pairing-approval off while MCP is on.

  • New peers sync [alias...] / peers unsync <alias>: upload outbound peers (URL and token) to the Worker so the connector's send / poll_outbound can reach them. Tokens are AES-GCM encrypted with a key derived from the owner token (rotating it makes them unreadable until the next sync); peer URLs must be public https.

  • Claude Code: connector first. The setup docs (README, setup skill, wake reference) make the connector the primary path for routines (add <Worker URL>/mcp at claude.ai/settings/connectors; routines include connectors and connector traffic bypasses the environment allowlist). The CLI checklist stays as the fallback. The claude-code wake text tells the session to use the connector's tools and falls back to the CLI requirements; wake set / wake test print both paths.

  • Rate-limited wakes are retried: when the wake endpoint answers 429 or 503 (a routine over its fire limit), the Worker keeps the inbound wake pending and re-sends it after Retry-After (30 s to 1 h), up to 3 times, on the next request to the Worker or the optional cron; then it logs wake_dropped. Previously such a wake was only logged.

  • Agent Skills + Agent Plugins compliance: both skills pass skills-ref validate (frontmatter limited to the agentskills.io allowed keys; version / author / homepage under metadata; Hermes and OpenClaw blocks kept under metadata.hermes / metadata.openclaw). Setup skill body split into skills/a2a-exposed-setup/references/ so SKILL.md stays under the progressive-disclosure size guidance. Root plugin.json targets Agent Plugins 1.0.0 (skills stay in skills/). Vendor manifests coexist: .cursor-plugin/plugin.json, .grok-plugin/plugin.json, .claude-plugin/plugin.json (+ marketplace.json for Claude). Codex/ChatGPT use the portable root plugin.json (extensions.com.openai); no separate .codex-plugin overlay. CI validates skills-ref and the plugin schema.

  • Claude Code routines (claude-code): the setup skill's wake reference has an ordered checklist for routine API triggers (repository source with the skills on the default branch, A2A_BASE_URL / A2A_OWNER_TOKEN on the routine's cloud environment and its risk, network allowlist for the Worker host, CLI in the setup script, a prompt that acts on the routine-fire-payload block, end-to-end verification) and troubleshooting rows for each failure mode. The claude-code wake text now tells a fresh routine session what it needs and to report incomplete setup instead of improvising (not for human-approved pairing wakes, which need no CLI). wake set / wake test with this preset print the same checklist. Upgrade: deploy (Worker wake text only).

0.4.1 - 2026-10-08

Upgrade: a2a-exposed deploy (new Worker owner endpoint; no D1 migration). Prompted by a façade in front of a Hermes A2A server behind Cloudflare Access: with only the Access service token set, every peer call failed with HTTP 502 / -32603, because Hermes also checks its own bearer (UPSTREAM_TOKEN), and nothing said so.

  • Setup checks the upstream's bearer requirement (proxy mode): init/deploy with --upstream read the upstream's agent card (from this machine, with the exported Access service token; never with a bearer) and, unless it declares no bearer / HTTP auth (A2A 1.0 securitySchemes + securityRequirements, 0.3 securitySchemes + security; OAuth 2.0 / OpenID Connect count as bearer; an empty requirement means anonymous access is allowed), require UPSTREAM_TOKEN: exported, piped with the new --upstream-token-stdin, or typed at a hidden prompt when stdin and stderr are a terminal. Never argv. Non-interactive runs exit 1 with what to do. --no-upstream-token is the explicit opt-out (saved as A2A_UPSTREAM_NO_TOKEN, cleared by an uploaded token or --upstream none). A redeploy is not asked again when the Worker already holds UPSTREAM_TOKEN for the same upstream, or the card it fetched declares no bearer. A required API-key, basic or mTLS scheme gets a warning (the façade only presents a bearer).
  • status: Access credential and upstream bearer reported separately (proxy mode): new rows upstream Access (credential configured + fingerprint, or NONE) and upstream bearer (configured + fingerprint, NONE, but the upstream card asks for one, or not needed), plus upstream check (the live check below), peer auth and last failure. An Access-only setup whose upstream card asks for a bearer is a failing next step, not "complete". When UPSTREAM_TOKEN is exported locally, its fingerprint is compared with the Worker's.
  • New upstream verify [--json] (also run by status): checks the façade's whole path layer by layer. (1) Façade peer auth: an unauthenticated JSON-RPC call to the public endpoint must get 401. (2) New owner-authenticated Worker endpoint POST /owner/facade/verify: the Worker calls the upstream with its stored secrets (they never leave the Worker) using a JSON-RPC method that doesn't exist (a2a-exposed/verify-unknown-method), so no task is created, and classifies the answer: reachable (any JSON-RPC answer, typically -32601), access_credentials_missing / access_rejected (Access login redirect, Access 401/403, CF-Access-* headers), upstream_auth_missing / upstream_auth_rejected (401/403 from the agent, including a JSON-RPC body such as Hermes's -32050), tunnel_down (530, Cloudflare error 1033/1016), upstream_unavailable (5xx without JSON-RPC), network, unexpected_response, misconfigured. Output: one row per layer (façade peer auth, Access, upstream bearer, A2A app) and the fix. Exit 1 unless the app answered. Reason codes and HTTP status only, never a secret value; no public probe endpoint.
  • Non-leaky 502 for peers, precise reasons for the operator: an upstream failure now reaches peers as a generic -32603 (This agent is unavailable: its façade is misconfigured; tell its operator, or ... not reachable right now; try again later) that no longer mentions credentials or Access. The Worker logs upstream_error with the reason code and stores the last failed peer call (reason, HTTP status, method, peer, time; D1 settings, no migration), which GET /owner/facade returns (lastFailure, plus upstreamAuth: what the upstream card asks for, scheme names and kinds only) and status shows.
  • Shadowed peer tokens in status: a PEER_<ALIAS>_TOKEN exported in the environment that differs from the token saved in config.env (e.g. a stale value after connect stored a fresh one) now also gets an also: line in status (names only, never values), in addition to the existing connect / send / poll / peers warnings.
  • Cloudflare login blocked / API token hints: when init finds no login, it now says that cf auth login failing with OAuth error: HTTP 403 Forbidden before any code appears is Cloudflare's bot mitigation for datacenter IPs (don't retry; use CLOUDFLARE_API_TOKEN); with a token set it points at IP filters (IPv4 /32 and IPv6 /128), and a token that can't list accounts gets "add User → Memberships → Read, or pass --account-id". Setup skill troubleshooting: account- vs zone-scoped permissions, API error 1001 for a wrong Memberships selector, IP-restricted tokens (overlaps the still-open docs PR #13, which adds the basic API-token path).
  • Pairing with a non-public card: connect --card-url with an http card (left out) and a failed pairing request that carried a Tailnet / LAN card now point at proxy mode (a public façade); the setup skill sends agents whose card isn't public https to proxy mode up front.
  • Removed the leftovers of the rename from a2a-over-webhook (0.4.0): the deprecated alias package alias/a2a-over-webhook/ and its CI pack check and publish step (a v* tag now publishes only a2a-exposed; the versions already on npm stay deprecated there), the fallback to ~/.config/a2a-over-webhook when ~/.config/a2a-exposed doesn't exist (the config directory is always $XDG_CONFIG_HOME/a2a-exposed or ~/.config/a2a-exposed, or A2A_CONFIG_DIR), the old default Worker name for a saved deployment without A2A_WORKER_NAME (the default is always a2a-exposed; init saves the name, and deploy now refuses a hand-edited config with a saved D1 but no Worker name instead of guessing: rerun with --worker-name), the A2A_NO_RENAME_NOTICE variable, and the rename notes in the README, both skills and AGENTS.md. Peer tokens keep their a2aow_ prefix (existing tokens and the token format are unchanged). Breaking for a config still in ~/.config/a2a-over-webhook (the CLI then reports no saved deployment): mv ~/.config/a2a-over-webhook ~/.config/a2a-exposed, or export A2A_CONFIG_DIR=~/.config/a2a-over-webhook.
  • Docs: README (façade credentials, upstream verify, security model), setup skill (the two upstream credentials, troubleshooting rows per reason code), operate skill (502 vs 401, upstream verify, shadowed peer tokens).

0.4.0

Upgrade: npm rm -g a2a-over-webhook && npm i -g a2a-exposed@latest, then a2a-exposed deploy (applies D1 migration 0005_facade_owners.sql, used only in proxy mode).

  • Renamed to a2a-exposed (the project now also exposes agents that already speak A2A, not only webhook inboxes). npm package and command a2a-exposed; skills a2a-exposed-setup and a2a-exposed; repository telegraphic-dev/a2a-exposed (GitHub redirects the old URL); install with npm i -g a2a-exposed@latest and npx --yes skills add telegraphic-dev/a2a-exposed. Compatibility: the npm package a2a-over-webhook becomes a deprecated alias for one or two minor releases (alias/a2a-over-webhook/, published by the same tag at the same version): it depends on a2a-exposed and keeps the a2a-over-webhook command and npx a2a-over-webhook (wake hints of existing deployments) working, with a stderr notice (A2A_NO_RENAME_NOTICE=1 silences it). Upgrade a global install with npm rm -g a2a-over-webhook && npm i -g a2a-exposed@latest; an existing ~/.config/a2a-over-webhook is used as is when ~/.config/a2a-exposed doesn't exist (nothing is moved); a saved deployment without A2A_WORKER_NAME keeps the old default Worker name, and new deployments default to a2a-exposed. Unchanged: A2A_* / WAKE_* variables, peer tokens (a2aow_ prefix), D1 data and URLs. Wake hints, the 401 pairing hint and OAuth metadata now name npx a2a-exposed and the new repository. The domain a2a.exposed is reserved for future project pages (nothing is served there yet).
  • Public façade for an agent that already speaks A2A on a private network (proxy / expose mode): init|deploy --upstream <https URL> (optional --upstream-card-url, which must be on the --upstream origin because the card is fetched with the upstream credentials: a cross-origin card URL is refused by init/deploy and by the Worker, which never sends UPSTREAM_TOKEN or the Access service token to any other origin; --upstream none switches back) makes the Worker a façade instead of an inbox. It forwards authenticated A2A JSON-RPC (1.0 and 0.3; SendMessage, GetTask, CancelTask) to the upstream through a Cloudflare Tunnel hostname behind Access. The upstream credentials come from the environment only and are uploaded as Worker secrets: UPSTREAM_TOKEN (bearer the façade presents) and UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (Access service token). The peer's token is never forwarded; the peer label goes upstream as X-A2A-Peer. Peers sharing the one upstream identity are isolated by the façade (D1 facade_owners: calls on another peer's task are refused before reaching the upstream, and a message may only name a contextId the façade recorded for that peer: client-chosen or unknown context ids are refused, fail closed). ListTasks is refused, streaming returns -32004, push notification configs are refused with -32003 (the upstream would call the URL from inside the private network, where even a public-looking name like 127.0.0.1.nip.io can resolve to a private address; peers poll GetTask; a relay through the Worker is a follow-up), and an upstream 401/403 or non-JSON answer becomes a 502 JSON-RPC error instead of a 401 to the peer. A private --upstream (Tailnet, LAN, localhost) is refused with the tunnel recipe. Device-flow pairing stays on the façade. New owner endpoint GET /owner/facade (upstream card status, versions, card-leak check, secret fingerprints only); status shows an upstream: row and proxy-mode next steps; /health reports mode.
  • Agent card rewrite: in proxy mode the public card is the upstream's card rewritten for the façade: interfaces only at PUBLIC_URL (one JSONRPC interface per version the upstream lists), the façade's security schemes and OAuth device URLs, upstream name / skills / version / extensions preserved (--agent-* settings win), streaming, pushNotifications and extendedAgentCard false, signatures and unknown fields dropped. Private and upstream URLs (localhost, RFC 1918, 100.64/10, private IPv6 literals such as [::1], [fd..], [fe80..] and IPv4-mapped ones, *.ts.net, *.local, single-label hosts, plain http, URLs that don't parse, the tunnel hostname) are removed from every string; documentationUrl, iconUrl and provider.url are dropped when private. The inbox card now applies the same scrubbing to AGENT_DESCRIPTION, AGENT_SKILLS, PROVIDER_URL and DOCUMENTATION_URL. If the upstream card is unreachable, a settings-based card is served (still public URLs only). Rules are documented in the setup skill.
  • HTML landing page at GET /: a browser gets a small page with the agent name, description and skills (from the served card, so in proxy mode the rewritten one with no private URL), the note that this is an A2A endpoint, and links to the agent card and the /device pairing page (or a note when pairing is off). It has the same frame as /device: no scripts, no external assets, a strict nonce CSP. Requests without Accept: text/html, or with ?format=json, still get JSON, now with description, a2a and pairing alongside name and agentCard.
  • Tailnet agents pairing out: connect --card-url <url> names your card in the pairing request. A private https card (*.ts.net, LAN) is sent as informational: the other inbox's /device page and wake (agentCardPrivate: true) flag it as "not publicly reachable". A non-https card is still left out.
  • Docs: setup skill section "Already have A2A on a Tailnet or LAN: expose it through a public façade" (tunnel + Access recipe, deploy, card rewrite rules, upstream credential model, threat model), README section and security/protocol notes, new troubleshooting rows, deploy.env.example.

0.3.1

  • Stale peer-token variables: a peer token in the environment (PEER_<ALIAS>_TOKEN, a --token-env variable, or A2A_PEER_TOKEN) still overrides the one saved in config.env, but when both are set and differ, send, poll, connect and peers add|list now warn on stderr, naming the variable and how to clear it (unset PEER_<ALIAS>_TOKEN), never either value. connect (and peers add --token-stdin) also warns right after saving a new token that a stale variable would still win on the next send; connect --json adds env_overrides_token: true.

0.3.0

Upgrade: npm i -g a2a-over-webhook@latest, then a2a-over-webhook deploy (applies D1 migrations 0002_wake_budget.sql and 0004_pairing_replace.sql).

  • Password setup over the web (pair set-password --web): the CLI asks the Worker for a one-time link (POST /owner/pairing/password-link, default 15 minutes, configurable 1–60). The human opens /device/setup?t=..., types a password (≥12) twice, and the Worker hashes it with PBKDF2-SHA256. The link is single-use, burned after 5 bad attempts, and a new link invalidates the old one. Same CSP/CSRF as /device. status / pair list show when the password was set and how (web or terminal). Agents must send the link to their human and never open it.
  • Re-pairing without orphan tokens (F6): connect refuses an alias whose token still works, unless --replace (alias --force). With --replace the old token is presented on device_authorization; the Worker stores replaces_label (D1 migration 0004_pairing_replace.sql, applied automatically by deploy) and the presented token's hash (replaces_hash), and, on approval, swaps the token under the same label only if that exact token is still on the label (a rotate or revoke in between downgrades to a new label, so a leaked old token can't take over a rotated one). Older peers keep the old token until their owner revokes it; the CLI says so. Revoked labels are not reused.
  • Expired --no-wait resumes (F7/F8): resuming an expired request exits 1 with "expired before it was approved; nothing was stored" and prints the exact re-run command (every flag, including --alias). Never starts a silent new request.
  • Polling agents see pairing requests (F5): inbox lists pending pairing requests after the tasks (and on stderr with --json); the polling recipes (Hermes, OpenClaw, generic) tell the agent to relay the code and never approve. pair set-password no longer claims every wake carries the link.
  • Node 22 and CLI upgrades (F1–F3): the setup skill and the CLI's version error list mise / nvm / fnm / the official installer. The install recipe is npm i -g a2a-over-webhook@latest (the old command -v || npm i never upgraded). An outdated notice on stderr checks the npm registry at most once a day in a detached process (A2A_NO_UPDATE_CHECK=1 / DO_NOT_TRACK=1 / CI opt out). Skills and cli/package.json versions are kept in sync with the release.
  • Deploy / status friction (F4, F16–F18): init/deploy wait until the agent card is served twice in a row (Cloudflare 1042 while a new workers.dev Worker propagates). status names Cloudflare error codes and treats 1042 as "retry in 30 s". Optional hints (also:) keep the next-step line short; with no wake it no longer mentions a preset. Global --config-dir (and --worker-dir for the Worker project; --dir still works). url exits 1 when unset. connect --json uses not_waiting instead of started. Friendly 401 from send/poll. --workers-logs on|off enables persisted Workers Logs (query strings redacted). Teardown steps are documented (no command).
  • Auth and pairing polish (F9–F15): 401 echoes the JSON-RPC id; WWW-Authenticate carries resource_metadata (RFC 9728) and a different error_description for a bad token vs no token; /.well-known/agent.json is an A2A 0.3-shaped card; token revoke of an unknown label exits 1 with a clear message (text, not raw JSON); pair approve in human mode names deploy --pairing-approval agent; Deny on /device needs no password and never runs PBKDF2; pairing off lists no pending requests; expired device codes return expired_token for 24 h, then invalid_grant.
  • PBKDF2: default stays 100,000 (Workers maximum); range 50,000–100,000 via init/deploy --pbkdf2-iterations (A2A_PBKDF2_ITERATIONS). Each stored hash keeps its own count. Documented in the threat model (online guessing is limited by lockouts; lower only if /device hits Cloudflare error 1102 on the free plan).
  • Worker: migration 0002_wake_budget.sql creates the wake_budget table on D1 databases whose 0001_init.sql came from an earlier build (D1 tracks migrations by file name, so the current 0001 never ran there). Without it, messages to such an inbox failed with -32603 Internal error and sent no wake. It does nothing on newer databases, and deploy applies it even where 0003_device_pairing.sql is already recorded (cf applies every unrecorded file in numeric order). A new test replays these upgrades.
  • Docs: setup skill section on adopting an existing deployment (same Worker, D1 and hostname) without reissuing tokens or re-uploading secrets: deploy keeps the Worker's secrets and applies 0002–0004, then status checks the result. Device-flow pairing is on after the deploy (human approval), or --pairing-approval off.
  • Skills: the setup skill suggests two optional companion skills near its prerequisites, with install commands: mise (telegraphic-dev/mise-skill, for Node 22.18+) and cloudflare (cloudflare/skills, for Workers, D1, the cf CLI, DNS, Tunnel and Access). The operate skill points to cloudflare for Cloudflare-side troubleshooting. related_skills lists them; the README's install section names both.

0.2.0

  • Tunnel on a workers.dev inbox: tunnel create (and init --workers-dev --tunnel) no longer needs a custom inbox hostname. The wake hostname goes on any zone of the account: the inbox hostname's zone, otherwise the account's only zone (it says which it picked). With several zones it stops and lists them; choose with the new --tunnel-zone <zone> (or --tunnel-hostname). With no zone it says plainly that polling is the option. Zones are listed for the deployment's account only.
  • status command: a read-only summary of the deployment and base URL, an agent-card check done by the CLI (no curl), the wake mode (webhook, tunnel, or none, which means polling), the tunnel state and connector connections, and a next step: line. Exit code 1 when something is broken. init and the card warning now point to it.
  • Safe re-runs: tunnel create on an existing tunnel creates nothing. It re-uploads Worker secrets if they're missing, together with an exported WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, and warns if the Worker would still have no webhook auth. A missing connector token file is downloaded again. When an earlier run stopped halfway, it asks for tunnel rm. status flags a local preset (Hermes, OpenClaw) whose Worker sends no webhook auth. If init --tunnel fails at the tunnel step, it says the inbox is deployed and how to continue.
  • Setup skill: decide how wakes reach the agent (public webhook, local-only webhook, or none) before picking the inbox URL. For local-only webhooks, check for a zone and recommend the secure tunnel with either inbox URL, explaining the tradeoff to the user; polling is the fallback. Adds copy-paste polling commands for Hermes (hermes cron create "every 2m" ... --skill a2a-over-webhook --name a2a-inbox, checked against the Hermes source and docs) and OpenClaw (openclaw cron add --every 5m --session isolated ...), with a note about command approval prompts. Also adds resuming with status, moving from polling to the webhook without a redeploy, and new troubleshooting rows.
  • The agent card always advertises the public base URL: the Worker template no longer reads an A2A_PUBLIC_URL override from the deploy environment, so a local webhook, Tailnet or tunnel URL exported by the agent can't end up in the card. The card URL comes only from the custom hostname or the workers.dev URL. The Worker also ignores a non-https or private-network PUBLIC_URL and falls back to the request origin. init and deploy verify and print the deployment's own URL, and warn when an exported A2A_BASE_URL differs. They also warn when the card points elsewhere. status flags both cases (agent card: WRONG URL: ..., next step deploy). This was found in a Hermes setup where the card advertised a Tailnet URL and the agent fixed it by hand.
  • Device-flow pairing (OAuth 2.0 Device Authorization Grant, RFC 8628): agents connect without exchanging tokens in chat.
    • Each inbox serves POST /oauth/device_authorization, POST /oauth/token (authorization_pending, slow_down, access_denied, expired_token, invalid_grant), a /device approval page, and /.well-known/oauth-authorization-server. An approval issues a normal hashed a2aow_ peer token, single-use.
    • Approval is human by default: an approval password set with pair set-password (terminal only, no echo; stored as salted PBKDF2-SHA256), with lockouts. --pairing-approval agent adds pair approve <code>; off disables pairing.
    • New requests wake the agent with kind: "pairing_request". New commands: connect <url> (client; --no-wait, --json), pair list|approve|deny. token list shows tokens created by pairing.
    • The agent card advertises an A2A 1.0 oauth2SecurityScheme with a deviceCode flow next to bearer, and a 401 from the A2A endpoint explains the flow.
    • Flood limits apply per IP and globally. D1 migration 0003_device_pairing.sql (numbered to avoid a 0002 from another branch); run deploy to apply it.
  • Docs: both skills and the README check the endpoint with status instead of curl, because some agent sandboxes (Hermes) flag .dev URLs in shell commands. The README and the operate skill describe the tunnel as needing any zone on the account.

0.1.0

First npm release of the CLI (npm i -g a2a-over-webhook).

  • Worker: public agent card and A2A JSON-RPC endpoint (1.0 primary, 0.3 compatible) with a D1 inbox; per-peer a2aow_ bearer tokens stored as SHA-256 hashes; push notifications; per-conversation wake debounce and an hourly wake cap.
  • Wake presets: grok-bot, claude-code, openclaw-wake, openclaw-agent, hermes (HMAC-SHA256), generic; wake set|unset|test|preview|fingerprint with masked previews and SHA-256 fingerprints instead of secret values.
  • Deploy: init / deploy with the cf CLI, on a custom domain or a free *.workers.dev URL (--workers-dev-subdomain registers the account subdomain); deploy --workers-dev / --hostname move a deployment between the two and retire the old URL (301 for the card, 410 otherwise).
  • cf auth profiles: --cf-profile / CF_PROFILE for several Cloudflare logins on one machine.
  • Secure tunnel: tunnel create|status|rm publishes a local-only webhook (OpenClaw, Hermes) through a named Cloudflare Tunnel behind a Cloudflare Access app that admits only the Worker's service token.
  • Inbox and outbound: inbox, show, working, reply, history, contexts; peers add|list|rm, send, poll, outbound; token issue|list|revoke|rotate.
  • Skills: a2a-over-webhook-setup (deploy and wake configuration) and a2a-over-webhook (day-to-day), with Hermes and OpenClaw frontmatter metadata.
  • Release tooling: CI on pushes and PRs; tag-triggered npm publish with provenance and a GitHub release.