# DaloyJS A2A agent endpoint

[Agent2Agent (A2A)](https://a2a-protocol.org/latest/specification/) 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.

**Diagram: A DaloyJS service as an A2A peer**

- **Peer agent** - any A2A 1.0 client
- **Agent Card** - GET /.well-known/agent-card.json
- **JSON-RPC endpoint** - POST /a2a, auth + validation
- **Your onMessage** - reply or task result
- **Existing systems** - database, 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.

| Surface | Caller | Unit of work |
| --- | --- | --- |
| [OpenAPI](/docs/openapi) + typed client | Humans and generated SDKs | HTTP route |
| [MCP](/docs/mcp) | An AI client using your service as a tool | Tool call, resource read |
| A2A | Another agent treating you as an opaque peer | Message, 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`.

**Diagram: Continuing an input-required task**

Participants: Peer agent, DaloyJS A2A, onMessage

1. **Peer agent -> DaloyJS A2A** (request) - SendMessage - "Check stock"
2. **DaloyJS A2A -> onMessage** (note) - ctx.task is undefined
3. **DaloyJS A2A -> Peer agent** (response) - task: TASK_STATE_INPUT_REQUIRED - "Which SKU?" (stored under the caller)
4. **Peer agent -> DaloyJS A2A** (request) - SendMessage with taskId - "ABC-1"
5. **DaloyJS A2A -> onMessage** (note) - ctx.task is a copy of the stored task
6. **DaloyJS A2A -> Peer agent** (response) - task: TASK_STATE_COMPLETED - artifacts + 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.

| Method | Behavior |
| --- | --- |
| `SendMessage` | Runs onMessage. Returns { message } or { task }. |
| `GetTask` | Needs a task store. Otherwise -32001 TaskNotFound. |
| `CancelTask` | Needs a task store. Finished tasks answer -32002 TaskNotCancelable. |
| `ListTasks` | Needs 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](/docs/security/boot-guards#9-unauthenticated-a2a-endpoint) 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()`](/docs/security/fetch-guard) 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.

---

Source: https://daloyjs.dev/docs/a2a