Skip to content

Toolnexus.Serve.build_agent_card

Elixir · package toolnexus · SPEC §7B · elixir/lib/toolnexus/serve.ex

@spec build_agent_card(map(), [Toolnexus.Skill.Info.t()], String.t()) :: map()
def build_agent_card(cfg, skills, url)
# cfg = the a2a profile map: %{name?, description?, version?, provider?, skills?: [String.t()]}
# skills = the toolkit's Skill.Info list (never raw Tools)
# url = the JSON-RPC POST endpoint peers will call (base <> "/")

Builds the exact map GET /.well-known/agent-card.json serves. Card skills[] always come from the toolkit’s skills (SKILL.md name + description), filtered to cfg[:skills] when given — never raw tools, so a peer only ever sees the capabilities you meant to advertise. Defaults apply when a field is omitted: name: "toolnexus-agent", description: "", version: "0.1.0", protocolVersion is always "0.3.0", capabilities: {streaming: false, pushNotifications: false}. provider is present only when cfg[:provider] was configured.

  • You want the card map without standing up a server — snapshot-test it, log it, or hand it to a discovery layer that doesn’t hit the HTTP endpoint.
  • Understanding exactly what a peer will see before you serve/3 — this is a pure function of your skills + config, so you can print it locally first.
  • Filtering which skills are advertised — pass cfg[:skills] (a list of skill names) to expose a subset of what the toolkit actually has, without changing the toolkit itself.

1. The smallest useful call — all defaults, no config

Section titled “1. The smallest useful call — all defaults, no config”
alias Toolnexus.Serve
card = Serve.build_agent_card(%{}, [], "http://localhost:4000/")
true = card["name"] == "toolnexus-agent"
true = card["description"] == ""
true = card["version"] == "0.1.0"
true = card["protocolVersion"] == "0.3.0"
true = card["capabilities"] == %{"streaming" => false, "pushNotifications" => false}
true = card["defaultInputModes"] == ["text"]
true = card["skills"] == []
false = Map.has_key?(card, "provider")
true = card["url"] == "http://localhost:4000/"
IO.puts("ok: #{card["name"]} v#{card["version"]}")

2. The realistic case — every skill advertised, plus a provider block

Section titled “2. The realistic case — every skill advertised, plus a provider block”
alias Toolnexus.Serve
skills = [
%Toolnexus.Skill.Info{name: "greeter", description: "Says hello nicely", location: "greeter/SKILL.md", content: "Wave."},
%Toolnexus.Skill.Info{name: "internal", description: "Not meant for peers", location: "internal/SKILL.md", content: "Hidden."}
]
cfg = %{
name: "docs-agent",
description: "an agent from the docs",
version: "2.0.0",
provider: %{organization: "deemwar", url: "https://deemwar.com"}
}
card = Serve.build_agent_card(cfg, skills, "http://127.0.0.1:9000/")
true = card["name"] == "docs-agent"
true = card["provider"] == %{"organization" => "deemwar", "url" => "https://deemwar.com"}
true = Enum.map(card["skills"], & &1["id"]) == ["greeter", "internal"]
true = hd(card["skills"]) == %{"id" => "greeter", "name" => "greeter", "description" => "Says hello nicely"}
IO.puts("ok: #{length(card["skills"])} skill(s) advertised, provider #{card["provider"]["organization"]}")

3. The full surface — filtered to a subset, then round-tripped through a real server

Section titled “3. The full surface — filtered to a subset, then round-tripped through a real server”
alias Toolnexus.{Client, Serve, Toolkit}
Application.ensure_all_started(:req)
skills = [
%Toolnexus.Skill.Info{name: "greeter", description: "Says hello nicely", location: "greeter/SKILL.md", content: "Wave."},
%Toolnexus.Skill.Info{name: "internal", description: "Not meant for peers", location: "internal/SKILL.md", content: "Hidden."}
]
# built directly: only "greeter" survives the filter
filtered = Serve.build_agent_card(%{skills: ["greeter"]}, skills, "http://x/")
true = Enum.map(filtered["skills"], & &1["id"]) == ["greeter"]
# the SAME filter, live over HTTP through Toolkit.serve/3
llm_client = Client.create(base_url: "http://127.0.0.1:1", style: "openai", model: "m", api_key: "k")
{:ok, tk} =
Toolnexus.create_toolkit(
skills: [
%{name: "greeter", description: "Says hello nicely", content: "Wave."},
%{name: "internal", description: "Not meant for peers", content: "Hidden."}
],
builtins: false
)
handle = Toolkit.serve(tk, "127.0.0.1:0", client: llm_client, a2a: %{skills: ["greeter"]})
served = Req.get!(url: handle.url <> "/.well-known/agent-card.json", retry: false).body
true = Enum.map(served["skills"], & &1["id"]) == ["greeter"]
Serve.stop(handle)
IO.puts("ok: filtered to #{length(served["skills"])} of 2 skills, matching build_agent_card/3 directly")
Field Default What it does
:name "toolnexus-agent" Card name.
:description "" Card description.
:version "0.1.0" Card version.
:provider omitted %{organization, url} — included only when set.
:skills all A list of skill names to filter the card’s skills[] to.
  • Toolnexus.Serve.start — publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent.
  • Toolnexus.Serve task_store — persist inbound A2A tasks so a suspended request survives a restart.
  • Toolnexus.McpServe — the inbound MCP profile: any MCP client can call your tools.