Behaviour for defining A2A agents.
Agents are the primary abstraction in the A2A library. Each agent declares
its identity and capabilities via agent_card/0 and implements message
handling via handle_message/2.
Usage
defmodule MyApp.GreeterAgent do
use A2A.Agent,
name: "greeter",
description: "Greets users",
skills: [
%{id: "greet", name: "Greet", description: "Says hello", tags: []}
]
@impl A2A.Agent
def handle_message(message, _context) do
text = A2A.Message.text(message)
{:reply, [A2A.Part.Text.new("Hello, #{text}!")]}
end
endThe use macro accepts shorthand options for agent_card/0:
:name— agent name (required unlessagent_card/0is defined manually):description— agent description (default:""):version— agent version (default:"0.1.0"):skills— list of skill maps (default:[]):opts— additional keyword options (default:[])
Architecture
use A2A.Agent generates a full GenServer. The agent author only implements
the behaviour callbacks — the runtime manages task lifecycle, state
transitions, history accumulation, and persistence.
Internally, three modules collaborate:
A2A.Agent(this module) — the behaviour definition and theusemacro that generates GenServer client API and callbacks.- Agent.Runtime (internal) — pure functions for message processing.
Creates tasks, calls
handle_message/2, maps reply tuples to state transitions. Also handles task continuation (multi-turn) and stream wrapping. - Agent.State (internal) — the internal GenServer state struct. Holds the task map, context index, and optional task store reference. Provides helpers for task storage, retrieval, and state transitions.
Task Lifecycle
The runtime manages a task state machine so agent implementations don't have to. Each message creates (or continues) a task that progresses through:
:submitted → :working → :completed
→ :failed
→ :input_required → (new message) → :working → ...
→ :canceledThe reply from handle_message/2 determines the transition:
{:reply, parts}— creates an artifact, transitions to:completed{:input_required, parts}— transitions to:input_required, caller can continue the same task by passingtask_id:to the next call{:stream, enumerable}— stays:working, transitions to:completedwhen the caller fully consumes the stream{:error, reason}— transitions to:failed
Reply Parts and Status
The parts returned from handle_message/2 serve double duty: they are
appended to the task's history (as an agent message) and, for
{:input_required, parts}, set as the task's status.message.
These two fields have different audiences:
history— the full conversation transcript, used by agent logic for context on subsequent turns.status.message— a short prompt surfaced to the client UI, explaining why the task is paused and what input is needed.
Because both are populated from the same parts, keep
{:input_required, parts} concise and actionable:
# Good — short prompt that works as both history entry and status
{:input_required, [Part.Text.new("What size pizza?")]}
# Avoid — long explanation duplicated into status.message
{:input_required, [Part.Text.new("Here's our full menu... What size?")]}For {:reply, parts} and {:stream, enumerable}, the parts only go
into history and artifacts — status.message is left empty.
Multi-Turn Conversations
When an agent returns {:input_required, parts}, the task pauses. The
caller continues it by passing task_id: with the next message:
{:ok, task} = A2A.call(agent, "order pizza")
# task.status.state == :input_required
{:ok, task} = A2A.call(agent, "large", task_id: task.id)
# task.status.state may be :completed or :input_required againThe runtime appends each message to the task's history, so the agent
receives the full conversation in context.history.
Streaming
When an agent returns {:stream, enumerable}, the runtime wraps the
stream so that consuming it automatically finalizes the task:
{:ok, task, stream} = A2A.stream(agent, "count to 5")
Enum.each(stream, &IO.inspect/1)
# task is now :completed with an artifact containing all streamed partsPersistence
By default, tasks live in the GenServer's process state (in-memory map). For external persistence, pass a task store at startup:
MyAgent.start_link(task_store: {A2A.TaskStore.ETS, :my_table})The runtime writes every task update to both the internal map and the
external store. See A2A.TaskStore for the behaviour interface.
Starting an Agent
{:ok, pid} = MyAgent.start_link()
{:ok, task} = A2A.call(MyAgent, "hello")Or with options:
MyAgent.start_link(name: :my_agent, task_store: {A2A.TaskStore.ETS, :tasks})
Summary
Callbacks
Returns the agent's identity and capabilities.
Called when a task is canceled by the caller. Optional.
Handles an incoming message. This is the core agent logic.
Types
@type context() :: %{ task_id: String.t(), context_id: String.t() | nil, history: [A2A.Message.t()], metadata: map() }
@type reply() :: {:reply, [A2A.Part.t()]} | {:stream, Enumerable.t()} | {:input_required, [A2A.Part.t()]} | {:error, term()}
Callbacks
@callback agent_card() :: card()
Returns the agent's identity and capabilities.
Called when a task is canceled by the caller. Optional.
@callback handle_message(A2A.Message.t(), context()) :: reply()
Handles an incoming message. This is the core agent logic.