Skip to content

Toolnexus.A2a.agent

Elixir · package toolnexus · SPEC §7A · elixir/lib/toolnexus/a2a.ex

@spec agent(keyword() | map()) :: map()
def agent(opts)
# opts: :card (required — the Agent Card URL)
# :headers — ${ENV_VAR} values expand at call time, never logged
# :timeout — overall poll budget in ms, default 300_000
# :poll_every — interval between GetTask polls in ms, default 1_000
# Toolnexus.agent/1 delegates here — the top-level shortcut.

Builds an Agent descriptor: a plain map pointing at a remote peer’s Agent Card URL. No I/O happens here — agent/1 is pure data assembly. Resolve the descriptor into callable tools with Toolnexus.A2a.agent_tools/1, which fetches the card and turns each advertised skill into a Toolnexus.Tool.

  • Calling one remote A2A peer you already know the card URL of — build the descriptor by hand and hand it to agent_tools/1, or register it live with Toolnexus.Toolkit.add_agent/3.
  • Wiring the descriptor into toolkit constructionToolnexus.create_toolkit!(agents: [...]) takes a list of these descriptors alongside mcp_config and skills_dir.
  • An authenticated peer — pass :headers with ${ENV_VAR} placeholders; they expand from the process environment at call time and are never logged, matching MCP’s remote-header contract.

agent/1 never touches the network — that happens in agent_tools/1, which is the function that actually resolves a descriptor into tools.

1. The smallest useful call — a descriptor, no network

Section titled “1. The smallest useful call — a descriptor, no network”
alias Toolnexus.A2a
ag = A2a.agent(card: "http://localhost:0/.well-known/agent-card.json")
true = ag.card == "http://localhost:0/.well-known/agent-card.json"
true = ag.headers == nil
true = ag.timeout == nil
true = ag.poll_every == nil
# The top-level shortcut builds the identical descriptor.
true = Toolnexus.agent(card: ag.card) == ag
IO.puts("ok: #{ag.card}")

2. The full round trip — our own served toolkit as the remote peer

Section titled “2. The full round trip — our own served toolkit as the remote peer”

One toolkit’s Toolnexus.Toolkit.serve/3 stands up a real A2A peer on loopback (its LLM call is stubbed via Client.create’s http_options: [plug: ...], so nothing leaves the box); the OTHER side calls it with agent/1 + agent_tools/1 over real HTTP.

alias Toolnexus.{A2a, Client, Context, Serve, Toolkit}
# The A2A wire is real loopback HTTP (via Req), unlike the plug-stubbed LLM call below.
Application.ensure_all_started(:req)
llm_plug = fn conn ->
{:ok, _raw, conn} = Plug.Conn.read_body(conn)
resp = %{
"choices" => [%{"message" => %{"role" => "assistant", "content" => "The sum is 5."}}],
"usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6}
}
conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp))
end
llm_client =
Client.create(
base_url: "http://localhost",
style: "openai",
model: "gpt-x",
api_key: "test-key",
http_options: [plug: llm_plug]
)
{:ok, peer_toolkit} =
Toolnexus.create_toolkit(
skills: [%{name: "calc", description: "Adds numbers", content: "Use the add tool."}],
builtins: false
)
handle = Toolkit.serve(peer_toolkit, "127.0.0.1:0", client: llm_client, a2a: %{name: "Calc Agent"})
# The OTHER side: point agent/1 at the peer's card and resolve its skills to tools.
[calc_tool] = A2a.agent_tools(A2a.agent(card: handle.url <> "/.well-known/agent-card.json", poll_every: 20))
true = calc_tool.name == "Calc_Agent_calc"
true = calc_tool.source == "a2a"
result = calc_tool.execute.(%{"task" => "add 2 and 3"}, %Context{})
false = result.is_error
true = result.output == "The sum is 5."
true = result.metadata.agent == "Calc Agent"
Serve.stop(handle)
IO.puts("ok: #{calc_tool.name} -> #{result.output}")

3. The full surface — ${ENV} header expansion against a bare Plug/Bandit peer

Section titled “3. The full surface — ${ENV} header expansion against a bare Plug/Bandit peer”

headers values are never resolved until the call — the server sees the expanded value, never the placeholder, and the env var is never logged.

alias Toolnexus.{A2a, Context}
Application.ensure_all_started(:req)
defmodule DocsAgentStub do
@behaviour Plug
import Plug.Conn
def init(agent), do: agent
def call(conn, agent) do
{:ok, body, conn} = read_body(conn)
case {conn.method, conn.request_path} do
{"GET", "/.well-known/agent-card.json"} ->
Agent.update(agent, &Map.put(&1, :seen_auth, get_req_header(conn, "authorization")))
card = %{
"name" => "Secure Agent",
"url" => Agent.get(agent, & &1.url),
"skills" => [%{"id" => "ping", "description" => "pings back"}]
}
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(card))
{"POST", _} ->
rpc = Jason.decode!(body)
result = %{
"id" => "t1",
"status" => %{"state" => "completed"},
"artifacts" => [%{"parts" => [%{"kind" => "text", "text" => "pong"}]}]
}
payload = %{"jsonrpc" => "2.0", "id" => rpc["id"], "result" => result}
conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(payload))
end
end
end
{:ok, sock} = :gen_tcp.listen(0, [])
{:ok, port} = :inet.port(sock)
:gen_tcp.close(sock)
url = "http://127.0.0.1:#{port}"
{:ok, agent_state} = Agent.start_link(fn -> %{url: url <> "/", seen_auth: nil} end)
{:ok, _pid} = Bandit.start_link(plug: {DocsAgentStub, agent_state}, scheme: :http, ip: {127, 0, 0, 1}, port: port)
System.put_env("TOOLNEXUS_DOCS_A2A_TOKEN", "sekret")
ag =
A2a.agent(
card: url <> "/.well-known/agent-card.json",
headers: %{"authorization" => "Bearer ${TOOLNEXUS_DOCS_A2A_TOKEN}"},
timeout: 5_000,
poll_every: 10
)
[tool] = A2a.agent_tools(ag)
result = tool.execute.(%{"task" => "ping"}, %Context{})
System.delete_env("TOOLNEXUS_DOCS_A2A_TOKEN")
false = result.is_error
true = result.output == "pong"
true = Agent.get(agent_state, & &1.seen_auth) == ["Bearer sekret"]
IO.puts("ok: expanded header reached the peer, never the placeholder")
Field Type What it is
card String.t() The Agent Card URL — required.
headers map() | nil ${ENV_VAR} values, expanded at call time, never logged.
timeout pos_integer() | nil Overall poll budget in ms. nilagent_tools/1’s default (300_000).
poll_every pos_integer() | nil Interval between GetTask polls in ms. nil ⇒ default (1_000).