Skip to content

Toolkit.Serve — expose your toolkit as an A2A agent

Go · package github.com/muthuishere/toolnexus/golang · SPEC §7B · golang/serve.go

func (tk *Toolkit) Serve(addr string, opts ServeOptions) (*ServeHandle, error)
type ServeOptions struct {
Client *Client // fulfils Tasks via its run loop
A2A *A2AConfig // opt-in A2A profile — absent ⇒ no A2A routes mount
OnTask OnTask // fires on each Task's terminal state
MCP *MCPServeConfig // independent opt-in MCP profile, see ExposedMcpTools
OnCall OnCall
}

Serve stands up an HTTP server exposing this toolkit’s skills (never raw tools) as a real A2A agent: GET /.well-known/agent-card.json publishes the card, POST / answers JSON-RPC (SendMessage submits a Task and fulfils it asynchronously through Client.Run; GetTask polls it). Any A2A peer — including this same port’s own AgentTools — can call it.

Reach for Serve when you want your toolkit to be callable by other agents, not just by your own Client.Run loop — a specialist toolkit another team’s agent delegates to, a service mesh of toolnexus toolkits, or a toolkit fronting both A2A and MCP for different kinds of callers at once.

All three stand up a REAL local server on an ephemeral port ("127.0.0.1:0") with a stubbed LLM (httptest.Server standing in for the model, same pattern as Client.Run) — no external network, no real API key.

1. The smallest useful call — publish the card

Section titled “1. The smallest useful call — publish the card”
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func mockLLM(reply string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": reply}}},
"usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
})
}))
}
func main() {
llm := mockLLM("hi there")
defer llm.Close()
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: llm.URL, Style: toolnexus.StyleOpenAI, Model: "x", APIKey: "k"})
handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{
Client: client,
A2A: &toolnexus.A2AConfig{Name: "greeter-desk"},
})
if err != nil {
log.Fatal(err)
}
defer handle.Stop()
resp, err := http.Get(handle.URL + "/.well-known/agent-card.json")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var card map[string]any
_ = json.NewDecoder(resp.Body).Decode(&card)
if card["name"] != "greeter-desk" || card["protocolVersion"] != "0.3.0" {
log.Fatalf("unexpected card: %+v", card)
}
fmt.Println("ok: card published for", card["name"])
}

2. The realistic case — two toolkits, a real A2A round trip

Section titled “2. The realistic case — two toolkits, a real A2A round trip”

Toolkit A is Served; toolkit B is a completely separate Toolkit that resolves toolkit A’s card and calls it via this port’s own outbound AgentTools — the same shape a real external A2A peer would use.

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func mockLLM(reply string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": reply}}},
"usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
})
}))
}
func main() {
llm := mockLLM("42")
defer llm.Close()
skillsDir := filepath.Join(os.TempDir(), "toolnexus-docs-serve-a2a-2")
_ = os.MkdirAll(filepath.Join(skillsDir, "answer"), 0o755)
_ = os.WriteFile(filepath.Join(skillsDir, "answer", "SKILL.md"),
[]byte("---\nname: answer\ndescription: answers a question\n---\n\nAnswer it.\n"), 0o644)
defer os.RemoveAll(skillsDir)
// Toolkit A: served over A2A with one skill.
served, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{
Builtins: false, SkillsDir: []string{skillsDir},
})
if err != nil {
log.Fatal(err)
}
defer served.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: llm.URL, Style: toolnexus.StyleOpenAI, Model: "x", APIKey: "k"})
handle, err := served.Serve("127.0.0.1:0", toolnexus.ServeOptions{
Client: client,
A2A: &toolnexus.A2AConfig{Name: "answer-desk"},
})
if err != nil {
log.Fatal(err)
}
defer handle.Stop()
// Toolkit B: a fresh, unrelated toolkit that points AT toolkit A over HTTP.
caller, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{
Builtins: false,
Agents: []toolnexus.Agent{{
Card: handle.URL + "/.well-known/agent-card.json",
PollEvery: 5,
}},
})
if err != nil {
log.Fatal(err)
}
defer caller.Close()
res, err := caller.Execute(context.Background(), "answer-desk_answer", map[string]any{"task": "what is 6*7?"})
if err != nil || res.IsError || res.Output != "42" {
log.Fatalf("unexpected: %+v %v", res, err)
}
fmt.Println("ok:", res.Output, "(via a real local A2A round trip)")
}

3. The full surface — a served skill, OnTask, and a real skill answer

Section titled “3. The full surface — a served skill, OnTask, and a real skill answer”
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func mockLLM(reply string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": reply}}},
"usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
})
}))
}
func main() {
llm := mockLLM("TRANSCRIBED")
defer llm.Close()
skillsDir := filepath.Join(os.TempDir(), "toolnexus-docs-serve-a2a-skills")
_ = os.MkdirAll(filepath.Join(skillsDir, "hello-world"), 0o755)
_ = os.WriteFile(filepath.Join(skillsDir, "hello-world", "SKILL.md"),
[]byte("---\nname: hello-world\ndescription: says hello\n---\n\nSay hello.\n"), 0o644)
defer os.RemoveAll(skillsDir)
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{
Builtins: false, SkillsDir: []string{skillsDir},
})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: llm.URL, Style: toolnexus.StyleOpenAI, Model: "x", APIKey: "k"})
events := make(chan toolnexus.OnTaskEvent, 1)
handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{
Client: client,
A2A: &toolnexus.A2AConfig{
Name: "video-desk", Skills: []string{"hello-world"},
Provider: &toolnexus.A2AProvider{Organization: "acme", URL: "https://acme.example"},
},
OnTask: func(ev toolnexus.OnTaskEvent) { events <- ev },
})
if err != nil {
log.Fatal(err)
}
defer handle.Stop()
tools, err := toolnexus.AgentTools(context.Background(), toolnexus.Agent{
Card: handle.URL + "/.well-known/agent-card.json", PollEvery: 5,
})
if err != nil {
log.Fatal(err)
}
var hello *toolnexus.Tool
for i := range tools {
if tools[i].Name == "video-desk_hello-world" {
hello = &tools[i]
}
}
if hello == nil {
log.Fatal("expected the served skill to resolve as video-desk_hello-world")
}
res, err := hello.Execute(map[string]any{"task": "do it"}, &toolnexus.ToolContext{Ctx: context.Background()})
if err != nil || res.IsError || res.Output != "TRANSCRIBED" {
log.Fatalf("unexpected: %+v %v", res, err)
}
ev := <-events
if ev.State != "completed" {
log.Fatalf("expected OnTask to fire completed, got %q", ev.State)
}
fmt.Println("ok:", res.Output, "OnTask state =", ev.State)
}
Field Type What it does
Client *Client Fulfils each Task by running the prompt through this client’s loop.
A2A *A2AConfig Opt-in — Name, Description, Version, Provider, Skills filter, Store. Absent ⇒ no A2A routes.
OnTask OnTask Fires with {ID, Skill, Task, Result, State} on every terminal Task state.
MCP *MCPServeConfig Independent opt-in MCP profile at POST /mcp — see ExposedMcpTools.
OnCall OnCall Fires per inbound MCP tools/call.

ServeHandle{URL, Stop()} is returned; Close() aliases Stop(). A2AConfig.Store selects Task persistence — see NewFileTaskStore.

  • BuildAgentCard — Construct the Agent Card that advertises your name, skills and endpoint.
  • NewFileTaskStore — Persist inbound A2A tasks so a suspended request survives a restart.
  • ExposedMcpTools — The inbound MCP profile: any MCP client can call your tools.
  • agents.New — the outbound counterpart: call someone else’s served toolkit.