Skip to main content

Agent-to-Agent (A2A)

The Agent-to-Agent (A2A) protocol lets agents from different frameworks call one another over HTTP. Chronos is both an A2A server (expose a Chronos agent so external clients — ADK, LangGraph, DeepAgents, or another Chronos — can discover and invoke it) and an A2A client (delegate a task to a remote agent and stream the result back, the same way you'd spawn a local subagent).

Protocol surface​

All routes are mounted under /a2a:

MethodPathPurpose
GET/a2a/agentFetch the agent card (capabilities/skills)
POST/a2a/tasksCreate (delegate) a task
GET/a2a/tasks/{id}Poll task status/result
GET/a2a/tasks/{id}/streamStream task updates as Server-Sent Events
DELETE/a2a/tasks/{id}Cancel a task

A task moves through pending → running → completed | failed | cancelled.

Server: expose a Chronos agent​

An a2a.Server pairs an agent card with a handler that does the work. Build the card straight from a skill registry so peers discover exactly what the agent can do:

import (
"context"
"net/http"
"strings"

"github.com/spawn08/chronos/sdk/protocol/a2a"
"github.com/spawn08/chronos/sdk/skill"
)

func doWork(input string) string {
return strings.ToUpper(input) // your agent run
}

func newA2AServer() *a2a.Server {
skills := skill.NewRegistry()
skills.Register(&skill.Skill{Name: "summarize", Version: "1.0"})

card := a2a.CardFromSkills("chronos-agent", "A Chronos A2A agent", "1.0", skills)

srv := a2a.NewServer(card, func(ctx context.Context, task *a2a.Task) error {
task.Output = doWork(task.Input) // your agent run
return nil
})
http.Handle("/a2a/", srv) // or mount on the control plane — see below
return srv
}

NewServer uses an in-memory store: simple, but tasks are lost on restart.

Durable, resumable tasks​

For long-running work, back tasks with the durable queue (engine/queue) so they survive restarts and are re-leased if a worker dies (orphan recovery). The task record is persisted as a per-tenant checkpoint via storage.Storage.

import (
"context"
"time"

"github.com/spawn08/chronos/engine/queue"
"github.com/spawn08/chronos/sdk/protocol/a2a"
"github.com/spawn08/chronos/storage"
)

// q is the *queue.Queue instance (named to avoid shadowing the queue package).
func newDurableA2AServer(ctx context.Context, q *queue.Queue, store storage.Storage, card a2a.AgentCard, handler a2a.Handler) *a2a.Server {
ds := a2a.NewDurableStore(q, store, handler)

// A worker drives execution; a reaper recovers orphaned tasks after a crash.
w, _ := queue.NewWorker(q, ds.Executor, queue.WorkerConfig{ID: "a2a-worker-1"})
go func() { _ = w.Run(ctx) }()
go func() { _ = queue.NewReaper(q, time.Second).Run(ctx) }()

return a2a.NewServerWithStore(card, ds)
}

The two backends implement the same a2a.TaskStore interface, so the HTTP surface is identical either way.

Serving on the control plane (auth + tenancy)​

Mount the server on ChronosOS with WithA2A to put it behind the auth middleware chain and scope every task to the caller's tenant:

import (
chronosos "github.com/spawn08/chronos/os"
"github.com/spawn08/chronos/os/auth"
"github.com/spawn08/chronos/sdk/protocol/a2a"
"github.com/spawn08/chronos/storage"
)

func newControlPlane(store storage.Storage, apiKeyCfg auth.APIKeyConfig, a2aServer *a2a.Server) *chronosos.Server {
return chronosos.NewWithOptions(":8420", store,
chronosos.WithAPIKeyAuth(apiKeyCfg),
chronosos.WithA2A(a2aServer), // a2aServer is an *a2a.Server (an http.Handler)
)
}
  • Requests to /a2a/* require authentication (the route is not exempt).
  • The tenant is derived from the authenticated principal, never from client input. A task created by one tenant is invisible to another — a cross-tenant GET /a2a/tasks/{id} resolves to 404, closing the IDOR.

Client: delegate to a remote agent​

import (
"context"
"fmt"

"github.com/spawn08/chronos/sdk/protocol/a2a"
)

func delegateToRemote(ctx context.Context) {
client := a2a.NewClient("https://peer.example.com")

card, _ := client.GetAgentCard(ctx) // discover
task, _ := client.CreateTask(ctx, "do X", nil) // delegate
fmt.Println(card.Name)

// Stream updates until the task reaches a terminal state.
tasks, errs := client.StreamTask(ctx, task.ID)
for snap := range tasks {
fmt.Println(snap.Status, snap.Output)
}
if err := <-errs; err != nil { /* ... */ }
}

WaitForCompletion is a polling alternative when you don't need incremental updates.

Remote agent as a tool (delegated subagent)​

NewRemoteAgentTool adapts a remote A2A agent into a tool.Definition, so a Chronos model can delegate a sub-task to it — composing with the subagent model. The tool submits the task, prefers the streamed result (falling back to polling for peers without a stream endpoint), and returns only the remote agent's final output, so its intermediate work never enters the caller's context.

import (
"github.com/spawn08/chronos/engine/tool"
"github.com/spawn08/chronos/sdk/agent"
"github.com/spawn08/chronos/sdk/protocol/a2a"
)

func withDelegateTool(client *a2a.Client) (*agent.Agent, error) {
delegate := a2a.NewRemoteAgentTool(
"research_agent",
"Delegate a research task to the remote research agent.",
client,
a2a.WithPermission(tool.PermRequireApproval), // gate outbound delegation
)
return agent.New("assistant", "Assistant").AddTool(delegate).Build()
}

Example​

A complete, key-free round-trip (durable server + discover + delegate + stream + tool delegation) lives in examples/a2a_interop/main.go:

go run ./examples/a2a_interop/