A2A.Extension behaviour (A2A v0.3.0)

Copy Markdown View Source

Behaviour for A2A v1.0 protocol extensions.

An extension module advertises a URI in its declaration/1, optionally participates in per-request activation via activate/3, and can hook into the request/response pipeline via handle_request/3 and handle_response/3. Configured on A2A.Plug and A2A.Client as a list of module() or {module(), keyword()} tuples.

See the A2A v1.0 extensions topic for protocol-level semantics. This module implements the data-only and profile extension categories; method extensions (registering new RPC methods) and state-machine extensions are not yet supported.

Defining an extension

defmodule MyApp.TimestampExtension do
  @behaviour A2A.Extension
  @uri "https://example.com/ext/timestamp"

  @impl true
  def declaration(_state) do
    %A2A.AgentExtension{uri: @uri, description: "Adds request timestamps"}
  end

  @impl true
  def activate(_requested_uris, _ctx, _state) do
    {:ok, System.system_time(:millisecond)}
  end

  @impl true
  def handle_response(task, _params, started_at) do
    {:ok, A2A.Extension.put_metadata(task, __MODULE__, %{started_at: started_at}),
     started_at}
  end
end

Activation model

At Plug/Client init time each configured extension's init/1 is called with its opts; the returned state is kept alongside the module. Per request, the server parses the A2A-Extensions header and for each configured extension whose declared URI is in the requested set, calls activate/3 with the requested URI list, the request context, and the init state. activate/3 may return {:ok, activation} to participate, :skip to opt out for this request, or {:error, error} to abort with a JSON-RPC error. The list of activated URIs is echoed in the response A2A-Extensions header.

Required extensions (required: true in declaration/1) that are not declared in the client's request header trigger ExtensionSupportRequiredError (-32008) before activation runs.

Hooks

Activated extensions can mutate the request or response:

  • handle_request/3 — receives the decoded message, JSON-RPC params, and the extension's activation. Runs in declaration order before the request is dispatched to the agent.
  • handle_response/3 — receives the task, JSON-RPC params, and the activation. Runs in declaration order after the agent replies and before the task is encoded.

Both callbacks are optional; data-only extensions implement neither.

Reading activations inside an agent

Activations are surfaced to the agent's handle_message/2 as context.extensions, a %{uri => activation} map. The A2A.Extension.fetch/2 and A2A.Extension.activated?/2 helpers look up by module rather than URI string.

def handle_message(message, context) do
  case A2A.Extension.fetch(context, MyApp.TimestampExtension) do
    {:ok, started_at} -> use_timestamp(message, started_at)
    :error -> handle_without(message)
  end
end

Configuring extensions

Server side, pass :extensions to A2A.Plug. The configured declarations are merged into capabilities.extensions on the served agent card, and the negotiation pipeline runs around every JSON-RPC dispatch.

# Standalone with Bandit
Bandit.start_link(
  plug: {A2A.Plug,
    agent: MyAgent,
    base_url: "http://localhost:4000",
    extensions: [A2A.Extension.Timestamp, {MyApp.Passport, issuer: "acme"}]}
)

# Or in a Phoenix router
forward "/a2a", A2A.Plug,
  agent: MyAgent,
  base_url: "http://localhost:4000/a2a",
  extensions: [A2A.Extension.Timestamp]

Client side, pass :extensions to A2A.Client.new/2. The configured URIs are sent in the A2A-Extensions request header on every call. A2A.Client.parse_extensions_header/1 and A2A.Client.activated/2 read the server's response header to discover which extensions actually ran.

client = A2A.Client.new("http://localhost:4000",
  extensions: [A2A.Extension.Timestamp])

{:ok, task} = A2A.Client.send_message(client, "hi")
task.metadata[A2A.Extension.Timestamp.uri()]
#=> %{"received_at" => ..., "completed_at" => ...}

Reference

A2A.Extension.Timestamp ships in-tree as a complete profile-extension example covering declaration/1, activate/3, handle_request/3, and handle_response/3. See examples/extensions.exs for an end-to-end runnable demo.

Summary

Types

An extension's per-request activation value (opaque to the framework).

Ordered list of activations for hook chaining.

Internal compiled extension entry. Pipeline state: the module, its init state, and its (cached) declaration.

User-facing extension configuration entry.

An extension's init state (returned from init/1).

Callbacks

Called once per request when the client has declared this extension in its A2A-Extensions header. Returns the activation value that is threaded through subsequent hooks, or :skip to opt out for this request, or {:error, error} to abort the request.

Returns the %A2A.AgentExtension{} describing this extension. The URI is used both for agent-card advertisement and for matching against the A2A-Extensions request header.

Optional. Mutate the inbound message or JSON-RPC params before dispatch. Runs in declaration order across activated extensions.

Optional. Mutate the outbound task or message before encoding. Runs in declaration order across activated extensions.

Validates and compiles the extension's options. Called once when the parent plug or client is initialised. The returned value is threaded back into the remaining callbacks as the state argument.

Functions

Runs activate/3 for each configured extension whose declared URI is in the requested list. Returns an ordered list of {module, activation, uri} tuples and the matching URI list, or an error if any extension's activate/3 returned {:error, ...}.

Returns true if the given extension module is activated in the context.

Returns the declarations of all compiled extensions, in declaration order.

Returns the URIs declared by all compiled extensions, in declaration order.

Fetches the activation value for the given extension module from a request context. Returns {:ok, activation} or :error.

Parses a list of raw A2A-Extensions header values (HTTP allows multiple values, each potentially comma-separated) into a deduplicated list of requested URIs.

Convenience for namespacing a value under an extension's URI inside the metadata field of a Message, Artifact, or Task.

Returns the URIs of declarations that are marked required: true.

Runs the handle_request/3 chain over an activated extension list. Returns possibly-mutated message, params, and the (possibly-updated) activations list, or {:error, error} if any extension aborted.

Runs the handle_response/3 chain over an activated extension list. Returns the possibly-mutated task or message and the updated activations list.

Converts the ordered activations list into the %{uri => activation} map exposed to the agent via context.extensions.

Validates required-extension presence against the client's requested URIs. Returns :ok or {:error, missing_uris}.

Types

activation()

@type activation() :: term()

An extension's per-request activation value (opaque to the framework).

activations()

@type activations() :: [{module(), activation(), String.t()}]

Ordered list of activations for hook chaining.

compiled()

@type compiled() :: {module(), state(), A2A.AgentExtension.t()}

Internal compiled extension entry. Pipeline state: the module, its init state, and its (cached) declaration.

config_entry()

@type config_entry() :: module() | {module(), keyword()}

User-facing extension configuration entry.

state()

@type state() :: term()

An extension's init state (returned from init/1).

Callbacks

activate(requested_uris, ctx, state)

(optional)
@callback activate(requested_uris :: [String.t()], ctx :: map(), state()) ::
  {:ok, activation()} | :skip | {:error, A2A.JSONRPC.Error.t()}

Called once per request when the client has declared this extension in its A2A-Extensions header. Returns the activation value that is threaded through subsequent hooks, or :skip to opt out for this request, or {:error, error} to abort the request.

Defaults to {:ok, nil} (always activate, no per-request state).

declaration(state)

@callback declaration(state()) :: A2A.AgentExtension.t()

Returns the %A2A.AgentExtension{} describing this extension. The URI is used both for agent-card advertisement and for matching against the A2A-Extensions request header.

handle_request(t, params, activation)

(optional)
@callback handle_request(A2A.Message.t(), params :: map(), activation()) ::
  {:ok, A2A.Message.t(), params :: map(), activation()}
  | {:error, A2A.JSONRPC.Error.t()}

Optional. Mutate the inbound message or JSON-RPC params before dispatch. Runs in declaration order across activated extensions.

handle_response(arg1, params, activation)

(optional)
@callback handle_response(A2A.Task.t() | A2A.Message.t(), params :: map(), activation()) ::
  {:ok, A2A.Task.t() | A2A.Message.t(), activation()}

Optional. Mutate the outbound task or message before encoding. Runs in declaration order across activated extensions.

init(opts)

(optional)
@callback init(opts :: keyword()) :: state()

Validates and compiles the extension's options. Called once when the parent plug or client is initialised. The returned value is threaded back into the remaining callbacks as the state argument.

Defaults to ignoring opts and returning nil.

Functions

activate(compiled, requested, ctx)

@spec activate([compiled()], [String.t()], map()) ::
  {:ok, activations(), [String.t()]} | {:error, A2A.JSONRPC.Error.t()}

Runs activate/3 for each configured extension whose declared URI is in the requested list. Returns an ordered list of {module, activation, uri} tuples and the matching URI list, or an error if any extension's activate/3 returned {:error, ...}.

activated?(ctx, module)

@spec activated?(map(), module()) :: boolean()

Returns true if the given extension module is activated in the context.

declarations(compiled)

@spec declarations([compiled()]) :: [A2A.AgentExtension.t()]

Returns the declarations of all compiled extensions, in declaration order.

declared_uris(compiled)

@spec declared_uris([compiled()]) :: [String.t()]

Returns the URIs declared by all compiled extensions, in declaration order.

fetch(arg1, module)

@spec fetch(map(), module()) :: {:ok, activation()} | :error

Fetches the activation value for the given extension module from a request context. Returns {:ok, activation} or :error.

parse_header(value)

@spec parse_header([String.t()] | String.t() | nil) :: [String.t()]

Parses a list of raw A2A-Extensions header values (HTTP allows multiple values, each potentially comma-separated) into a deduplicated list of requested URIs.

put_metadata(msg, module, value)

@spec put_metadata(
  A2A.Message.t() | A2A.Artifact.t() | A2A.Task.t(),
  module(),
  term()
) ::
  A2A.Message.t() | A2A.Artifact.t() | A2A.Task.t()

Convenience for namespacing a value under an extension's URI inside the metadata field of a Message, Artifact, or Task.

required_uris(compiled)

@spec required_uris([compiled()]) :: [String.t()]

Returns the URIs of declarations that are marked required: true.

run_request(activations, message, params)

@spec run_request(activations(), A2A.Message.t(), map()) ::
  {:ok, A2A.Message.t(), map(), activations()} | {:error, A2A.JSONRPC.Error.t()}

Runs the handle_request/3 chain over an activated extension list. Returns possibly-mutated message, params, and the (possibly-updated) activations list, or {:error, error} if any extension aborted.

run_response(activations, task, params)

@spec run_response(activations(), A2A.Task.t() | A2A.Message.t(), map()) ::
  {:ok, A2A.Task.t() | A2A.Message.t(), activations()}

Runs the handle_response/3 chain over an activated extension list. Returns the possibly-mutated task or message and the updated activations list.

to_context_map(activations)

@spec to_context_map(activations()) :: %{required(String.t()) => activation()}

Converts the ordered activations list into the %{uri => activation} map exposed to the agent via context.extensions.

validate_required(compiled, requested)

@spec validate_required([compiled()], [String.t()]) :: :ok | {:error, [String.t()]}

Validates required-extension presence against the client's requested URIs. Returns :ok or {:error, missing_uris}.