How agents call other agents via the A2A (agent-to-agent) JSON-RPC protocol. Use when enabling inter-agent communication, exposing agent skills to other agents, or calling external agents from scripts.
67
82%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Agents can call other agents using the A2A protocol. This is a JSON-RPC-based protocol for agent discovery and communication. Use it when work should go to a different agent entirely — not the local agent chat.
Agent-native apps don't exist in isolation. A mail agent might need analytics data. A calendar agent might need to search issues. A2A lets agents discover each other, send messages, and receive structured results — all over HTTP with bearer token auth.
Add mountA2A() to a server plugin:
// server/plugins/a2a.ts
import { mountA2A } from "@agent-native/core/a2a";
export default defineNitroPlugin((nitro) => {
const app = nitro.h3App;
mountA2A(app, {
name: "Analytics Agent",
description: "Queries analytics data across providers",
version: "1.0.0",
skills: [
{
id: "query-data",
name: "Query Data",
description: "Run analytics queries across connected data sources",
tags: ["analytics", "data"],
examples: ["What were last week's signups?", "Show conversion rates"],
},
],
apiKeyEnv: "A2A_API_KEY", // env var holding the bearer token
streaming: true, // enable message/stream method
});
});This mounts the agent-native A2A endpoints:
GET /.well-known/agent-card.json — public agent discovery (no auth required)POST /_agent-native/a2a — primary JSON-RPC endpoint (bearer token auth required)The client may fall back to POST /a2a for external or legacy peers that only
expose that simple path. New agent-native apps should document and call the
/_agent-native/a2a endpoint.
interface A2AConfig {
name: string; // agent display name
description: string; // what this agent does
version?: string; // semver version (default: "1.0.0")
skills: AgentSkill[]; // capabilities this agent exposes
handler?: A2AHandler; // custom message handler
apiKeyEnv?: string; // env var name for bearer token auth
streaming?: boolean; // enable streaming responses
}
interface AgentSkill {
id: string; // unique skill identifier
name: string; // human-readable name
description: string; // what this skill does
tags?: string[]; // categorization tags
examples?: string[]; // example prompts
}The agent card is auto-generated at GET /.well-known/agent-card.json. Other agents fetch this to discover what skills are available:
{
"name": "Analytics Agent",
"description": "Queries analytics data across providers",
"url": "https://analytics.example.com",
"version": "1.0.0",
"protocolVersion": "0.3",
"capabilities": { "streaming": true },
"skills": [
{
"id": "query-data",
"name": "Query Data",
"description": "Run analytics queries across connected data sources"
}
]
}callAgent() (text in, text out)import { callAgent } from "@agent-native/core/a2a";
const answer = await callAgent(
"https://analytics.example.com",
"What were last week's signups?",
{ apiKey: process.env.ANALYTICS_A2A_KEY },
);
// answer is a plain stringinvokeAgentAction()When the caller knows the exact receiver-owned read action and arguments, skip the receiver's model loop:
import { invokeAgentAction } from "@agent-native/core/a2a";
const { result } = await invokeAgentAction({
target: "analytics",
action: "gong-calls",
input: { company: "Acme", days: 90, includeTranscripts: true },
userEmail,
orgDomain,
orgSecret,
});The receiver still owns schema validation, credentials, access scoping, audit attribution, and exposure policy. Direct invocation is available only for cataloged, authenticated, explicitly exposed read-only actions that do not require approval. Its JWT is audience-bound to the receiving app. Use normal message delegation for planning, synthesis, multi-step work, or mutations.
Inside an agent loop, call-agent exposes the same path with action + input;
omit message and taskId in that mode.
call-agent automatically derives an owner-scoped idempotency key from the
originating turn, target, and exact message. If a retry reaches the receiver
after the caller timed out, asynchronous message/send returns the existing
active or completed task instead of starting a duplicate agent run. Failed and
canceled tasks release the key for an intentional retry; synchronous calls are
never deduplicated. Lower-level clients may pass an
idempotencyKey explicitly; keep it stable for the same logical submission and
change it when the work changes. Dedupe is scoped to the JWT-authenticated
owner and verified org, and keys are limited to 128 characters.
The caller also forwards bounded correlation metadata (callerApp,
callerThreadId, parentRunId, parentTurnId, and direct-read
invocationId). These fields
are telemetry hints only. Receivers must continue to derive identity,
ownership, org scope, access, and approval from the verified request context.
Delegated model loops emit $ai_generation with A2A/MCP lineage, while direct
reads emit the content-free $a2a_read_invoke event; neither event includes
action arguments or results.
A2AClient (full control)import { A2AClient } from "@agent-native/core/a2a";
const client = new A2AClient(
"https://analytics.example.com",
process.env.ANALYTICS_A2A_KEY,
);
// Discover agent capabilities
const card = await client.getAgentCard();
// Send a message and get a task back
const task = await client.send({
role: "user",
parts: [{ type: "text", text: "What were last week's signups?" }],
});
// task.status.state === "completed"
// task.status.message.parts[0].text === "Last week: 1,247 signups..."
// Stream responses
for await (const update of client.stream({
role: "user",
parts: [{ type: "text", text: "Detailed breakdown by day" }],
})) {
console.log(update.status.state, update.status.message);
}Agent Native peers attach a bounded data part with
kind: "agent-native/agent-activity" to in-progress and terminal task status
messages. It contains the same user-visible reasoning summaries shown in the
receiving app, tool names and completion states, elapsed time, and progressive
response text. It never includes tool inputs, tool results, credentials, or
hidden provider reasoning.
call-agent reads this optional part while it polls an asynchronous task and
renders the remote work as a nested agent run. Unknown A2A peers do not need to
implement the extension: their ordinary status and final text still render in
the same nested block. Treat activity data as untrusted presentation content;
never use it for identity, authorization, approval, routing, or artifact
validation.
When the authenticated caller has an exact consequential action that the user
explicitly authorized in the originating chat, pass the tool name and complete
input as approvedActions. The receiver accepts these grants only from a
JWT-verified user identity, converts each one to the same content-addressed key
as its local approval gate, and consumes it once:
await client.send(message, {
async: true,
approvedActions: [
{
tool: "send-email",
input: { to, subject, body, attachments },
},
],
});Never infer authorization from request prose or broaden the input. A changed recipient, body, attachment, or tool produces a different key and follows the receiver's normal approval-required path. Static API keys and unsigned callers cannot carry these grants.
| Method | Purpose | Auth required |
|---|---|---|
message/send | Send a message, get a task back | Yes |
message/stream | Send a message, stream responses | Yes |
actions/invoke | Invoke one exposed read action | Yes, JWT |
tasks/get | Get task status by ID | Yes |
tasks/cancel | Cancel a running task | Yes |
Tasks go through these states:
submitted → working → completed
→ failed
→ canceled
→ input-requiredstatus.messagetasks/cancelA2A uses bearer token auth. The server reads the token from the environment variable specified by apiKeyEnv:
A2A_API_KEY=<A2A_API_KEY_VALUE> in the server's deployment environmentAuthorization: Bearer <A2A_API_KEY_VALUE>/.well-known/agent-card.json) is public — no auth needed for discoveryNever hardcode the bearer token in source, docs, prompts, app state, action descriptions, client bundles, or examples. A2A tokens are deploy-level secrets unless a specific app designs a scoped credential flow; read them from secure runtime configuration and never log or return them.
Messages contain typed parts:
| Part type | Fields | Use for |
|---|---|---|
text | { type: "text", text: "..." } | Natural language messages |
file | { type: "file", file: { ... } } | Files (bytes or URI) |
data | { type: "data", data: { ... } } | Structured JSON data |
A mail agent calls an analytics agent to include data in an email draft:
// actions/draft-with-analytics.ts
import { callAgent } from "@agent-native/core/a2a";
import { writeAppState } from "@agent-native/core/application-state";
export default async function (args: string[]) {
// Ask the analytics agent for data
const stats = await callAgent(
process.env.ANALYTICS_AGENT_URL!,
"Summarize last week's key metrics in 3 bullet points",
{ apiKey: process.env.ANALYTICS_A2A_KEY },
);
// Create a draft with the analytics data
await writeAppState("compose-analytics-report", {
id: "analytics-report",
to: "team@example.com",
subject: "Weekly Analytics Summary",
body: `Hi team,\n\nHere are last week's numbers:\n\n${stats}\n\nBest`,
mode: "compose",
});
}All types are exported from @agent-native/core/a2a:
import type {
A2AConfig,
A2AHandler,
A2AHandlerContext,
A2AHandlerResult,
AgentCard,
AgentSkill,
AgentCapabilities,
Task,
TaskState,
TaskStatus,
Message,
Part,
TextPart,
FilePart,
DataPart,
Artifact,
JsonRpcRequest,
JsonRpcResponse,
} from "@agent-native/core/a2a";c1ee18b
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.