Plug for serving A2A agents over HTTP.
Handles agent card discovery (GET), JSON-RPC dispatch (POST), and SSE
streaming. Works standalone with Bandit or mounted inside Phoenix via
forward.
Usage
# In a Phoenix router:
forward "/a2a", A2A.Plug, agent: MyAgent, base_url: "http://localhost:4000/a2a"
# Standalone with Bandit:
Bandit.start_link(plug: {A2A.Plug, agent: MyAgent, base_url: "http://localhost:4000"})Options
:agent— GenServer name or pid of the agent (required):base_url— the public URL of the agent endpoint. Required unless always provided at runtime viaput_base_url/2. Whennil, agent card requests raiseArgumentError.:agent_card_path— path segments for the agent card endpoint (default:[".well-known", "agent-card.json"]). Set tofalseto disable built-in agent card serving — useful when you want to serve the card from a custom route usingA2A.get_agent_card/2.:json_rpc_path— path segments for the JSON-RPC endpoint (default:[]):agent_card_opts— keyword options forwarded toA2A.JSON.encode_agent_card/2:last_modified—DateTimeserved as the agent card'sLast-Modifiedheader (default:DateTime.utc_now()atinit/1). Under Phoenix'splugmacroinit/1runs at compile time, so the default is the build time — which is accurate, since the card is itself a compile-time literal. Called directly, it is boot time. The card also carries asha256ETagandCache-Control: public, max-age=300.:metadata— static metadata merged into every JSON-RPC call (default:%{}). Useful for deployment-level metadata like%{"env" => "prod"}. Overridden per-request byput_metadata/2.:resubscribe_timeout— how long atasks/resubscribestream waits without an event before closing, in ms (default:60_000). A task that never reaches a terminal state would otherwise hold its connection process open indefinitely.:authorize_task— optional authorization callback for task-scoped operations. Called as(operation, task, context)before returning, canceling, or listing tasks, and before any push notification config operation.operationis one of:get,:cancel,:list,:resubscribe,:push_set,:push_get,:push_list, or:push_delete— the push operations are distinct so an authorizer can grant read access to a task without also granting the ability to rewrite its webhooks. Denied requests returnTaskNotFoundErrorso task IDs are not leaked.:extensions— list ofA2A.Extensionmodules (or{module, opts}tuples) declaring protocol extensions this server supports. Required extensions are validated against the client'sA2A-Extensionsrequest header; missing required extensions returnExtensionSupportRequiredError(-32008). The server sets theA2A-Extensionsresponse header to the URIs that were activated. Declarations are merged intocapabilities.extensionson the served agent card.:versions— list of supported A2A protocol versions asMajor.Minorstrings (default:A2A.Version.supported_default/0, currently["0.3", "1.0"]). The client'sA2A-Versionheader is normalized toMajor.Minorand validated against this list; unsupported versions returnVersionNotSupportedError(-32009). Missing/empty headers are treated as"0.3"(spec §3.6.2). The negotiated version is echoed in theA2A-Versionresponse header.
Per-Request Overrides
Use put_base_url/2 and put_metadata/2 in an upstream plug or
Phoenix pipeline to set per-request values. These are stored in
conn.private[:a2a] following the Ash/Absinthe convention.
plug :set_tenant_a2a
defp set_tenant_a2a(conn, _opts) do
conn
|> A2A.Plug.put_base_url("https://#{conn.host}/a2a")
|> A2A.Plug.put_metadata(%{"tenant_id" => conn.assigns.tenant_id})
endMetadata Merge Order
Metadata is merged in three layers (later wins):
:metadatafrominit/1(static defaults)put_metadata/2on conn (per-request)"metadata"from JSON-RPC params (per-call from client)
Summary
Functions
Returns the per-request base URL, or nil if not set.
Returns the per-request metadata, or nil if not set.
Stores a per-request base URL in conn.private[:a2a].
Stores per-request metadata in conn.private[:a2a].
Functions
@spec get_base_url(Plug.Conn.t()) :: String.t() | nil
Returns the per-request base URL, or nil if not set.
@spec get_metadata(Plug.Conn.t()) :: map() | nil
Returns the per-request metadata, or nil if not set.
@spec put_base_url(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
Stores a per-request base URL in conn.private[:a2a].
Use this in an upstream plug or Phoenix pipeline to override the
base_url configured at init time.
@spec put_metadata(Plug.Conn.t(), map()) :: Plug.Conn.t()
Stores per-request metadata in conn.private[:a2a].
This metadata is merged between the init-time :metadata and the
per-call JSON-RPC "metadata" field.