Skip to content

Search docs

Jump between documentation pages.

Browse docs

DaloyJS A2A agent endpoint

Agent2Agent (A2A) is the Linux Foundation protocol for one agent calling another agent it does not share memory, tools, or code with. DaloyJS can expose an existing service as an A2A 1.0 peer: other agents discover it through a public Agent Card and send it work over the JSON-RPC binding.

The split is deliberate. DaloyJS owns the protocol: the card, the envelope, version and extension negotiation, validation, error codes, task bookkeeping, and the auth guardrails. You own the meaning: one onMessage function reads the message and decides what to do. DaloyJS is not an agent framework and runs no LLM or skill router. If you want a model to interpret requests, call it from your handler.

A DaloyJS service as an A2A peer
  1. Peer agentany A2A 1.0 client
  2. Agent CardGET /.well-known/agent-card.json
  3. JSON-RPC endpointPOST /a2a, auth + validation
  4. Your onMessagereply or task result
  5. Existing systemsdatabase, REST handlers, queues
Discovery is public so a peer can learn how to authenticate. Everything after the card goes through your auth middleware, body limits, timeouts, and the A2A validator before your handler runs.

A2A, MCP, and OpenAPI

These are three faces of the same service, not competitors. Pick by who is calling.

SurfaceCallerUnit of work
OpenAPI + typed clientHumans and generated SDKsHTTP route
MCPAn AI client using your service as a toolTool call, resource read
A2AAnother agent treating you as an opaque peerMessage, task with a lifecycle, artifacts

If the question is "can Cursor call my inventory API?", that is MCP. If it is "can a partner's procurement agent hand my service a job and get a result back?", that is A2A.

Install

A2A ships in core at @daloyjs/core/a2a (also re-exported from @daloyjs/core). No extra SDK is installed.

bash
pnpm add @daloyjs/core

Create an agent

createA2aHandler() builds the protocol layer and a2aRoutes() mounts it. The card route is GET /.well-known/agent-card.json. The JSON-RPC route is POST at the path you choose, plus a GET hint (405) and OPTIONS preflight. The hooks option applies auth to the POST route only, so the card stays reachable without an except().

ts
import {
  App,
  a2aData,
  a2aRoutes,
  bearerAuth,
  createA2aHandler,
  timingSafeEqual,
} from "@daloyjs/core";

const agent = createA2aHandler({
  card: {
    name: "inventory-agent",
    description: "Answers stock questions for Acme products.",
    version: "1.0.0",
    // Absolute public URL of the JSON-RPC route below.
    url: "https://api.acme.example/a2a",
    provider: { organization: "Acme", url: "https://acme.example" },
    skills: [
      {
        id: "stock-lookup",
        name: "Stock lookup",
        description: "Units on hand for a SKU. Send { sku } as a data part.",
        tags: ["inventory"],
        examples: ['{"sku":"ABC-1"}'],
      },
    ],
    // Tell peers how to authenticate. Required unless the routes are public.
    securitySchemes: { bearer: { httpAuthSecurityScheme: { scheme: "Bearer" } } },
    securityRequirements: [{ schemes: { bearer: { list: [] } } }],
  },
  // Your logic. DaloyJS never guesses a skill: you read the message and decide.
  onMessage: async ({ message }) => {
    const input = message.parts.find((part) => part.data !== undefined)?.data as
      | { sku?: string }
      | undefined;
    if (!input?.sku) return { status: "rejected", message: "Send { sku } as a data part." };
    const units = await inventory.unitsFor(input.sku);
    return {
      status: "completed",
      artifacts: [{ name: "stock", parts: [a2aData({ sku: input.sku, units })] }],
    };
  },
});

const app = new App();
const auth = bearerAuth({
  validate: (token) => timingSafeEqual(token, process.env.A2A_TOKEN!),
});
// Auth covers the JSON-RPC POST only; the Agent Card stays public.
for (const route of a2aRoutes("/a2a", agent, { hooks: auth })) {
  app.route(route);
}

The route handlers forward the Daloy ctx.state to onMessage, so the principal your auth middleware stored is available as ctx.state inside the handler.

The Agent Card

You write the identity and skills. DaloyJS derives supportedInterfaces and capabilities from what the handler actually implements, so the card cannot advertise a feature that would fail when called. The card is validated at startup: it needs at least one skill, every skill needs an id, name, description, and a tag, URLs must be absolute http(s) without embedded credentials, and every security requirement must name a declared scheme.

json
{
  "name": "inventory-agent",
  "description": "Answers stock questions for Acme products.",
  "supportedInterfaces": [
    { "url": "https://api.acme.example/a2a", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }
  ],
  "provider": { "organization": "Acme", "url": "https://acme.example" },
  "version": "1.0.0",
  "capabilities": { "streaming": false, "pushNotifications": false, "extendedAgentCard": false },
  "securitySchemes": { "bearer": { "httpAuthSecurityScheme": { "scheme": "Bearer" } } },
  "securityRequirements": [{ "schemes": { "bearer": { "list": [] } } }],
  "defaultInputModes": ["text/plain", "application/json"],
  "defaultOutputModes": ["text/plain", "application/json"],
  "skills": [{ "id": "stock-lookup", "name": "Stock lookup", "description": "...", "tags": ["inventory"] }]
}

The card response carries Cache-Control: public, max-age=300 (tune with cardMaxAgeSeconds) and a strong ETag, and answers If-None-Match with 304. Skills are descriptive. A2A has no skill id on messages, which is exactly why your handler decides what to run.

Wire format

DaloyJS implements the A2A 1.0 JSON-RPC binding: PascalCase methods, ProtoJSON field names, SCREAMING_SNAKE enums, and parts whose member name is the type (text, raw, url, or data). The 0.3 kind field and message/send style names are not accepted.

http
POST /a2a HTTP/1.1
Content-Type: application/json
A2A-Version: 1.0
Authorization: Bearer <token>

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "9b1c...",
      "role": "ROLE_USER",
      "parts": [{ "data": { "sku": "ABC-1" }, "mediaType": "application/json" }]
    }
  }
}
json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "task": {
      "id": "3f0e...",
      "contextId": "c71a...",
      "status": { "state": "TASK_STATE_COMPLETED", "timestamp": "2026-09-28T10:30:00.000Z" },
      "artifacts": [
        { "artifactId": "a1...", "name": "stock", "parts": [{ "data": { "sku": "ABC-1", "units": 12 }, "mediaType": "application/json" }] }
      ],
      "history": [{ "messageId": "9b1c...", "role": "ROLE_USER", "parts": [...] }]
    }
  }
}

What onMessage returns

Return a string or { reply } for a direct answer that creates no task. Return { status } for a task. The spec allows both, and a direct message is the right choice for simple, stateless answers.

ts
// 1. Direct answer, no task created. Returns { message }.
onMessage: ({ text }) => `You said: ${text}`,

// 2. Same, with parts and metadata.
onMessage: () => ({ reply: [a2aText("**Done**", "text/markdown")], metadata: { source: "cache" } }),

// 3. A task in a final state. Returns { task }.
onMessage: () => ({
  status: "completed", // or "failed" | "rejected"
  message: "Here is your report.",
  artifacts: [{ name: "report", parts: [a2aData(report)] }],
}),

// 4. An interrupted task the client continues later (needs a task store).
onMessage: ({ task }) =>
  task ? { status: "completed", artifacts: [...] } : { status: "input-required", message: "Which SKU?" },

The context also carries text (all text parts joined), contextId, the taskId a task would get, acceptedOutputModes, request metadata, the extensions the client activated, and signal, which aborts when the request times out.

Multi-turn tasks

Without a task store the agent is stateless, which fits serverless and edge deployments. Add a taskStore to enable GetTask, CancelTask, ListTasks, and the input-required / auth-required states a client continues by sending another message with the same taskId.

Continuing an input-required task
Peer agentDaloyJS A2AonMessage
  1. 01requestPeer agentDaloyJS A2ASendMessage"Check stock"
  2. 02noteDaloyJS A2AonMessagectx.task is undefined
  3. 03responseDaloyJS A2APeer agenttask: TASK_STATE_INPUT_REQUIRED"Which SKU?" (stored under the caller)
  4. 04requestPeer agentDaloyJS A2ASendMessage with taskId"ABC-1"
  5. 05noteDaloyJS A2AonMessagectx.task is a copy of the stored task
  6. 06responseDaloyJS A2APeer agenttask: TASK_STATE_COMPLETEDartifacts + history
A message to a finished task is refused with -32004, and a contextId that does not match the task is refused with -32602, as the spec requires.
ts
import { createA2aHandler, memoryTaskStore } from "@daloyjs/core";

const agent = createA2aHandler({
  card,
  // Process-local and bounded. Use a shared store on serverless or multi-instance.
  taskStore: memoryTaskStore({ maxTasks: 1000, ttlMs: 60 * 60 * 1000 }),
  // Who owns a task. Every store call is scoped by this value.
  taskOwner: ({ state }) => (state.user as { sub?: string } | undefined)?.sub,
  onMessage: ({ task, text }) => {
    if (!task) return { status: "input-required", message: "Which SKU?" };
    return { status: "completed", artifacts: [{ parts: [a2aData(lookup(text))] }] };
  },
});

Task ownership fails closed. A task store requires taskOwner. Every store call is scoped by the value it returns, so one caller can never read, continue, cancel, or list another caller's tasks: those requests get -32001 TaskNotFound, which does not reveal that the task exists. When taskOwner returns nothing, the request is refused with 401 instead of falling into a shared bucket. With tenancy, include the tenant in the owner, for example JSON.stringify([state.tenant, user.sub]).

memoryTaskStore() is bounded, deep-copies on read and write, and expires entries. On serverless or several instances, a follow-up call can land somewhere else, so bring a shared store:

ts
import type { A2aTaskStore } from "@daloyjs/core";

const redisTaskStore: A2aTaskStore = {
  async get(owner, taskId) {
    const raw = await redis.get(`a2a:${owner}:${taskId}`);
    return raw ? JSON.parse(raw) : undefined;
  },
  async set(owner, task) {
    await redis.set(`a2a:${owner}:${task.id}`, JSON.stringify(task), { EX: 3600 });
  },
  // list() is optional. Without it, ListTasks answers -32004.
};

Methods and capabilities

Version 1 is honest about what it does not do. The card says streaming, pushNotifications, and extendedAgentCard are false, and those methods return the errors the spec requires instead of hanging.

MethodBehavior
SendMessageRuns onMessage. Returns { message } or { task }.
GetTaskNeeds a task store. Otherwise -32001 TaskNotFound.
CancelTaskNeeds a task store. Finished tasks answer -32002 TaskNotCancelable.
ListTasksNeeds a store with list(). Otherwise -32004 UnsupportedOperation.
SendStreamingMessage, SubscribeToTask-32004 UnsupportedOperation (streaming: false).
*TaskPushNotificationConfig-32003 PushNotificationNotSupported (pushNotifications: false).
GetExtendedAgentCard-32004 UnsupportedOperation (extendedAgentCard: false).
Anything else, including 0.3 names like message/send-32601 Method not found.

Versioning. Clients send A2A-Version: 1.0. An empty header means 0.3 per the spec, and 0.3 is not implemented, so it is refused with -32009 VersionNotSupported. Extensions. A card extension marked required: true must be activated in the A2A-Extensions header, or the request gets -32008.

Calling your agent

Any A2A 1.0 client can call a DaloyJS agent. This example uses the official JS SDK, which DaloyJS is tested against for discovery, direct replies, multi-turn tasks, GetTask, ListTasks, and error mapping.

ts
import { ClientFactory } from "@a2a-js/sdk/client";

// Any A2A 1.0 client works. This is the official Linux Foundation JS SDK.
const client = await new ClientFactory().createFromUrl("https://api.acme.example");
const result = await client.sendMessage({
  message: {
    messageId: crypto.randomUUID(),
    role: 1, // ROLE_USER
    parts: [{ content: { $case: "data", value: { sku: "ABC-1" } } }],
  },
});

Error handling

Validation failures return -32602 with a google.rpc.BadRequest list of field violations. A2A errors carry a google.rpc.ErrorInfo with the spec's reason, for example TASK_NOT_FOUND. Throw A2aError to choose the code yourself. Unexpected throws become a redacted -32603. Raw error text is shown only when NODE_ENV is development or test, and never on an App running in production unless you set exposeInternalErrors explicitly.

ts
import { A2aError, A2A_ERROR_CODES } from "@daloyjs/core";

onMessage: ({ message }) => {
  if (message.parts.length > 3) {
    // Caller-visible JSON-RPC error. Keep secrets out of the message.
    throw new A2aError(A2A_ERROR_CODES.invalidParams, "Send at most three parts.");
  }
  // Any other throw becomes a redacted -32603 "Internal error".
  ...
}

What stays out of core

  • Streaming (SendStreamingMessage, SSE) and push notifications. Planned only when real users need work that outlives one request.
  • The gRPC and HTTP+JSON bindings. The card lists JSON-RPC only.
  • Signed and extended Agent Cards.
  • Agent orchestration, skill routing, memory, and LLM calls. Those belong in your handler or an agent framework.
  • Fetching url parts. DaloyJS validates that they are http(s) without credentials, but never fetches them.

Security checklist

  • Authenticate the JSON-RPC route with the hooks option or app middleware. In production a secureDefaults App refuses to boot without it. For a genuinely public, read-only agent, opt out with a2aRoutes(path, handler, { public: true }).
  • Keep the card truthful and free of internal details. It is public, and other agents act on it. Do not list operator-only skills.
  • Use an https: card URL. In production, registration throws on http: for any non-loopback host.
  • Resolve taskOwner from the verified identity only, never from a header or message field the caller controls.
  • Route any fetch of a url part through fetchGuard() so a peer cannot aim your service at loopback, private, or cloud-metadata addresses.
  • Treat message text and data as untrusted input, especially if your handler passes it to a model. A peer agent is just another client.
  • Rate-limit the endpoint like any other API route, and keep the default Origin check. Add trusted browser apps to allowedOrigins instead of a wildcard CORS layer.