@cool-ai/beach-protocol
v0.6.0
Published
Envelope shape, canonical part-type registry, and Agent Card for Beach applications.
Readme
@cool-ai/beach-protocol
Owns the envelope shape, the canonical part-type registry, and the Agent Card — the artefacts a Beach application produces regardless of which edge will deliver them. The envelope is how a participant returns a result through the manifest handler; the part-type registry is an open registry with a canonical core that consumers extend at startup.
Home: cool-ai.org · Documentation: cool-ai.org/docs
Install
npm install @cool-ai/beach-protocolConcern
@cool-ai/beach-protocol provides:
- Envelope types —
AgentResponseEnvelopeas a stream of part events with a settlement marker. Parts arrive progressively during a handler invocation; streaming transports emit them immediately, buffered transports accumulate untilconversationState: 'complete'. - Envelope builder — assembles an envelope stream from a session's mailbox and the handler's current
respond()parts. See https://cool-ai.org/docs/envelope. llm-contexttranslator — runs a fast secondary model (typically Haiku) to translate a handler's conversationalresponseinto analytical prose for peer LLMs. Skipped when no peer-LLM originator is present, or when the peer's Agent Card declares it does notconsumellm-context.- A2A Message Part types — the standard A2A Message shape (
role,parts[]) with Beach'spartTypemetadata extensions. - Agent Card schema + types — re-exports A2A SDK
AgentCard,AgentSkill,AgentInterface,AgentCapabilities,AgentProvider, andAgentExtensionfrom@a2a-js/sdk. AddsBEACH_EXTENSION_URIandAgentCardBeachExtensionfor Beach-specific extension data. Provides abuildAgentCard()builder and JSON Schema validator. - Agent Card publisher middleware — serves
/.well-known/agent-card.json(A2A canonical path). - Canonical part types — registered against
@cool-ai/beach-core'sPartTypeRegistry.
Canonical part types registered by this package
@cool-ai/beach-protocol registers the envelope data parts against @cool-ai/beach-core's PartTypeRegistry on its module load. Core itself owns the conversational-signal partTypes (ack, thinking, response, clarify, error) because they don't depend on envelope delivery — see @cool-ai/beach-core.
The parts registered by this package:
| partType | Purpose |
|----------|---------|
| domain-data | Raw structured data. A2A data part. |
| llm-context | Natural-language analysis from the producing agent's LLM, for a peer LLM to reason about the data. |
| a2ui-surface | A2UI createSurface / updateDataModel / updateComponents messages. |
| artifact | Binary or URL-referenced artifact (file, image, audio). Reference by artifactId; bytes live in @cool-ai/beach-missives's ArtifactStore. |
| reasoning-trace | Captured model-native reasoning (Claude thinking blocks etc.). Persistence configurable. |
| citation | Source reference bound to a specific path in domain-data. |
| approval-request | HITL approval request for a pending tool call. Schema defined below. |
| approval-response | The decision (granted / denied) with correlation ID. Schema defined below. |
| progress | Structured progress updates for long-running operations (e.g. research sections). |
| setState | Handler-initiated state-coordinate transition. Recognised by the EventRouter and applied to session state before delivery. Per-component opt-in via state-machine.json. Stripped from outbound formatters — router instruction, not user content. See state machines guide. |
Consumers register further types against the core registry at startup.
Approval part schemas
Because approvals flow across channels (a chat request may be approved by email reply or WhatsApp "yes"), the parts carry an explicit correlation ID.
// approval-request part `data` shape
interface ApprovalRequest {
approvalId: string; // correlation ID — session-scoped unique
toolName: string;
toolCallId: string; // the LLM's own tool-call identifier
args: Record<string, unknown>;
handler: string; // which handler requested the tool
turn: string; // handler invocation event ID
session: string; // session ID
expiresAt?: string; // ISO datetime; auto-deny after
}
// approval-response part `data` shape
interface ApprovalResponse {
approvalId: string; // matches the request
decision: 'granted' | 'denied';
reason?: string;
decidedBy?: string; // user ID, policy name, or auto-approval rule
decidedAt: string; // ISO datetime
}Transport adapters receiving an approval reply (email reply, WhatsApp message, webhook payload) must parse the approvalId from the reply and construct the approval-response part. Conventional encodings:
- Email: subject token (e.g.
[approval:abc123]) or a parseable body line. - WhatsApp: approval ID embedded in the button payload.
- Chat UI: the approval card's action submits the ID explicitly.
setState part schema
import type { SetStatePart } from '@cool-ai/beach-protocol';
// SetStatePart `data` shape — dot-namespaced coordinate patch
// keys: '<component>.<coordinate>'
// values: the new state value (string)
interface SetStatePart {
partType: 'setState';
data: Record<string, string>;
}Emitted by a handler's respond() output when it proposes a state-coordinate transition. The router validates the patch against the configured StateMachineRegistry, applies it to the session state (atomic — all coordinates accept or none do), and strips the part from the handler's reply before delivery. Destinations and outbound formatters never see setState parts.
Per-component opt-in via allowHandlerTransitions: true in state-machine.json — handlers cannot propose transitions for components that don't explicitly enable this source. Rejected parts fire onStateTransitionRejected on the router.
Cross-channel handling:
- A2A peer envelopes — the part passes through verbatim. A peer Beach receiving the envelope decodes and applies; a non-Beach peer ignores it (no contract).
- Missive records — recorded as-is. Beach does not re-apply a
setStatepart read from a missive store on replay. - Outbound formatters — filtered out before formatting. Domain-data parts and response text are what consumers see.
Delivery rules per transport class
Each part type declares delivery semantics per transport class, registered with the PartTypeRegistry. Example rules:
| partType | Streaming transport | Buffered transport |
|----------|---------------------|--------------------|
| ack, thinking, progress | Flush immediately | Drop |
| response | Stream progressive text deltas | Final value rendered |
| domain-data | Once, on availability | Once, at settlement |
| a2ui-surface | Each update flushed | Final state rendered (often dropped — buffered transports usually cannot render surfaces) |
| artifact | URL-reference preferred | Bytes-as-attachment (e.g. email) |
| llm-context | Produced once at turn settlement via Haiku translator; delivered as a single flush (not progressive). | Produced at settlement; delivered as part of the buffered envelope. |
| reasoning-trace | Audit-only by default | Audit-only by default |
| clarify, error | Flush immediately | Flush immediately |
| approval-request | Flush immediately; turn enters suspended state | Flush immediately |
| approval-response | Inbound only — inject, resume tool | Same |
Consumer-registered part types declare their own rules on registration.
Slot keys and merge strategy
When calling buildEnvelope(), pass slotKey and mergeStrategy to stamp convergence metadata onto domain-data parts:
buildEnvelope({
sessionId, eventId, originatorId, conversationState, parts,
slotKey: 'ta.research-rome', // optional — stamped as metadata.slotKey on domain-data parts
mergeStrategy: 'replace', // optional — 'replace' | 'append' | 'deep-merge'; default 'replace'
});slotKey is the stable identity of the destination slot. Renderers and A2A peers use it to converge successive updates — new data for the same slot replaces (or merges with) the previous value rather than appending a new entry. The A2A outbound adapter mirrors slotKey and mergeStrategy into A2A part metadata automatically.
mergeStrategy originates from LLMHandlerConfig.domainDataMergeStrategy in @cool-ai/beach-llm; slotKey is the destination slot the consumer supplies. The envelope builder receives both from the consumer who calls buildEnvelope().
Envelope assembly
Conceptually, for each turn:
- Collect data-bearing events from the turn's mailbox into a single domain object.
- Derive
llm-contextlazily, only if the turn's originators include at least one peer Agent Card that carries the Beach extension (BEACH_EXTENSION_URI) withenvelopeConsumes: ["llm-context"]. Non-Beach peers (Mastra, Google ADK, etc.) do not receivellm-contextby default. Otherwise skipped. - Build surfaces from the data using consumer-registered surface templates. If no template matches the data kind, the
a2ui-surfacepart is omitted (peer receives data + llm-context only; local UI falls back to generic rendering). - Deliver parts to each originator via its transport adapter, respecting per-part delivery rules for the transport's class.
See https://cool-ai.org/docs/envelope for the full spec.
Surface template registration
Consumers register surface templates per data kind:
// Conceptual.
registerSurfaceTemplate('flight-results', (data) => a2uiBuilder.list(...));
registerSurfaceTemplate('hotel-results', (data) => a2uiBuilder.list(...));The envelope builder looks up a template matching the data kind and calls it to produce the a2ui-surface parts.
Built-in approval surface template
@cool-ai/beach-protocol ships a default surface template for approval-request parts. When an approval request enters the envelope, the builder converts it into an A2UI surface composed from Basic Catalog primitives (Card, Column, Text, Button), bound to the approval's approvalId so the approve/deny buttons submit the correct correlation ID. The result is delivered as a normal a2ui-surface part, indistinguishable to the renderer from any other surface.
Consumers can override this default by registering their own approval-request template. The renderer (@cool-ai/beach-a2ui) has no envelope-part knowledge — all approval-specific concerns live in the template at the protocol layer.
Consumer-defined part types
@cool-ai/beach-protocol's canonical part types cover cross-consumer, cross-transport content. Consumers with bespoke rendering needs — stateful itinerary UI, multi-centre timelines, proprietary visualisations — register their own part types against @cool-ai/beach-core's PartTypeRegistry using a namespaced identifier.
Naming convention
Prefix with a consumer slug to avoid registry collisions:
ta.itinerary-slot-state
ta.multi-centre-timeline
baxter.task-boardBeach validates uniqueness within the registry; it does not validate the payload (the consumer owns the payload contract).
Registration
import { partTypeRegistry } from '@cool-ai/beach-core';
partTypeRegistry.register({
partType: 'ta.itinerary-slot-state',
deliveryRules: {
streaming: 'flush', // send immediately over SSE
buffered: 'drop', // drop — transports that cannot render it should not receive it
},
allowedTransports: ['sse', 'a2a'], // excluded from email, MCP
requiresPeerConsumes: true, // suppressed for peers that do not declare this type in consumes
});requiresPeerConsumes: true mirrors how llm-context works: the part is only sent to a peer whose Agent Card lists it in consumes. A peer without the renderer receives domain-data and llm-context for the same turn; the bespoke part is suppressed for that peer only.
Agent Card advertising
Declare Beach-specific capabilities (including bespoke part types the agent produces or consumes) in the Beach extension on the Agent Card:
"capabilities": {
"extensions": [
{
"uri": "https://beach.cool-ai.io/extensions/v1",
"required": false,
"params": {
"envelopeConsumes": ["domain-data", "a2ui-surface", "ta.itinerary-slot-state"]
}
}
]
}Beach peers check envelopeConsumes in this extension to determine which part types they accept. Non-Beach peers without the extension receive only the standard A2A Message parts.
Worked example: TA itinerary surface
// 1. Register on startup.
partTypeRegistry.register({
partType: 'ta.itinerary-slot-state',
deliveryRules: { streaming: 'flush', buffered: 'drop' },
allowedTransports: ['sse', 'a2a'],
requiresPeerConsumes: true,
});
// 2. Produce it in respond().
respond({
conversationState: 'complete',
parts: [
{ partType: 'response', text: 'Here is your itinerary.' },
{ partType: 'domain-data', data: packageData },
{ partType: 'ta.itinerary-slot-state', data: itinerarySlotState },
],
});
// 3. In the browser, route by partType.
envelopeStream.on('part', (part) => {
if (part.partType === 'a2ui-surface') a2uiRenderer.handle(part);
if (part.partType === 'ta.itinerary-slot-state') itineraryRenderer.apply(part.data);
});a2ui-surface and ta.itinerary-slot-state are siblings: both envelope parts, dispatched by partType, each handled by its own renderer. The envelope builder, transport layer, and @cool-ai/beach-inspect event log treat them identically — dispatching on partType without inspecting the payload.
See a2ui README for renderer-side guidance.
Not in this package
- The A2UI Basic Catalog renderer or builder primitives (
@cool-ai/beach-a2ui). - Channel transport (
@cool-ai/beach-transport). - Session turn management (
@cool-ai/beach-session).
Consumers
Any agent responding to external callers with structured data. A pure event-processing agent with no outbound envelopes could skip this, but most Beach-based conversational agents include it.
Related
- https://cool-ai.org/docs/envelope — the envelope spec.
- https://cool-ai.org/docs/agent-card — the Agent Card spec.
- https://cool-ai.org/docs/diagrams/envelope-assembly.svg — the assembly flow.
