a2a

package
v0.22.146 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 2, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package a2a — Agent2Agent protocol surface for clawtool (ADR-024). Phase 1 ships only the Agent Card serializer + the JSON shape the protocol wants advertised at /.well-known/agent-card.json. mDNS announce, HTTP server, peer discovery, and capability tier enforcement layer on top in phase 2+.

We adopt Google A2A's wire format (now Linux Foundation; github.com/a2aproject/A2A) verbatim rather than inventing one, per ADR-007 (wrap, don't reinvent). The Card describes *what* this clawtool instance can do (capabilities + skills + auth); it deliberately does NOT enumerate every aggregated tool — per A2A's opacity model, peers see the agent's contract, not its internal toolset.

Package a2a — peer inbox. The discovery half (registry.go) surfaces *who* is on the host; the inbox half (this file) is *how they talk*. Each peer has an in-memory mailbox; senders enqueue via POST /v1/peers/{id}/messages, recipients drain via GET /v1/peers/{id}/messages or `clawtool peer inbox`.

Wire shape mirrors repowire/repowire/protocol/messages.py (Query / Response / Notification / Broadcast) so a runtime hook polling once per UserPromptSubmit can surface pending messages as additionalContext without inventing its own format.

Persistence: each peer's inbox is mirrored to ~/.config/clawtool/peers.d/<peer_id>.inbox.json on every mutation. A daemon crash mid-flight loses at most the last in-flight message; the rest survive a restart. Soft cap at 256 messages per peer — overflow drops the OLDEST so a chatty sender can't OOM the daemon. New peers start empty.

Package a2a — peer registry. Phase 1 of ADR-024's local-mesh half: every running clawtool / claude-code / codex / gemini / opencode session on this host registers into a single in-memory table keyed on a stable peer_id, so `clawtool a2a peers` can surface the live roster.

Mirrors the shape of repowire/daemon/peer_registry.py (prassanna-ravishankar/repowire) — the reference implementation for the discovery half. Differences from repowire:

  • Identity tuple: (backend, path, session_id, tmux_pane). The runtime-supplied session_id (claude-code's hook payload `.session_id`, etc.) is the primary disambiguator so two parallel sessions in the same cwd register as separate peers. tmux_pane is the secondary key when no session id exists.
  • REST + 30s heartbeat instead of WebSocket transport. The real-time push notifications repowire offers via websocket are deferred to Phase 2; Phase 1 ships the registry + polling because it's a fraction of the LoC and covers 80% of the operator value (visibility, cross-pane discovery).

Persistence: ~/.config/clawtool/peers.json (LF-delimited JSON, 0600). Atomic temp+rename writes so a crash mid-write doesn't leave a corrupt state file. Lazy repair on every read sweeps peers whose declared `path` no longer exists.

Index

Constants

View Source
const CurrentProtocolVersion = "0.2.0"

CurrentProtocolVersion is the A2A spec snapshot we conform to. Bump in lockstep with upstream as their stable snapshots advance.

View Source
const HeartbeatStaleAfter = 60 * time.Second

HeartbeatStaleAfter — peers whose last_seen is older than this are flipped to PeerOffline on the next list. Matches the 30 s heartbeat cadence we recommend in the registration docs (one missed heartbeat = grace period; two missed = offline).

Variables

This section is empty.

Functions

func DefaultStatePath added in v0.22.36

func DefaultStatePath() string

DefaultStatePath returns ~/.config/clawtool/peers.json (or its XDG_CONFIG_HOME equivalent). Mirrors daemon.StatePath's convention so an operator inspecting the config dir sees daemon.json + peers.json side-by-side.

func PeersStateDir added in v0.22.36

func PeersStateDir() string

PeersStateDir returns the canonical ~/.config/clawtool/peers.d directory used by both the daemon (per-peer inbox files written by this package) and the CLI's `clawtool peer` verb (per-session id pointer files). One layout, one helper — exported so callers outside this package don't reinvent the path-resolution dance.

On-disk layout:

peers.d/<session>.id            — CLI's session→peer_id pointer
peers.d/<peer_uuid>.inbox.json  — daemon's per-peer mailbox

func SetGlobal added in v0.22.36

func SetGlobal(r *Registry)

SetGlobal installs the process-wide registry. Caller — typically internal/server/server.go's buildMCPServer — does this once at boot. Passing nil clears it (used by daemon shutdown).

Types

type Capabilities

type Capabilities struct {
	// Streaming: server-sent events for long-running tasks. We
	// have BIAM (TaskNotify) which is conceptually the same;
	// phase 2 wires the HTTP transport.
	Streaming bool `json:"streaming"`

	// PushNotifications: webhook delivery on task transitions.
	// Same primitive as TaskNotify but cross-network. Phase 3+.
	PushNotifications bool `json:"pushNotifications"`

	// StateTransitionHistory: peer can replay every task
	// state. BIAM stores envelope history; we'll expose it
	// when phase 2 ships the HTTP /tasks/{id} endpoint.
	StateTransitionHistory bool `json:"stateTransitionHistory"`
}

Capabilities is A2A's feature-flag block. We model only the flags clawtool currently cares about; A2A's spec allows additional vendor extensions.

type Card

type Card struct {
	// Name is the human-readable agent name shown in registries
	// and peer dashboards. We use clawtool's instance name when
	// the operator has set one; otherwise the bare project
	// name.
	Name string `json:"name"`

	// Description is one paragraph of plain text describing what
	// this agent does. Peers may render it in discovery UIs.
	Description string `json:"description"`

	// URL is the JSON-RPC endpoint base. Empty in phase 1
	// (Card-only mode); populated when phase 2 lands the HTTP
	// server.
	URL string `json:"url,omitempty"`

	// Version is the agent's product version (clawtool's
	// internal/version.Resolved()). NOT the A2A protocol version;
	// that's protocolVersion below.
	Version string `json:"version"`

	// ProtocolVersion is the A2A spec the card conforms to.
	// Follow upstream as it evolves; pinning here lets peers
	// negotiate.
	ProtocolVersion string `json:"protocolVersion"`

	// Capabilities is the feature flag block. We surface only
	// the streaming + push-notifications primitives clawtool
	// can actually serve today; future phases flip more on as
	// the implementation lands.
	Capabilities Capabilities `json:"capabilities"`

	// Skills enumerates the high-level abilities this agent
	// advertises. We don't dump every internal tool — that
	// would leak the operator's private surface and overflow
	// the card. Skills are *coarse* groupings (research,
	// code-edit, dispatch) the peer chooses from.
	Skills []Skill `json:"skills"`

	// DefaultInputModes / DefaultOutputModes — A2A's MIME-typed
	// I/O contract. clawtool speaks plain text + JSON-RPC; we
	// don't ship audio / image modes today.
	DefaultInputModes  []string `json:"defaultInputModes"`
	DefaultOutputModes []string `json:"defaultOutputModes"`

	// SecuritySchemes describes the auth modes this agent
	// accepts. Phase 1 advertises an empty schemes block (peers
	// can read the card but can't authenticate yet). Phase 2+
	// adds Bearer / OAuth schemes per ADR-024 §Threat model.
	SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty"`

	// PublishedAt is when this card snapshot was generated.
	// Useful for caches / freshness checks. Always UTC.
	PublishedAt time.Time `json:"publishedAt"`
}

Card is the canonical A2A Agent Card (Schema v0.2.x, the stable LF-A2A snapshot as of 2026-04). Field names match the spec verbatim — JSON-RPC clients consuming the card MUST be able to parse it without translation.

func NewCard

func NewCard(opts CardOptions) Card

NewCard builds the Card snapshot for this clawtool instance. Pure function — fields come from CardOptions + version.Resolved() + a static skill list. Caller serializes via json.Marshal.

func (Card) MarshalIndented

func (c Card) MarshalIndented() ([]byte, error)

MarshalIndented is the human-readable form for CLI output. Two-space indent matches GitHub Actions' workflow YAML convention so an operator copy-pasting the output into a gist / issue gets a readable layout.

func (Card) MarshalJSON

func (c Card) MarshalJSON() ([]byte, error)

MarshalJSON serializes the card to compact JSON — what an HTTP handler would write to /.well-known/agent-card.json. We use MarshalIndent for the CLI surface (`clawtool a2a card`) so humans can read it; the server-side path uses bare json.Marshal.

type CardOptions

type CardOptions struct {
	// Name overrides the default ("clawtool"); empty keeps default.
	Name string
	// URL is the JSON-RPC endpoint. Empty until phase 2 lands.
	URL string
	// ExtraSkills appends to the canonical skill list. Empty
	// gives just the canonical set.
	ExtraSkills []Skill
}

CardOptions carries the per-instance fields we don't want hard-coded into NewCard so a future supervisor can customise per dispatch (e.g. emit different skills depending on which instance is calling).

type Inbox added in v0.22.36

type Inbox struct {
	// contains filtered or unexported fields
}

Inbox is the per-peer message queue. One Inbox per registered peer; created lazily on first send. Methods are safe for concurrent calls — mu guards both the queue and the on-disk snapshot.

func (*Inbox) Drain added in v0.22.36

func (i *Inbox) Drain(peek bool) []Message

Drain returns every queued message and empties the inbox. Pass peek=true to read without consuming — the runtime's UserPromptSubmit hook uses peek to avoid losing messages if the recipient cancels the prompt.

func (*Inbox) Enqueue added in v0.22.36

func (i *Inbox) Enqueue(msg Message) Message

Enqueue appends `msg` to this inbox, capping to inboxCap and dropping the oldest if needed. Returns the persisted message (with assigned ID + timestamp when the caller didn't supply them). Idempotent on (FromPeer, Timestamp, Text) is NOT attempted — duplicate sends mean the sender retried; the recipient sees both.

type ListFilter added in v0.22.36

type ListFilter struct {
	Status  PeerStatus
	Path    string
	Backend string
	Circle  string
}

ListFilter narrows the result set returned by List. Empty fields are no-ops so callers can pass {Backend: "claude-code"} to see just claude peers.

type Message added in v0.22.36

type Message struct {
	ID            string      `json:"id"`
	Type          MessageType `json:"type"`
	FromPeer      string      `json:"from_peer"`
	ToPeer        string      `json:"to_peer,omitempty"` // omitted for broadcast
	Text          string      `json:"text"`
	CorrelationID string      `json:"correlation_id,omitempty"` // matches a prior query's ID
	Timestamp     time.Time   `json:"timestamp"`
}

Message is one envelope in the peer mesh.

type MessageType added in v0.22.36

type MessageType string

MessageType matches repowire's protocol/messages.py taxonomy. Locked at v0.22; new types are additive.

const (
	MsgQuery        MessageType = "query"        // expects a response
	MsgResponse     MessageType = "response"     // reply to a query (correlation_id required)
	MsgNotification MessageType = "notification" // fire-and-forget
	MsgBroadcast    MessageType = "broadcast"    // to all peers (to_peer ignored)
)

type Peer added in v0.22.36

type Peer struct {
	PeerID       string            `json:"peer_id"`
	DisplayName  string            `json:"display_name"`
	Path         string            `json:"path,omitempty"`
	Backend      string            `json:"backend"` // claude-code | codex | gemini | opencode | clawtool
	Circle       string            `json:"circle"`  // group name; defaults to tmux session or "default"
	Role         PeerRole          `json:"role"`
	Status       PeerStatus        `json:"status"`
	SessionID    string            `json:"session_id,omitempty"` // runtime-supplied session key (claude-code: hook payload .session_id)
	TmuxPane     string            `json:"tmux_pane,omitempty"`
	PID          int               `json:"pid,omitempty"`
	Metadata     map[string]string `json:"metadata,omitempty"`
	RegisteredAt time.Time         `json:"registered_at"`
	LastSeen     time.Time         `json:"last_seen"`
}

Peer is the single source of truth for one registered session. Field names are JSON-serialised verbatim so the wire shape (the `/v1/peers` endpoint) reflects the in-memory model directly.

type PeerRole added in v0.22.36

type PeerRole string

PeerRole differentiates dispatchers (orchestrators) from dispatchees (worker agents). Most peers are agents; an operator running multiple terminals manually flips one to orchestrator if they want it to coordinate the others.

const (
	RoleAgent        PeerRole = "agent"
	RoleOrchestrator PeerRole = "orchestrator"
)

type PeerStatus added in v0.22.36

type PeerStatus string

PeerStatus is the lifecycle marker every peer carries.

const (
	PeerOnline  PeerStatus = "online"
	PeerBusy    PeerStatus = "busy"
	PeerOffline PeerStatus = "offline"
)

type RegisterInput added in v0.22.36

type RegisterInput struct {
	DisplayName string            `json:"display_name"`
	Path        string            `json:"path,omitempty"`
	Backend     string            `json:"backend"`
	Circle      string            `json:"circle,omitempty"`
	Role        PeerRole          `json:"role,omitempty"`
	SessionID   string            `json:"session_id,omitempty"`
	TmuxPane    string            `json:"tmux_pane,omitempty"`
	PID         int               `json:"pid,omitempty"`
	Metadata    map[string]string `json:"metadata,omitempty"`
}

RegisterInput is the shape callers supply to Register. Mirrors the JSON body of POST /v1/peers/register so the HTTP handler is a thin marshaller.

type Registry added in v0.22.36

type Registry struct {
	// contains filtered or unexported fields
}

Registry is the process-wide peer table. One instance lives in the daemon for the lifetime of the process; constructed via NewRegistry which loads any persisted state.

func GetGlobal added in v0.22.36

func GetGlobal() *Registry

GetGlobal returns the process-wide registry, or nil when no daemon has set one. Read-side callers (CLI tools, MCP handlers) should nil-check; the returned value is safe for concurrent access.

func NewRegistry added in v0.22.36

func NewRegistry(statePath string) *Registry

NewRegistry constructs an empty registry, then attempts to load state from path. A missing / unreadable / corrupt file is non-fatal: we start with an empty table and log to stderr.

func (*Registry) Broadcast added in v0.22.36

func (r *Registry) Broadcast(msg Message) int

Broadcast enqueues `msg` into every currently-known peer's inbox (except the sender's own, identified by msg.FromPeer). Returns the count of recipients reached. Used by MsgBroadcast — one HTTP hit fans out to all live sessions.

func (*Registry) Deregister added in v0.22.36

func (r *Registry) Deregister(peerID string) (*Peer, error)

Deregister removes a peer outright. Used by SessionEnd hooks when the session is shutting down cleanly. Returns the removed peer (or nil) so callers can surface a "peer X went offline" event. Also drops the peer's inbox so deregistered sessions don't leave persisted mailboxes behind.

func (*Registry) DrainInbox added in v0.22.36

func (r *Registry) DrainInbox(peerID string, peek bool) []Message

DrainInbox returns the pending messages for peerID and clears them (or peeks, leaving them queued). Non-existent peers return an empty slice — the inbox is created lazily and an empty drain stays empty.

func (*Registry) Get added in v0.22.36

func (r *Registry) Get(peerID string) *Peer

Get returns one peer by ID, or nil when unknown. Pure read, no lazy-repair (the lazy sweep is List's job).

func (*Registry) Heartbeat added in v0.22.36

func (r *Registry) Heartbeat(peerID string, status PeerStatus) (*Peer, error)

Heartbeat refreshes a peer's last_seen + status. Returns nil-error / nil-peer when the peer_id is unknown; that's the "I just registered, then noticed my session ID was wrong" case — caller should re-register, not retry.

func (*Registry) List added in v0.22.36

func (r *Registry) List(filter ListFilter) []Peer

List returns every peer matching the filter. Lazy-repair runs inline: peers whose last_seen is older than HeartbeatStaleAfter flip to PeerOffline before the result is built; peers whose declared path no longer exists are dropped entirely. Sort order: online first, then by display_name lexicographic — so `clawtool a2a peers` reads top-down "currently active first".

func (*Registry) Register added in v0.22.36

func (r *Registry) Register(in RegisterInput) (*Peer, error)

Register adds a new peer (or refreshes an existing one with the same identity tuple) and returns the assigned peer_id. Idempotent: repeated calls with the same backend + path + tmux_pane + pubkey update the existing row's last_seen instead of creating a duplicate. Without this, every hook fire would multiply the peer table.

func (*Registry) Save added in v0.22.36

func (r *Registry) Save() error

Save persists the registry to its state path. Atomic via temp+rename so a crash mid-write doesn't leave a half-formed JSON. Idempotent — if dirty=false, no I/O happens.

func (*Registry) SaveAsync added in v0.22.39

func (r *Registry) SaveAsync()

SaveAsync runs Save on a background goroutine and tracks it on r.saves so test cleanup paths can drain in-flight writes via WaitForSaves before the state path's directory is removed. Use instead of `go r.Save()` from any handler that may execute under a t.TempDir-rooted state path.

func (*Registry) SendTo added in v0.22.36

func (r *Registry) SendTo(peerID string, msg Message) Message

SendTo enqueues `msg` into peerID's inbox. Returns the assigned message (with ID + timestamp). Caller must have validated peerID exists in the registry — the inbox creates lazily, so this would happily accept messages for a non-existent peer otherwise.

func (*Registry) WaitForSaves added in v0.22.39

func (r *Registry) WaitForSaves()

WaitForSaves blocks until every SaveAsync goroutine in flight has finished its atomicfile write. Tests using t.TempDir for the state path call this before letting the cleanup hook RemoveAll the dir; otherwise macOS's stricter unlinkat fails with "directory not empty" on the still-pending temp file.

type SecurityScheme

type SecurityScheme struct {
	Type        string `json:"type"`             // "http" | "oauth2" | "apiKey"
	Scheme      string `json:"scheme,omitempty"` // for http: "bearer" / "basic"
	Description string `json:"description,omitempty"`
}

SecurityScheme mirrors A2A's auth-scheme block. We expose only the fields clawtool's phase 2 will actually populate; A2A's full spec covers OAuth 2.1, mTLS, API key, etc.

type Skill

type Skill struct {
	ID          string   `json:"id"`
	Name        string   `json:"name"`
	Description string   `json:"description"`
	Tags        []string `json:"tags,omitempty"`
	// Examples are short prompts a peer can use to test the
	// skill. Optional. Keep them representative, not
	// exhaustive — the card is a contract, not a tutorial.
	Examples []string `json:"examples,omitempty"`
}

Skill is one coarse ability the agent advertises. A2A treats skills as the discovery primitive — a peer scanning a roster of cards looks at skill IDs to decide who can help.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL