telegraphic-dev/a2a-exposed
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(andsignup, 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 asCONTROL_TOKENand is not printed. Login has to be on; otherwise the command stops. Apply D1 migration0004_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./appshows a message forauth,turnstile,unavailable,invite, andemail. 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-httpsrefuses 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 whateverWAKE_WEBHOOK_URLthe operator configured. - Control plane assets. Every request runs the Worker (
runWorkerFirst), soAccept: text/markdowncan return the Markdown twin instead of the HTML asset. That response, and the HTML asset for the same URL, sendVary: Accept.text/markdown;q=0is not a request for Markdown. A prerender withSITE_URLunset 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-configurationpublishes 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/callbackand/oauth/authorize/oidc/callbackand pushesapproval(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/appand brought back. With login unset, discovery stays 404. WhenISSUERis set, login uses that origin even ifSITE_URLnames 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 withcf. With no configuration it serves a neutral page ("Inbox: sign in / create an agent") and tells the operator that no login provider is configured./appand/apiarenoindex;robots.txtdisallows only those two paths. Brand components, Markdown pages, andpublic/files are an overlay (control/OVERLAY.md). The npm package does not includecontrol/. No login, billing, or tenant API in this change. - Control plane tenants.
POST /api/v1/tenantscreates an agent for the signed-in account when the data-plane Durable Object and name directory are bound. The body must beapplication/json. A browserOriginorSec-Fetch-Sitefrom 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 updatestenant:<name>and callspushConfig. 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 untilAUTH_SECRET, D1, and a provider are set. Providers are GitHub, Google, Cloudflare (user-details.readonly), 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=1requires 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
TenantContextbuilt from the Worker bindings instead of readingenv.*themselves. With the new settings unset, owner checks and outbound peer-token encryption are the same operations as before (existingpeers syncciphertext still decrypts; the owner API still usesOWNER_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=hostwithout aTENANT_DObinding still usesenv.DBandOWNER_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 fromTENANT_SECRETS_KEYplus 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).initdoes not set any of this. -
Per-tenant SQLite, only when hosted. With
TENANCY=hostthe Worker entry issrc/hosted.ts, which bindsTENANT_DO(one SQLite Durable Object per tenant) andTENANT_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 throughpushConfig(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, ordefault) is written onto that entry; later requests use the stored region, so changingDATA_REGIONdoes not move a tenant. LeaveTENANCYunset and the build is the previous single-tenant Worker on D1: same entry, no Durable Object export, same cron flush.STORAGE_BACKEND=d1andSTORAGE_BACKEND=dorun 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=1still flushesenv.DBevery 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 exportprints a JSON dump (GET /owner/export);export --sqlprints 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'stransactionSync). 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 includesoidc_txns. A text value that contains a NUL byte is written withchar(0), so the SQL script does not contain a raw NUL. The owner token stays a Worker secret.d1_migrationsand the hostedtenant_configrow 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 isexportSql()on the Durable Object.bookmarkForTimereads a bookmark when the storage runtime provides one.restoreBookmarkschedules 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=1adds an R2 bindingBACKUP_BUCKETand a daily cron (0 3 * * *) that writestenants/<id>/<YYYY-MM-DD>.sqland 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 secretAPPROVAL_OIDC_CLIENT_SECRETandAPPROVAL_OIDC_ALLOWED_SUBJECTSall set,/deviceand/oauth/authorizeadd an identity-provider button beside the password. The start is authorization code + PKCE (S256) and requests theopenidandemailscopes so the provider returnsemailandemail_verified;stateandnonceare 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. Asubmatch is enough. Anemailmatch also needsemail_verifiedtrue. A token with more than one audience, or anyazpclaim, must setazpto this client id. Deny still needs no login. Unset settings leave both pages as they are today.APPROVAL_METHODS=passwordkeeps the password even when an issuer is set.APPROVAL_METHODS=oidchides the password only once that config is complete; a partial config keeps the password so the operator is not locked out. LeavingAPPROVAL_METHODSunset offers both when the config is complete.npx -y a2a-exposed@latest pair set-oidcwrites the non-secret settings intoconfig.env. The client secret is read fromAPPROVAL_OIDC_CLIENT_SECRETor--secret-stdinand is not saved or printed; the nextnpx -y a2a-exposed@latest deployuploads that environment variable.statusandpair listname the issuer host when OpenID Connect is on. In hosted mode the same fields come from the tenant's pushedapprovalobject (including its client secret); Worker-levelAPPROVAL_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_COMMANDdefault), 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 forcednpm i -g a2a-exposedas untrusted code; npx needs no install.@latestrather than a pinned@0.5:deploycopies 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, thena2a-exposed <command>). The outdated-version notice now saysrun npx -y a2a-exposed@latest deploy(with a global install,npm i -g a2a-exposed@latestfirst); under npx@latestit doesn't appear, because the running copy is the newest. -
Skills without a global install:
npx -y skills add telegraphic-dev/a2a-exposedinto the current project is the documented path (--globaloptional), or the agent reads theSKILL.mdfiles 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>/mcpas 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
initializehandshake keep working; the behaviour is chosen per request). The new version is stateless: the version,clientCapabilitiesandclientInfocome from each request's_meta, and theMCP-Protocol-Version,Mcp-Methodand (fortools/call, also Base64-encoded)Mcp-Nameheaders are required and must match the body (-32020HeaderMismatch, HTTP 400). Newserver/discover(supported versions, capabilities, instructions); every result carriesresultType: "complete"and the server info in_meta;tools/listkeeps its deterministic order and addsttlMs(1 hour) andcacheScope: "private";subscriptions/listenis acknowledged with no notification types and closed at once;initialize,pingand other unknown methods get-32601with HTTP 404;Mcp-Session-IdandLast-Event-IDare ignored; GET and DELETE stay 405. An unknown protocol version (in_metaor the header, either era) gets-32022UnsupportedProtocolVersion withsupportedandrequested. 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 itsclient_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 perCache-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 echoesapplication_type(nativeorweb). The authorization server metadata advertisesauthorization_response_iss_parameter_supported(the redirect already carriediss, RFC 9207). -
Fix: an authorization request without the optional
resourceparameter 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), toolsinbox,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 withWWW-Authenticate: Bearer resource_metadata=...), RFC 8414 metadata (now also listingauthorization_endpoint,registration_endpoint,revocation_endpoint, S256), dynamic client registration (public clients, https or loopback redirects, rate limited, capped), authorization code with PKCE S256,resourcechecked 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 bytoken list(mcp-<client>) and ended bytoken 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 offdisables it; never in proxy mode./healthreportsmcp. The approval-password link (pair set-password --web) now also works with--pairing-approval offwhile MCP is on. -
New
peers sync [alias...]/peers unsync <alias>: upload outbound peers (URL and token) to the Worker so the connector'ssend/poll_outboundcan 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>/mcpat claude.ai/settings/connectors; routines include connectors and connector traffic bypasses the environment allowlist). The CLI checklist stays as the fallback. Theclaude-codewake text tells the session to use the connector's tools and falls back to the CLI requirements;wake set/wake testprint 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 logswake_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/homepageundermetadata; Hermes and OpenClaw blocks kept undermetadata.hermes/metadata.openclaw). Setup skill body split intoskills/a2a-exposed-setup/references/soSKILL.mdstays under the progressive-disclosure size guidance. Rootplugin.jsontargets Agent Plugins 1.0.0 (skills stay inskills/). Vendor manifests coexist:.cursor-plugin/plugin.json,.grok-plugin/plugin.json,.claude-plugin/plugin.json(+ marketplace.json for Claude). Codex/ChatGPT use the portable rootplugin.json(extensions.com.openai); no separate.codex-pluginoverlay. 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_TOKENon the routine's cloud environment and its risk, network allowlist for the Worker host, CLI in the setup script, a prompt that acts on theroutine-fire-payloadblock, end-to-end verification) and troubleshooting rows for each failure mode. Theclaude-codewake 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 testwith 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/deploywith--upstreamread 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.0securitySchemes+securityRequirements, 0.3securitySchemes+security; OAuth 2.0 / OpenID Connect count as bearer; an empty requirement means anonymous access is allowed), requireUPSTREAM_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-tokenis the explicit opt-out (saved asA2A_UPSTREAM_NO_TOKEN, cleared by an uploaded token or--upstream none). A redeploy is not asked again when the Worker already holdsUPSTREAM_TOKENfor 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 rowsupstream Access(credential configured + fingerprint, or NONE) andupstream bearer(configured + fingerprint,NONE, but the upstream card asks for one, or not needed), plusupstream check(the live check below),peer authandlast failure. An Access-only setup whose upstream card asks for a bearer is a failing next step, not "complete". WhenUPSTREAM_TOKENis exported locally, its fingerprint is compared with the Worker's.- New
upstream verify [--json](also run bystatus): 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 endpointPOST /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 logsupstream_errorwith thereasoncode and stores the last failed peer call (reason, HTTP status, method, peer, time; D1settings, no migration), whichGET /owner/facadereturns (lastFailure, plusupstreamAuth: what the upstream card asks for, scheme names and kinds only) andstatusshows. - Shadowed peer tokens in
status: aPEER_<ALIAS>_TOKENexported in the environment that differs from the token saved inconfig.env(e.g. a stale value afterconnectstored a fresh one) now also gets analso:line instatus(names only, never values), in addition to the existingconnect/send/poll/peerswarnings. - Cloudflare login blocked / API token hints: when
initfinds no login, it now says thatcf auth loginfailing withOAuth error: HTTP 403 Forbiddenbefore any code appears is Cloudflare's bot mitigation for datacenter IPs (don't retry; useCLOUDFLARE_API_TOKEN); with a token set it points at IP filters (IPv4/32and 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 error1001for 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-urlwith 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 (av*tag now publishes onlya2a-exposed; the versions already on npm stay deprecated there), the fallback to~/.config/a2a-over-webhookwhen~/.config/a2a-exposeddoesn't exist (the config directory is always$XDG_CONFIG_HOME/a2a-exposedor~/.config/a2a-exposed, orA2A_CONFIG_DIR), the old default Worker name for a saved deployment withoutA2A_WORKER_NAME(the default is alwaysa2a-exposed;initsaves the name, anddeploynow refuses a hand-edited config with a saved D1 but no Worker name instead of guessing: rerun with--worker-name), theA2A_NO_RENAME_NOTICEvariable, and the rename notes in the README, both skills andAGENTS.md. Peer tokens keep theira2aow_prefix (existing tokens and the token format are unchanged). Breaking for a config still in~/.config/a2a-over-webhook(the CLI then reportsno saved deployment):mv ~/.config/a2a-over-webhook ~/.config/a2a-exposed, orexport 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; skillsa2a-exposed-setupanda2a-exposed; repositorytelegraphic-dev/a2a-exposed(GitHub redirects the old URL); install withnpm i -g a2a-exposed@latestandnpx --yes skills add telegraphic-dev/a2a-exposed. Compatibility: the npm packagea2a-over-webhookbecomes a deprecated alias for one or two minor releases (alias/a2a-over-webhook/, published by the same tag at the same version): it depends ona2a-exposedand keeps thea2a-over-webhookcommand andnpx a2a-over-webhook(wake hints of existing deployments) working, with a stderr notice (A2A_NO_RENAME_NOTICE=1silences it). Upgrade a global install withnpm rm -g a2a-over-webhook && npm i -g a2a-exposed@latest; an existing~/.config/a2a-over-webhookis used as is when~/.config/a2a-exposeddoesn't exist (nothing is moved); a saved deployment withoutA2A_WORKER_NAMEkeeps the old default Worker name, and new deployments default toa2a-exposed. Unchanged:A2A_*/WAKE_*variables, peer tokens (a2aow_prefix), D1 data and URLs. Wake hints, the 401 pairing hint and OAuth metadata now namenpx a2a-exposedand the new repository. The domaina2a.exposedis 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--upstreamorigin because the card is fetched with the upstream credentials: a cross-origin card URL is refused byinit/deployand by the Worker, which never sendsUPSTREAM_TOKENor the Access service token to any other origin;--upstream noneswitches 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) andUPSTREAM_ACCESS_CLIENT_ID/UPSTREAM_ACCESS_CLIENT_SECRET(Access service token). The peer's token is never forwarded; the peer label goes upstream asX-A2A-Peer. Peers sharing the one upstream identity are isolated by the façade (D1facade_owners: calls on another peer's task are refused before reaching the upstream, and a message may only name acontextIdthe façade recorded for that peer: client-chosen or unknown context ids are refused, fail closed).ListTasksis 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 like127.0.0.1.nip.iocan resolve to a private address; peers pollGetTask; 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 endpointGET /owner/facade(upstream card status, versions, card-leak check, secret fingerprints only);statusshows anupstream:row and proxy-mode next steps;/healthreportsmode. - 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,pushNotificationsandextendedAgentCardfalse,signaturesand 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,iconUrlandprovider.urlare dropped when private. The inbox card now applies the same scrubbing toAGENT_DESCRIPTION,AGENT_SKILLS,PROVIDER_URLandDOCUMENTATION_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/devicepairing 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 withoutAccept: text/html, or with?format=json, still get JSON, now withdescription,a2aandpairingalongsidenameandagentCard. - 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/devicepage 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-envvariable, orA2A_PEER_TOKEN) still overrides the one saved inconfig.env, but when both are set and differ,send,poll,connectandpeers add|listnow warn on stderr, naming the variable and how to clear it (unset PEER_<ALIAS>_TOKEN), never either value.connect(andpeers add --token-stdin) also warns right after saving a new token that a stale variable would still win on the nextsend;connect --jsonaddsenv_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 listshow when the password was set and how (weborterminal). Agents must send the link to their human and never open it. - Re-pairing without orphan tokens (F6):
connectrefuses an alias whose token still works, unless--replace(alias--force). With--replacethe old token is presented ondevice_authorization; the Worker storesreplaces_label(D1 migration0004_pairing_replace.sql, applied automatically bydeploy) 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-waitresumes (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):
inboxlists 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-passwordno 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 oldcommand -v || npm inever 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/CIopt out). Skills andcli/package.jsonversions are kept in sync with the release. - Deploy / status friction (F4, F16–F18):
init/deploywait until the agent card is served twice in a row (Cloudflare 1042 while a new workers.dev Worker propagates).statusnames 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-dirfor the Worker project;--dirstill works).urlexits 1 when unset.connect --jsonusesnot_waitinginstead ofstarted. Friendly 401 fromsend/poll.--workers-logs on|offenables 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-Authenticatecarriesresource_metadata(RFC 9728) and a differenterror_descriptionfor a bad token vs no token;/.well-known/agent.jsonis an A2A 0.3-shaped card;token revokeof an unknown label exits 1 with a clear message (text, not raw JSON);pair approvein human mode namesdeploy --pairing-approval agent; Deny on/deviceneeds no password and never runs PBKDF2; pairingofflists no pending requests; expired device codes returnexpired_tokenfor 24 h, theninvalid_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/devicehits Cloudflare error 1102 on the free plan). - Worker: migration
0002_wake_budget.sqlcreates thewake_budgettable on D1 databases whose0001_init.sqlcame from an earlier build (D1 tracks migrations by file name, so the current0001never ran there). Without it, messages to such an inbox failed with-32603 Internal errorand sent no wake. It does nothing on newer databases, anddeployapplies it even where0003_device_pairing.sqlis 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:
deploykeeps the Worker's secrets and applies0002–0004, thenstatuschecks the result. Device-flow pairing is on after the deploy (humanapproval), 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+) andcloudflare(cloudflare/skills, for Workers, D1, thecfCLI, DNS, Tunnel and Access). The operate skill points tocloudflarefor Cloudflare-side troubleshooting.related_skillslists them; the README's install section names both.
0.2.0
- Tunnel on a workers.dev inbox:
tunnel create(andinit --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. statuscommand: a read-only summary of the deployment and base URL, an agent-card check done by the CLI (nocurl), the wake mode (webhook, tunnel, or none, which means polling), the tunnel state and connector connections, and anext step:line. Exit code 1 when something is broken.initand the card warning now point to it.- Safe re-runs:
tunnel createon an existing tunnel creates nothing. It re-uploads Worker secrets if they're missing, together with an exportedWAKE_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 fortunnel rm.statusflags a local preset (Hermes, OpenClaw) whose Worker sends no webhook auth. Ifinit --tunnelfails 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 withstatus, 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_URLoverride 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-networkPUBLIC_URLand falls back to the request origin.initanddeployverify and print the deployment's own URL, and warn when an exportedA2A_BASE_URLdiffers. They also warn when the card points elsewhere.statusflags both cases (agent card: WRONG URL: ..., next stepdeploy). 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/deviceapproval page, and/.well-known/oauth-authorization-server. An approval issues a normal hasheda2aow_peer token, single-use. - Approval is
humanby default: an approval password set withpair set-password(terminal only, no echo; stored as salted PBKDF2-SHA256), with lockouts.--pairing-approval agentaddspair approve <code>;offdisables pairing. - New requests wake the agent with
kind: "pairing_request". New commands:connect <url>(client;--no-wait,--json),pair list|approve|deny.token listshows tokens created by pairing. - The agent card advertises an A2A 1.0
oauth2SecuritySchemewith adeviceCodeflow next tobearer, 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 a0002from another branch); rundeployto apply it.
- Each inbox serves
- Docs: both skills and the README check the endpoint with
statusinstead ofcurl, because some agent sandboxes (Hermes) flag.devURLs 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|fingerprintwith masked previews and SHA-256 fingerprints instead of secret values. - Deploy:
init/deploywith thecfCLI, on a custom domain or a free*.workers.devURL (--workers-dev-subdomainregisters the account subdomain);deploy --workers-dev/--hostnamemove a deployment between the two and retire the old URL (301 for the card, 410 otherwise). - cf auth profiles:
--cf-profile/CF_PROFILEfor several Cloudflare logins on one machine. - Secure tunnel:
tunnel create|status|rmpublishes 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) anda2a-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.