// AgentCard L3 registration for sesh-channels v0.4 (clean subject scheme). // // Registers two NATS micro endpoints on an existing AgentService: // // agents.card... — public partial card // agents.cardx... — auth-gated partial card // // The single-segment verbs (`card`, `cardx`) keep the 5-token // addressing tier consistent with `prompt`/`hb`/`status`. `cardx` is // a distinct verb so NATS subject-based authz can enforce the auth // boundary at the account level rather than inside each adapter. // // These are the L3 contribution in the design doc's three-layer model // (L1 = sesh defaults baked into the shim config, L2 = $SRV.INFO.agents // metadata, L3 = adapter-served). The shim merges L1 + L2 + L3 and signs // the result before serving over HTTPS — this SDK NEVER signs. // // Both endpoints reply with a JSON-encoded partial AgentCard. The // request body is ignored (identity is in the subject per design doc // Section 6.3). // // Auth gating is the shim's responsibility — this SDK does not check // principals. Adapters that don't want to expose extended skills simply // don't call `registerAgentCardExtended`; the shim then returns // `AuthenticatedExtendedCardNotConfiguredError` to upstream A2A // clients. import type { ServiceMsg } from "@nats-io/services"; import { card as cardSubjectBuilder, cardx as cardxSubjectBuilder, type Coord } from "./subjects.ts"; /** * Structural type capturing the minimal surface of an * `@synadia-ai/agent-service` `AgentService` (or a raw `Svcm`-built * service) that this module uses. * * Under the clean v0.4 scheme the registrar no longer carries the * (agent, owner, name) triple — subjects are derived from an explicit * `Coord` passed alongside the service. The registrar interface is * just "something that can register an endpoint". * * The `opts` parameter is typed as `unknown` because the helpers * construct the opts object internally and pass it through — the * runtime (NATS micro Svcm) is the one that validates the shape. */ export interface AgentCardRegistrar { readonly service: { addEndpoint(name: string, opts: unknown): unknown; }; } /** * A2A AgentSkill mirror. Field names match the JSON shape from the * a2a-go spec reference (`~/references/a2a-go/a2a/agent.go`, type * `AgentSkill`). The SDK does not validate beyond TS typing — the * shim re-validates after merging L1+L2+L3. */ export interface AgentCardSkill { readonly id: string; readonly name: string; readonly description: string; readonly tags: ReadonlyArray; readonly examples?: ReadonlyArray; readonly inputModes?: ReadonlyArray; readonly outputModes?: ReadonlyArray; // SecurityRequirements deliberately omitted — L1 owns security // schemes; per-skill security is a future extension. } /** * A2A AgentExtension mirror. Same source: `a2a-go/a2a/agent.go` * `AgentExtension`. Wire format is what A2A clients expect verbatim. */ export interface AgentCardExtension { readonly uri: string; readonly description?: string; readonly params?: Readonly>; readonly required?: boolean; } /** * The slice of AgentCard adapters genuinely vary on. Everything else * (name, version, provider, supportedInterfaces, securitySchemes, * capabilities.streaming/pushNotifications, signatures) is L1 or L2 * and merged in by the shim. * * `description` and `skills` are required — those are the load-bearing * fields that justify L3 existing at all. Everything else is optional. */ export interface AgentCardPartial { readonly description: string; readonly skills: ReadonlyArray; readonly iconUrl?: string; readonly documentationUrl?: string; readonly capabilities?: { readonly extensions?: ReadonlyArray; }; } /** NATS micro endpoint names (match the single-segment verbs in subjects.ts). */ const ENDPOINT_NAME_PUBLIC = "card"; const ENDPOINT_NAME_EXTENDED = "cardx"; /** Queue group. No load-balancing — every adapter instance serves its own card. */ const CARD_QUEUE_GROUP = ""; /** * Register the public AgentCard endpoint on an already-started * `AgentService`. Subject is * `agents.card...` (single-segment verb; * the 5-token shape matches `prompt`/`hb`/`status`). Reply is the * JSON-encoded `partial`, pre-encoded once at registration time and * held in a closure — the SDK serves byte-identical bytes on every * request. * * `service` MUST be started (`await service.start()`) before this is * called — `service.service` throws otherwise. Throws if a `card` * endpoint is already registered (NATS micro rejects duplicate names). * * Returns the subject the handler was registered on, so callers can * log it. */ export function registerAgentCard( service: AgentCardRegistrar, coord: Coord, partial: AgentCardPartial, ): string { const subject = cardSubjectBuilder(coord); const bytes = encodeCard(partial); service.service.addEndpoint(ENDPOINT_NAME_PUBLIC, { subject, queue: CARD_QUEUE_GROUP, handler: (err: Error | null, msg: ServiceMsg) => respondCard(err, msg, bytes), metadata: { "sesh.card.layer": "L3", "sesh.card.scope": "public", }, }); return subject; } /** * Register the auth-gated extended AgentCard endpoint. Subject is * `agents.cardx...` — distinct * single-segment verb so NATS subject-based authz can gate it at the * account level rather than inside the adapter. * * The SDK does NOT check auth — the shim authenticates at the HTTPS * boundary and only calls this subject for principals that pass. * Within the mesh, this subject is treated as internally trusted. * * Same preconditions and same return shape as `registerAgentCard`. */ export function registerAgentCardExtended( service: AgentCardRegistrar, coord: Coord, partial: AgentCardPartial, ): string { const subject = cardxSubjectBuilder(coord); const bytes = encodeCard(partial); service.service.addEndpoint(ENDPOINT_NAME_EXTENDED, { subject, queue: CARD_QUEUE_GROUP, handler: (err: Error | null, msg: ServiceMsg) => respondCard(err, msg, bytes), metadata: { "sesh.card.layer": "L3", "sesh.card.scope": "extended", }, }); return subject; } function encodeCard(partial: AgentCardPartial): Uint8Array { // JSON.stringify of typed input — no custom serializer needed. // Holds the bytes for the lifetime of the AgentService. return new TextEncoder().encode(JSON.stringify(partial)); } function respondCard( err: Error | null, msg: ServiceMsg, bytes: Uint8Array, ): void { if (err) return; try { msg.respond(bytes); } catch (e) { try { msg.respondError(500, `card handler error: ${(e as Error).message}`); } catch { // connection may already be gone } } }