CtrlK
BlogDocsLog inGet started
Tessl Logo

a2a-protocol

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

Quality

82%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

SKILL.md
Quality
Evals
Security

A2A Protocol (Agent-to-Agent)

Rule

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.

Why

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.

How to Enable A2A (Server Side)

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.

The Config Object

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
}

Agent Card

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"
    }
  ]
}

Calling Another Agent

Simple: 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 string

Fast bounded read: invokeAgentAction()

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.

Retry safety and trace linkage

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.

Advanced: 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 activity in delegated chat

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.

Carrying explicit chat authorization

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.

JSON-RPC Methods

MethodPurposeAuth required
message/sendSend a message, get a task backYes
message/streamSend a message, stream responsesYes
actions/invokeInvoke one exposed read actionYes, JWT
tasks/getGet task status by IDYes
tasks/cancelCancel a running taskYes

Task Lifecycle

Tasks go through these states:

submitted → working → completed
                    → failed
                    → canceled
                    → input-required
  • submitted — message received, not yet processing
  • working — agent is processing the request
  • completed — agent finished, result in status.message
  • failed — agent encountered an error
  • canceled — task was canceled via tasks/cancel
  • input-required — agent needs more information from the caller

Security

A2A uses bearer token auth. The server reads the token from the environment variable specified by apiKeyEnv:

  • Set A2A_API_KEY=<A2A_API_KEY_VALUE> in the server's deployment environment
  • Callers pass it as Authorization: Bearer <A2A_API_KEY_VALUE>
  • The agent card endpoint (/.well-known/agent-card.json) is public — no auth needed for discovery

Never 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.

Message Parts

Messages contain typed parts:

Part typeFieldsUse for
text{ type: "text", text: "..." }Natural language messages
file{ type: "file", file: { ... } }Files (bytes or URI)
data{ type: "data", data: { ... } }Structured JSON data

Example: Cross-Agent Workflow

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

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";

Related Skills

  • delegate-to-agent — For work the local agent handles. Use A2A when the work goes to a different agent.
  • actions — A2A calls typically happen inside actions
  • storing-data — Results from A2A calls are stored in SQL like any other data
Repository
BuilderIO/agent-native
Last updated
First committed

Is this your skill?

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.