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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ a2a send -a https://agent.example.com --stream "Summarize this document"
# Send asynchronously and poll until completion
a2a send -a https://agent.example.com --async "Analyze this dataset"
a2a task get -a https://agent.example.com <task-id> --wait --timeout 60s

# Find agents in an AI Catalog and talk to one by its identifier (experimental)
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?"
```

See the **[full command reference](./internal/README.md)** for all commands, flags, configuration, and server mode.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/a2aproject/a2a-cli
go 1.26.0

require (
github.com/Agent-Card/ai-catalog-go v0.3.0
github.com/a2aproject/a2a-go v0.3.15
github.com/a2aproject/a2a-go/v2 v2.8.0
github.com/google/go-cmp v0.7.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/Agent-Card/ai-catalog-go v0.3.0 h1:DP49MyGN4WwdNLK4iClq0US8W3bnfDbaN4KBhOmqrUY=
github.com/Agent-Card/ai-catalog-go v0.3.0/go.mod h1:xMelpW5dWl60Fb6sRasAAvZdTMdbfDdPvncez+VwA9E=
github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI=
github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I=
github.com/a2aproject/a2a-go/v2 v2.8.0 h1:RmZBANuY8P9Pm1dfZ8gCY2yHAuVO7OnTFw8/kOZdCQQ=
Expand Down
40 changes: 38 additions & 2 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ 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, or a local file path. 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, 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. |
| `--insecure` | | Use plaintext for the built-in gRPC client. By default it uses TLS and verifies the server certificate against the system trust store, or the certificates in `--ca-cert`. |
Expand Down Expand Up @@ -156,7 +157,7 @@ To fetch the extended card (if supported):
a2a card get <url> --extended --auth "Bearer <token>"
```

The card reference may also be supplied via the global `-a, --agent-card` flag instead of the positional argument.
The card reference may also be supplied via the global `-a, --agent-card` flag instead of the positional argument. Like `--agent-card`, it may be the identifier of an agent in an [AI Catalog](#ai-catalogs-experimental).

### `send` - Send a Message

Expand Down Expand Up @@ -297,6 +298,41 @@ Streams events to stdout until the task reaches a terminal state. Output format

---

## AI Catalogs (experimental)

An [AI Catalog](https://ai-catalog.io/) lists AI artifacts, A2A agents among them. `a2a` can register catalogs and resolve `--agent-card` from the agents they list, so you can refer to an agent by its catalog identifier instead of a card URL.

Registered catalogs live in the store shared with [ai-catalog-cli](https://github.com/Agent-Card/ai-catalog-cli): `~/.ai-catalog`, or the directory in `AI_CATALOG_CACHE_DIR`. A catalog registered with either tool is visible to both. The store layout is not part of the AI Catalog specification, so these commands are experimental, and `a2a` warns about and refuses to modify a store whose format it does not recognize.

```bash
a2a catalog add corp https://acme.com # fetches https://acme.com/.well-known/ai-catalog.json
a2a catalog add local ./ai-catalog.json
a2a catalog list
a2a catalog search finance # A2A agents whose identifier, name, description or tags match
a2a catalog search --catalog https://acme.com/ai-catalog.json # search a catalog without registering it
a2a catalog update [name] # fetch one or every registered catalog again
a2a catalog remove corp
```

`search` lists only A2A agents (entries of type `application/a2a-agent-card+json`). Catalogs nested in a catalog are followed up to four levels deep: any catalog may reference `https` URLs, and only a local catalog may reference local files.

Pass an agent's identifier to `--agent-card`:

```bash
a2a send -a urn:air:acme.com:agent:finance "..." # across all registered catalogs
a2a send --catalog corp -a urn:air:acme.com:agent:finance "..." # in a registered catalog
a2a send --catalog https://acme.com/ai-catalog.json -a urn:air:acme.com:agent:finance "..." # in an unregistered catalog
a2a send -a https://acme.com/finance-catalog.json "..." # a catalog with a single A2A agent
```

- A `urn:` value of `--agent-card` is an identifier, unless a file with that name exists. With `--catalog`, any value is an identifier.
- 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.

---

## 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
219 changes: 219 additions & 0 deletions internal/aicatalog/agents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Copyright 2026 The A2A Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package aicatalog

import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"slices"
"strings"

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

// IsIdentifier reports whether an --agent-card value names a catalog entry
// rather than a host, URL or path: it is a URN and not an existing file.
func IsIdentifier(ref string) bool {
if len(ref) <= len("urn:") || !strings.EqualFold(ref[:len("urn:")], "urn:") {
return false
}
_, err := os.Stat(ref)
return err != nil
}

// NormalizeSource turns a catalog URL, local path or bare host into the URL it
// is fetched from. A URL without a path and a bare host get the well-known path.
func NormalizeSource(source string) (string, error) {
source = strings.TrimSpace(source)
if source == "" {
return "", errors.New("catalog source must not be empty")
}
if !strings.Contains(source, "://") {
if _, err := os.Stat(source); err == nil {
abs, err := filepath.Abs(source)
if err != nil {
return "", err
}
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}).String(), nil
}
source = "https://" + source
}
u, err := url.Parse(source)
if err != nil {
return "", fmt.Errorf("invalid catalog source %q: %w", source, err)
}
switch u.Scheme {
case "file":
return u.String(), nil
case "http", "https":
default:
return "", fmt.Errorf("unsupported catalog source %q: want an http, https or file URL, or a local path", source)
}
if u.Host == "" {
return "", fmt.Errorf("invalid catalog source %q: missing host", source)
}
if u.Path == "" || u.Path == "/" {
u.Path = catalog.WellKnownPath
}
return u.String(), nil
}

// IsAgent reports whether the entry is an A2A Agent Card.
func IsAgent(e *catalog.CatalogEntry) bool {
return e.Type == catalog.MediaTypeA2AAgentCard
}

// Agents returns the A2A agents among entries, each listed once per catalog.
func Agents(entries []Entry) []Entry {
type key struct{ catalog, id, version string }
seen := map[key]bool{}
var agents []Entry
for _, e := range entries {
k := key{e.Catalog, e.Identifier, e.Version}
if !IsAgent(&e.CatalogEntry) || seen[k] {
continue
}
seen[k] = true
agents = append(agents, e)
}
return agents
}

// Search returns the A2A agents whose identifier, display name, description or
// tags contain query, ignoring case. An empty query matches every agent.
func Search(entries []Entry, query string) []Entry {
query = strings.ToLower(query)
var found []Entry
for _, e := range Agents(entries) {
fields := append([]string{e.Identifier, e.DisplayName, e.Description}, e.Tags...)
if slices.ContainsFunc(fields, func(f string) bool { return strings.Contains(strings.ToLower(f), query) }) {
found = append(found, e)
}
}
return found
}

// NotFoundError reports an identifier that names no A2A agent in Scope.
type NotFoundError struct {
Identifier string
Scope string
// Type is the media type of an entry with the identifier that is not an A2A agent.
Type string
// Agents are the A2A agents that are in Scope.
Agents []Entry
}

func (e *NotFoundError) Error() string {
if e.Type != "" {
return fmt.Sprintf("%q in %s is a %s entry, not an A2A agent", e.Identifier, e.Scope, e.Type)
}
return fmt.Sprintf("no A2A agent %q in %s", e.Identifier, e.Scope)
}

// AmbiguousError reports an identifier listed by more than one catalog.
type AmbiguousError struct {
Identifier string
Catalogs []string
}

func (e *AmbiguousError) Error() string {
return fmt.Sprintf("A2A agent %q is listed by more than one catalog: %s", e.Identifier, strings.Join(e.Catalogs, ", "))
}

// NoSingleAgentError reports a catalog used as an --agent-card that does not
// list exactly one A2A agent.
type NoSingleAgentError struct {
Source string
Agents []Entry
}

func (e *NoSingleAgentError) Error() string {
if len(e.Agents) == 0 {
return fmt.Sprintf("%s is an AI Catalog with no A2A agents", e.Source)
}
return fmt.Sprintf("%s is an AI Catalog with %d A2A agents; pick one with --catalog %s -a <identifier>", e.Source, len(e.Agents), e.Source)
}

// FindAgent returns the A2A agent with the identifier id among entries, which
// were collected from scope. When a catalog lists several versions, the
// latest is returned.
func FindAgent(entries []Entry, id, scope string) (Entry, error) {
var matches []Entry
var catalogs []string
otherType := ""
for _, e := range entries {
if e.Identifier != id {
continue
}
if !IsAgent(&e.CatalogEntry) {
otherType = e.Type
continue
}
matches = append(matches, e)
if !slices.Contains(catalogs, e.Catalog) {
catalogs = append(catalogs, e.Catalog)
}
}
switch {
case len(matches) == 0:
return Entry{}, &NotFoundError{Identifier: id, Scope: scope, Type: otherType, Agents: Agents(entries)}
case len(catalogs) > 1:
return Entry{}, &AmbiguousError{Identifier: id, Catalogs: catalogs}
}
doc := catalog.AICatalog{}
for _, m := range matches {
doc.Entries = append(doc.Entries, m.CatalogEntry)
}
latest, _ := doc.GetLatest(id)
for i := range doc.Entries {
if &doc.Entries[i] == latest {
return matches[i], nil
}
}
return matches[0], nil
}

// SingleAgent returns the only A2A agent among the entries of the catalog at source.
func SingleAgent(entries []Entry, source string) (Entry, error) {
agents := Agents(entries)
if len(agents) != 1 {
return Entry{}, &NoSingleAgentError{Source: source, Agents: agents}
}
return agents[0], nil
}

// CardURL returns the URL of the Agent Card an entry points to.
func CardURL(e Entry) (string, error) {
if e.URL == "" {
return "", fmt.Errorf("entry %q has neither data nor a URL", e.Identifier)
}
return resolveReference(e.Base, e.URL)
}

// IsCatalogDocument reports whether data is an AI Catalog rather than an Agent Card.
func IsCatalogDocument(data []byte) bool {
var doc struct {
SpecVersion *string `json:"specVersion"`
Entries *json.RawMessage `json:"entries"`
}
if err := json.Unmarshal(data, &doc); err != nil {
return false
}
return doc.SpecVersion != nil && doc.Entries != nil
}
Loading
Loading