How agent-native apps discover, authenticate with, and call each other over A2A. Use when connecting one app to another, debugging a remote agent that won't authenticate, exposing skills to peers, or calling agents from scripts.
82%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
Agents call other agents over A2A, a JSON-RPC protocol for discovery and delegation. Use it only when the user explicitly asks to involve a peer, or the requested outcome requires data, credentials, or a capability owned by that peer and unavailable through the local app's actions. The local app's actions are the default; a peer's availability or ability to enrich a result is not enough. When a peer supplies input, keep authoring and editing artifacts native to the local app here.
When a handoff qualifies under the rule above, preserve the peer boundary: do not have app A generate and execute raw SQL against app B's database, or expose B's internal tools directly to A as a substitute for delegation. The receiving agent has context, skills, and guardrails the caller doesn't. If a justified A2A call is unreliable, fix the delegation path (timeouts, retries, or typed terminal states) instead of bypassing it app by app.
Connecting app A to an Agent-Native app B is two independent things, and both must be true:
remote-agents/<id>.json resource.Neither is a code change, and neither is symmetric. Registering B on A does not let B call A. A hosted peer uses provider-specific endpoint and credential metadata in place of the shared Agent-Native secret, but it still does not make the provider's model or SDK an A2A endpoint.
createAgentChatPlugin calls mountA2A for every app, so a generated app
already serves:
GET /.well-known/agent-card.json — public discovery, with a larger
authenticated capability view for verified sibling callersPOST /_agent-native/a2a — JSON-RPC, authenticatedDo not add a mountA2A server plugin to enable A2A; it is on. Hand-mounting is
only for a bespoke card or a custom handler (see Custom mount below).
The client falls back to POST /a2a for external peers that only expose that
path. New agent-native apps should call /_agent-native/a2a.
A remote agent is a row in the resources table, not a file on disk. Path
remote-agents/<id>.json, owner SHARED_OWNER, content:
{
"id": "analytics",
"name": "Analytics",
"description": "Queries analytics data across providers",
"url": "https://analytics.example.com",
"color": "#6B7280",
"cardUrl": "https://analytics.example.com/.well-known/agent-card.json",
"auth": {
"type": "bearer",
"credentialRef": "ANALYTICS_A2A_TOKEN"
}
}url is the only required endpoint field. cardUrl is the optional discovery
URL for providers that do not serve /.well-known/agent-card.json. The client
reads the protocol version from the agent card. Pass protocolVersion directly
to A2AClient when a provider's card omits it. The optional auth descriptor is
non-secret connection metadata. Use
{ "type": "bearer", "credentialRef": "..." } for a vault-backed bearer
credential, or { "type": "oauth-client-credentials", "tokenUrl": "...", "clientId": "...", "clientSecretRef": "...", "scope": "..." } when the
peer issues OAuth client-credentials tokens. credentialRef and
clientSecretRef are references, never secret values.
Do not add apiKey, env, skills, or token values to a manifest. Resolve
credentials server-side from the workspace connection or vault. A direct
A2AClient call can pass cardUrl and protocolVersion while a provider
adapter is being used, but browser code must never receive the credential.
Four ways to create it, all writing the same row:
| Surface | Where |
|---|---|
| Settings → Agent → Sub-agents → Connect agent (owners and admins) | any app with the Settings shell |
| Agent page → Connections | apps mounting AgentTabsPage |
| Dispatch → Agents → Add external agent | workspace dispatch |
| The agent itself | resources tool, action: "write", --scope shared |
The last one matters for template apps with no Code tab: "connect me to
https://analytics.example.com" is a complete instruction. remote-agents/ is
whitelisted as a durable control path, so the write is workspace-scoped, and it
takes effect on the next discoverAgents() call with no restart.
First-party templates are seeded automatically and overlaid on top of manifests,
so Mail, Calendar, and friends appear without registration. In dev they
resolve to http://localhost:<devPort>; a manifest pointing at localhost is
ignored in production.
call-agent also accepts a raw URL, which skips registration entirely — useful
for one-off calls, useless for @-mentions.
GET /_agent-native/agents/probe?url=<base> reads the peer's card and makes one
authenticated no-op call, returning reachable and authorized as independent
fields. Omit url to probe every registered peer. Reachable-but-unauthorized is
the failure worth looking for: local dev runs the receiver unauthenticated, so a
mismatched secret only surfaces after deploy. The Connected Agents settings
section calls this on add and on open.
A2A authenticates with a short-lived JWT the caller signs, not a stored
bearer key. Authorization: Bearer <jwt>; HS256; 15-minute default TTL.
There is no per-peer API key anywhere in the system, which is why the manifest
has no field for one. A user identity assertion carries the authenticated
caller's email and is signed only with the deployment A2A_SECRET. Mint it
from authenticated request context; never accept an email from message params
or metadata as identity.
verifyA2AToken rejects MCP connect and OAuth tokens even though they can be
signed with the same secret: they are long-lived MCP credentials, never A2A
tokens. Callers bind signed tokens to the receiving endpoint with aud; every
receiver, including a custom JWT verifier, must check that claim against its
own canonical endpoint URL. Preserve compatibility for old tokens without
aud, but reject an audience-bearing token when the receiver cannot derive or
match its expected audience. A scoped resource route may require aud even
for legacy callers; document and test that boundary when adding such a route.
The framework supports two credential scopes:
| Secret | Scope | How it is set |
|---|---|---|
A2A_SECRET | User identity across a deployment | env var, never auto-generated |
org a2a_secret | Organization principal, with optional current-member delegation | auto-generated on org creation; Team page UI |
An ordinary org-secret JWT proves only that the caller holds the organization's
shared credential. Its sub or email claims are ignored; do not use it to read
person-owned data, claim a user's task, invoke a user-scoped action, or carry a
human approval. A separate agent-native-org-member-delegation assertion may
carry the identity of any current receiver-org member to a caller holding that
shared secret, but only when the receiver's owner enables the setting, the org
secret differs from A2A_SECRET, and the audience and short expiry verify. The
receiver checks the live roster, and delegated org actions are always capped at
member. MCP requests remain organization-scoped.
Organization IDs are local to each app's database. For calls to a peer app,
bind the token to the trusted org_domain and the peer's canonical audience;
do not put the caller app's local org_id in the token. The receiver resolves
the domain to its own organization row and binds authorization to that local
ID. An ID-only token is valid only when both sides intentionally share the same
local organization ID, such as a credential scoped to one local database.
The org secret is managed at Team page → A2A secret (owner only), where it can be revealed, copied, regenerated, and pushed to discovered peers. Its member-identity delegation setting is owner-only and defaults off.
A2A_SECRET is the deploy-level path. workspace-deploy refuses a production
workspace deploy without it and prints the generator command. Peers that must
trust deployment-secret user assertions need the same value on both sides.
Keep it different from every org a2a_secret; a shared value cannot
distinguish a deployment user assertion from an organization credential. An
org secret equal to A2A_SECRET is verified as an organization principal and
reaches the caller as the typed
permanent_precondition error ("no verified user"): an admin/ops fix, never a
retry. Mismatched A2A_SECRET values are rejected with 401
(a2a_auth_rejected).
With no secret configured at all:
| Runtime | Result |
|---|---|
| local dev | open, one-time console warning |
| production | 503 — "A2A authentication not configured" |
| production, secret set, bad/absent token | 401 |
"Production" is detected broadly (NODE_ENV, Netlify, Lambda, Vercel, Render,
Fly, Cloud Run, …) and unrecognized deployed hosts fail closed unless the
request is genuine loopback or A2A_ALLOW_UNSIGNED_INTERNAL=1.
A2AConfig.apiKeyEnv still exists for static bearer auth against non-agent-native
peers, but the framework's own mount never sets it. Do not reach for it when
debugging a connection between two agent-native apps — the answer there is
always the shared secret. For a provider that uses OAuth, resolve and refresh
the access token in a server-side adapter. A2A_SECRET is not a substitute for
the provider's Entra, Google, or other OAuth credential.
Foundry, Gemini Enterprise, and other hosted services can be A2A peers only
when they expose a compatible protocol endpoint. Foundry hosted agents run
agent code in managed containers and can expose A2A through Agent Service. Keep
the Agent-Native UI, actions, and PostgreSQL app on its normal host. Foundry
callers use Microsoft Entra bearer tokens with Foundry Agent Consumer access,
so configure cardUrl and obtain the token through the workspace credential
provider. Foundry v1.0 is the GA JSON-RPC endpoint; v0.3 is the preview
endpoint used when no version is selected. Use the v1 card URL
.../agents/{agent}/endpoint/protocols/a2a/agentCard/v1.0 when available,
and pass protocolVersion only when the card omits it. Foundry v1 does not
provide SSE streaming. See the Foundry hosted agent overview
and A2A endpoint guidance.
Gemini Enterprise managed assistants expose a standard A2A JSON-RPC endpoint
under the assistant resource, such as
.../assistants/default_assistant/agents/{id}/a2a, and can publish the card at
a custom URL. Use the generic bearer client with that cardUrl; the caller's
Google OAuth bearer needs the discoveryengine.assist permission. Custom A2A
agent registration is Pre-GA, so verify that it is enabled in the target
project before exposing it in the workspace picker. A model provider or SDK
without an A2A endpoint still needs a server-side adapter before an
Agent-Native app can call it.
Anthropic Managed Agents has no inbound A2A endpoint. Use
createAnthropicManagedAgentsHandler() as the A2AConfig.handler when the
Agent-Native app should own the A2A task, workflow state, and approvals while
Anthropic owns the session, context, and tools. Its configuration is a native
provider kind on the existing remote-agent resource:
{
"kind": {
"provider": "anthropic-managed-agents",
"agentId": "agt_01...",
"environmentId": "env_01...",
"credentialRef": "ANTHROPIC_API_KEY"
}
}credentialRef is a vault reference, resolved in the caller's user and
organization scope. The adapter calls POST /v1/sessions, sends
user.message or user.tool_confirmation events to
POST /v1/sessions/{id}/events, and reads
GET /v1/sessions/{id}/events/stream with the
managed-agents-2026-04-01 beta header. A session.status_idle event whose
stop reason is requires_action becomes A2A input-required; each blocking
tool also emits the normal approval-request runtime event. Continue only
with structured { toolUseId, result: "allow" | "deny" } confirmations in
the adapter metadata. Register the anthropic-managed-agents workspace
connection provider to reuse existing per-app grants.
Never hardcode either secret in source, docs, prompts, app state, action descriptions, client bundles, or examples. Read them from runtime config; never log or return them.
Card skills are derived from actions marked publicAgent; do not maintain a
second capability registry. Anonymous callers see only explicitly public-safe
reads. Verified sibling callers see two concise kinds of capability:
publicAgent: { expose: true, readOnly: false, requiresAuth: true } are advertised without schemas as message-only
capabilities. A sibling delegates an objective; the receiving agent chooses
and validates its own local actions.agentTool: false and externalAgents.denyActions remove an action from both
authenticated paths. An app that marks no actions still publishes
"skills": [], and natural-language delegation still works because the
receiver loads its own instructions, skills, data dictionary, credentials, and
tools. Expose stable user-facing capabilities, not internal implementation
actions.
Natural-language delegation is the default for agent-to-agent work. Tell the receiving specialist the objective, relevant IDs/date range, and desired result shape. The receiver owns source selection, schemas, queries, joins, SQL, and provider-specific details. Do not switch to a direct action merely because a delegated run is slow or flaky; fix the A2A path instead.
callAgent() (text in, text out)import { callAgent, resolveA2ACallerAuth } from "@agent-native/core/a2a";
const auth = await resolveA2ACallerAuth();
const answer = await callAgent(
"https://analytics.example.com",
"What were last week's signups?",
auth,
);
// answer is a plain stringcallAgent signs nothing on its own — with no userEmail/orgSecret in opts
it calls unauthenticated, which silently works in local dev and 401s in
production. resolveA2ACallerAuth() pulls the authenticated email, org domain,
and org secret out of request context; pass them explicitly only from CLI or
cron, where there is no request.
invokeAgentAction()Use direct invocation only when a trusted integration already specifies the exact receiver-owned semantic read action and complete arguments. It is an optional machine API, not an agent-performance shortcut or fallback for failed message delegation:
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's exact base
URL, including a workspace path such as /content. Use normal message
delegation whenever the receiver must interpret the request, choose a
source, consult its data dictionary, plan, synthesize, join data, or perform a
multi-step workflow.
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,
selectedReceiverApp, callerThreadId, parentRunId, parentTurnId, and
direct-read invocationId). selectedReceiverApp lets the matching receiver
prioritize its declared local capabilities before loading cross-app tools; the
other fields remain telemetry hints. Receivers must continue to derive
identity, data 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, resolveA2ACallerAuth } from "@agent-native/core/a2a";
// The second argument is the bearer token itself, not a signing secret.
const { apiKey, apiKeyFallbacks } = await resolveA2ACallerAuth();
const client = new A2AClient("https://analytics.example.com", apiKey, {
fallbackApiKeys: apiKeyFallbacks,
});
// 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/cancelMessages contain typed parts:
| Part type | Fields | Use for |
|---|---|---|
text | { type: "text", text: "..." } | Natural language messages |
file | { type: "file", file: { ... } } | Stored file URI |
data | { type: "data", data: { ... } } | Structured JSON data |
The receiver rejects inline file bytes and data: URLs before creating a task:
task history, status messages, artifacts, and approval inputs are durable SQL
records. Upload files to configured storage first, then send the returned URI.
Only when the default card is wrong for the app — a curated skill list, a non-agent handler, or static bearer auth against an external peer:
// server/plugins/a2a.ts
import { mountA2A } from "@agent-native/core/a2a";
export default defineNitroPlugin((nitro) => {
mountA2A(nitro, {
appId: "analytics",
name: "Analytics Agent",
description: "Queries analytics data across providers",
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"],
},
],
streaming: true,
});
});Config also carries handler, publicSkillsOnly, durableBackgroundRuns,
executeReadOnlyAction, executeApproval, and apiKeyEnv. See A2AConfig in
@agent-native/core/a2a for the full shape.
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";publicAgent marks what peers can see.BuilderIO/agent-native
4712315
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.