Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ a2a task get -a https://agent.example.com <task-id> --wait --timeout 60s
a2a catalog add corp https://acme.com
a2a catalog search finance
a2a send -a urn:air:acme.com:agent:finance "What were last quarter's expenses?"

# Save an agent you trust as a contact and address it by name
a2a contact add fin urn:air:acme.com:agent:finance
a2a send -a fin "Hello again"
```

See the **[full command reference](./internal/README.md)** for all commands, flags, configuration, and server mode.
Expand Down Expand Up @@ -128,6 +132,8 @@ Prefer a plugin-aware client? The same skill ships as an [Agent Plugin](https://

Like the skill, it drives the `a2a` binary — [install the CLI](#installation) first.

In Claude Code the plugin also adds hooks that tell the agent about your [contacts](./internal/README.md#contacts), and about A2A agents found on sites that come up in the conversation. Agents found this way are marked unverified until you save them as contacts.

## About the Project

Several community and SDK-provided A2A CLIs existed across languages, but they diverged in command names, flags, output shapes, and transport handling. This project reconciles them into one **standardized** CLI — built for long-term stability and cross-transport consistency. It builds on the CLI from the [A2A Go SDK](https://github.com/a2aproject/a2a-go).
Expand Down
23 changes: 22 additions & 1 deletion agent-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ An [Agent Plugin](https://agent-plugins.org/) that teaches an AI agent to drive

## Requires the `a2a` binary

The plugin carries the agent-facing skill only. Install `a2a` separately:
The plugin carries the agent-facing skill and hooks only. Install `a2a` separately:

- **Homebrew (macOS / Linux):** `brew tap a2aproject/a2a-cli https://github.com/a2aproject/a2a-cli && brew install a2a`
- **WinGet (Windows):** `winget install a2aproject.a2acli`
Expand Down Expand Up @@ -38,6 +38,8 @@ npx skills add https://github.com/a2aproject/agent-plugins/tree/main/plugins/a2a
a2a-cli/
├── plugin.json # Agent Plugins 1.0.0 manifest
├── LICENSE
├── hooks/
│ └── hooks.json # Claude Code hooks running `a2a hook`
└── skills/
└── a2a-cli/
└── SKILL.md
Expand All @@ -49,6 +51,25 @@ Ask your agent for A2A work and it loads the skill on its own, for example:

> Fetch the agent card at `http://localhost:8080` and send it "summarize this repo".

## Contacts and agent discovery (Claude Code)

The hooks run `a2a hook`, which tells your agent which A2A agents it can delegate to:

- **Contacts.** Agents you saved with `a2a contact add <name> <ref>` are listed as trusted at the start of a session, after a compaction, and again whenever the contact book changes. The agent can delegate to them with `a2a send -a <name>`.
- **Discovered agents.** When a URL appears in a prompt or in a `WebFetch` or `WebSearch` call, `a2a` looks for an agent card at that site's `/.well-known/agent-card.json`. Agents it finds are listed as unverified, and the agent is told to ask you before delegating to them.

Agents in AI Catalogs you registered with `a2a catalog add` are not listed, since a catalog can list hundreds; the agent finds them with `a2a catalog search`.

The hooks don't make network requests, so they don't slow down a turn. A detached background process fetches cards for sites it hasn't seen, and the agent hears about them at its next step. Cards are cached under the user cache directory (`~/.cache/a2a-cli` on Linux), and each agent is listed once per session.

Discovery probes public and internal hosts. It never connects to link-local addresses such as cloud metadata endpoints, and it doesn't follow a redirect from a public site to an internal address. To make an exception for a host, list it in `~/.config/a2a-cli/config.yaml`:

```yaml
discovery-allow-host: [agents.internal.example]
```

To turn discovery off and keep the contacts, set `discovery: false` in the same file, or `A2ACLI_DISCOVERY=false` in the environment. To scan other tools' calls for URLs, set `discovery-scan-tools`.

## Versioning

The plugin `version` matches the a2a-cli release it was built from. The bundled skill keeps its own date-based `metadata.version`.
1 change: 1 addition & 0 deletions agent-plugin/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ for skill_md in "$root"/skills/*/SKILL.md; do
rm -f "$out/skills/$name/README.md"
done

cp -R "$root/agent-plugin/hooks" "$out/hooks"
cp "$root/agent-plugin/README.md" "$out/README.md"
cp "$root/LICENSE" "$out/LICENSE"
jq --arg v "$version" '{"$schema": ."$schema", name: .name, version: $v} + .' \
Expand Down
26 changes: 26 additions & 0 deletions agent-plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"description": "Tell the agent about your a2a contacts and about A2A agents discovered on sites in the conversation",
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "a2a", "args": ["hook"], "timeout": 5 }
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "a2a", "args": ["hook"], "timeout": 5 }
]
}
],
"PostToolBatch": [
{
"hooks": [
{ "type": "command", "command": "a2a", "args": ["hook"], "timeout": 5 }
]
}
]
}
}
51 changes: 50 additions & 1 deletion internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ These apply to every client-mode command. Each command selects the agent it talk

| Flag | Short | Description |
|---|---|---|
| `--agent-card <ref>` | `-a` | Agent Card reference: a host/origin (the well-known path is appended), a full card URL, a local file path, or the identifier of an agent in an [AI Catalog](#ai-catalogs-experimental). The card is resolved and a transport negotiated. |
| `--agent-card <ref>` | `-a` | Agent Card reference: a host/origin (the well-known path is appended), a full card URL, a local file path, a [contact](#contacts) name, or the identifier of an agent in an [AI Catalog](#ai-catalogs-experimental). The card is resolved and a transport negotiated. |
| `--catalog <name\|url>` | | AI Catalog to look the `--agent-card` identifier up in: a registered catalog name, or a catalog URL or path. Experimental. |
| `--endpoint <ref>` | `-e` | Agent interface URL for a direct connection, skipping card resolution. Must be paired with exactly one `--transport`. Mutually exclusive with `--agent-card`. |
| `--transport <name>` | | Transport preference: `rest`, `jsonrpc`, `grpc`. Repeatable and ordered (highest preference first). With `--agent-card` it overrides the card's preference order; with `--endpoint` exactly one is required. |
Expand Down Expand Up @@ -326,13 +326,62 @@ a2a send -a https://acme.com/finance-catalog.json "..."
```

- A `urn:` value of `--agent-card` is an identifier, unless a file with that name exists. With `--catalog`, any value is an identifier.
- An identifier of a [contact](#contacts) resolves to the contact before the registered catalogs are searched.
- Lookups in registered catalogs read only the local store; run `a2a catalog update` to pick up changes.
- An identifier listed by more than one catalog is an error; pick one with `--catalog`.
- When a catalog lists several versions of an agent, the latest is used.
- A URL passed to `--agent-card` that serves a catalog instead of a card resolves only when the catalog lists exactly one A2A agent; otherwise the error lists the agents.

---

## Contacts

The contact book stores named agents you trust. It is an AI Catalog at `~/.config/a2a-cli/catalog.json` that only `a2a` writes, registered in the shared [AI Catalog store](#ai-catalogs-experimental) as `a2a-contacts`, so `ai-catalog search` and `a2a catalog search` see your contacts too. Other tools keep their own curated catalogs and register them the same way.

```bash
a2a contact add weather https://weather.example.com # any --agent-card reference
a2a contact add fin urn:air:acme.com:agent:finance --note "Expense reports"
a2a contact add travel --catalog https://acme.com/ai-catalog.json urn:air:acme.com:agent:travel
a2a contact list # no requests are made
a2a contact get fin # contact details and its card (--refresh to fetch it again)
a2a send -a fin "Where is report R-2041?"
a2a contact remove fin
```

- A contact name can be passed to `--agent-card` (`-a`) or `card get` in place of a card reference. Names use lowercase letters, digits, `-` and `_`, so they never look like a host, URL, path or identifier.
- An agent from a catalog is copied with its identifier, publisher and trust manifest, so its signature can still be verified. An agent added by its card gets the identifier `urn:ai-catalog:a2a-cli:<name>`.
- A contact's identifier takes precedence over the same identifier in registered catalogs, so `-a <identifier>` resolves to the contact.
- Two contacts can't share an identifier. Pass `--replace` to overwrite a contact of the same name.
- `contact add` fetches the card first and fails if it can't, using the same credentials, `--svc-param` headers and `--ca-cert` as `card get`. Fetched cards are cached under the user cache directory (`~/.cache/a2a-cli` on Linux).
- If the AI Catalog store can't be updated, the contact is still saved and `a2a` prints a warning.

### Agent harness hooks

`a2a hook` is a hidden command for agent harness hooks. It reads a hook event as JSON on stdin and prints the context to add in the Claude Code hook output format. The [Agent Plugin](../agent-plugin/) registers it for `SessionStart`, `UserPromptSubmit` and `PostToolBatch`.

- On `SessionStart` it lists your contacts as trusted agents. After a compaction it also lists the agents discovered earlier in the session.
- When the contact book changes during a session, the next event lists the contacts again.
- It probes the origins of URLs in prompts and in `WebFetch` and `WebSearch` calls (`discovery-scan-tools`) for an agent card. Agents it finds are listed as unverified, and the model is told to ask the user before delegating to them.
- Agents in registered AI Catalogs are not listed, since a catalog can list hundreds. The model finds them with `a2a catalog search`.

The hook itself makes no network requests. Origins without a cached card are probed by a detached `a2a hook probe` process, and agents it finds are reported by a later event. Each agent is reported once per session. Found cards are cached for 24 hours, and origins without a card for 6 hours. A connection failure, a server error or a rate limit isn't cached, so the origin is probed again when it's next mentioned. A document at the well-known path without an agent name and interface doesn't count as a card.

| Setting | Default | Description |
|---|---|---|
| `discovery` | `true` | Probe origins of URLs in the conversation. Set `false` (or `A2ACLI_DISCOVERY=false`) to keep only the contacts. |
| `discovery-allow-host` | | Hosts discovery may probe whatever address they resolve to, and reach by any redirect. |
| `discovery-scan-tools` | `WebFetch,WebSearch` | Tools whose calls are scanned for URLs. |

Discovery fetches URLs that only appeared in a conversation, so it limits where a probe can connect:

- It probes internal hosts (loopback, private and carrier-grade NAT addresses) as well as public ones.
- It never connects to link-local addresses, such as cloud metadata endpoints, or to unspecified or multicast addresses.
- It follows at most 3 redirects, and refuses a redirect from a public address to an internal one, so a public site can't point a probe into your network.

The checks run on the address a probe connects to, so DNS rebinding can't get around them, and probes don't reuse connections, so every redirect hop is checked. Probes use the proxy from `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`. A proxied target is checked against its address as resolved locally, and a name that doesn't resolve locally is treated as public. The proxy resolves the name again on its own, so for proxied requests the checks are best effort. A refused probe isn't cached, so a change to `discovery-allow-host` applies at once. `discovery-allow-host` is not read from a `.env` found by walking up from the working directory.

---

## Credentials

A credential helper is a program that returns the HTTP headers to send to an agent. a2a implements the [Bazel credential helper protocol](https://github.com/EngFlow/credential-helper-spec), so helpers written for Bazel work unchanged: a2a runs `<command> get`, writes `{"uri": "<agent URL>"}` to its stdin, and reads `{"headers": {"Authorization": ["Bearer …"]}, "expires": "<RFC 3339>"}` from its stdout.
Expand Down
6 changes: 3 additions & 3 deletions internal/aicatalog/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ func (r *Registry) fetch(ctx context.Context, f *Fetcher, sourceURL string) (*Re
if err != nil {
return nil, err
}
doc, err := catalog.Parse(data)
doc, err := parseDocument(data)
if err != nil {
return nil, fmt.Errorf("%s is not an AI Catalog: %w", sourceURL, err)
}
Expand Down Expand Up @@ -216,7 +216,7 @@ func (r *Registry) Entries(ctx context.Context, nameOrSource string) ([]Entry, [
warnings = append(warnings, fmt.Sprintf("skipping catalog %q: %v", e.Name, err))
continue
}
doc, err := catalog.Parse(data)
doc, err := parseDocument(data)
if err != nil {
warnings = append(warnings, fmt.Sprintf("skipping catalog %q: %v", e.Name, err))
continue
Expand All @@ -232,7 +232,7 @@ func (r *Registry) Entries(ctx context.Context, nameOrSource string) ([]Entry, [
// Load fetches the catalog document data was served from at sourceURL, and
// the catalogs nested in it, without storing anything.
func Load(ctx context.Context, f *Fetcher, sourceURL string, data []byte) ([]Entry, []string, error) {
doc, err := catalog.Parse(data)
doc, err := parseDocument(data)
if err != nil {
return nil, nil, fmt.Errorf("%s is not an AI Catalog: %w", sourceURL, err)
}
Expand Down
43 changes: 5 additions & 38 deletions internal/aicatalog/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import (
"strings"

"github.com/Agent-Card/ai-catalog-go/catalog"

"github.com/a2aproject/a2a-cli/internal/atomicfile"
)

// EnvDir names the environment variable that overrides the store directory.
Expand Down Expand Up @@ -306,21 +308,15 @@ func (r *Registry) save() error {
if err != nil {
return err
}
if err := os.MkdirAll(r.store.Dir, 0o755); err != nil {
return fmt.Errorf("creating catalog store: %w", err)
}
return writeFileAtomic(r.store.path(registryFile), data)
return atomicfile.Write(r.store.path(registryFile), data, 0o644)
}

func (r *Registry) saveRefs() error {
data, err := json.MarshalIndent(r.refs, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(r.store.Dir, 0o755); err != nil {
return fmt.Errorf("creating catalog store: %w", err)
}
return writeFileAtomic(r.store.path(refsFile), data)
return atomicfile.Write(r.store.path(refsFile), data, 0o644)
}

func (e *registryEntry) set(c Catalog, objectURL string) error {
Expand Down Expand Up @@ -393,10 +389,7 @@ func (s *Store) writeObject(data []byte) (string, error) {
if _, err := os.Stat(path); err == nil {
return hash, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", fmt.Errorf("creating catalog store: %w", err)
}
if err := writeFileAtomic(path, data); err != nil {
if err := atomicfile.Write(path, data, 0o644); err != nil {
return "", err
}
return hash, nil
Expand All @@ -421,32 +414,6 @@ func isContentHash(s string) bool {
return err == nil
}

// writeFileAtomic replaces path with data, so a reader never sees a partial file.
func writeFileAtomic(path string, data []byte) (err error) {
f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
if err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
defer func() {
if err != nil {
err = errors.Join(err, os.Remove(f.Name()))
}
}()
if _, err := f.Write(data); err != nil {
return errors.Join(fmt.Errorf("writing %s: %w", path, err), f.Close())
}
if err := f.Chmod(0o644); err != nil {
return errors.Join(fmt.Errorf("writing %s: %w", path, err), f.Close())
}
if err := f.Close(); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
if err := os.Rename(f.Name(), path); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
return nil
}

func mustMarshal(v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
Expand Down
39 changes: 33 additions & 6 deletions internal/aicatalog/walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package aicatalog

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -125,6 +126,32 @@ type Entry struct {
// Base is the URL of the catalog document that lists the entry, against
// which a relative entry URL resolves.
Base string
// Raw is the entry as listed, with members the SDK does not model, so a
// copy keeps its trust manifest verifiable.
Raw json.RawMessage
}

// document is a parsed catalog with each entry as listed.
type document struct {
*catalog.AICatalog
rawEntries []json.RawMessage
}

func parseDocument(data []byte) (*document, error) {
doc, err := catalog.Parse(data)
if err != nil {
return nil, err
}
var raw struct {
Entries []json.RawMessage `json:"entries"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
if len(raw.Entries) != len(doc.Entries) {
return nil, fmt.Errorf("catalog has %d entries, parsed %d", len(raw.Entries), len(doc.Entries))
}
return &document{AICatalog: doc, rawEntries: raw.Entries}, nil
}

// walker collects the entries of a catalog and the catalogs nested in it. An
Expand All @@ -143,9 +170,9 @@ type walker struct {
warnings []string
}

func (w *walker) walk(ctx context.Context, doc *catalog.AICatalog, base string, depth int) {
for _, entry := range doc.Entries {
w.entries = append(w.entries, Entry{CatalogEntry: entry, Catalog: w.label, Base: base})
func (w *walker) walk(ctx context.Context, doc *document, base string, depth int) {
for i, entry := range doc.Entries {
w.entries = append(w.entries, Entry{CatalogEntry: entry, Catalog: w.label, Base: base, Raw: doc.rawEntries[i]})
if !entry.IsNestedCatalog() {
continue
}
Expand All @@ -171,9 +198,9 @@ func (w *walker) warn(entry catalog.CatalogEntry, err error) {
w.warnings = append(w.warnings, fmt.Sprintf("skipping nested catalog %q in %s: %v", entry.Identifier, w.label, err))
}

func (w *walker) open(ctx context.Context, entry *catalog.CatalogEntry, base string) (*catalog.AICatalog, string, error) {
func (w *walker) open(ctx context.Context, entry *catalog.CatalogEntry, base string) (*document, string, error) {
if len(entry.Data) > 0 {
doc, err := catalog.Parse(entry.Data)
doc, err := parseDocument(entry.Data)
return doc, base, err
}
if entry.URL == "" {
Expand All @@ -190,7 +217,7 @@ func (w *walker) open(ctx context.Context, entry *catalog.CatalogEntry, base str
if err != nil {
return nil, "", err
}
doc, err := catalog.Parse(data)
doc, err := parseDocument(data)
return doc, ref, err
}

Expand Down
Loading
Loading