Using the Starter Scaffold
@cool-ai/beach-starter is a small package that wraps the canonical Beach pipeline as a set of reusable handlers. The application registers its orchestrator; the starter handles everything around it.
What the scaffold gives you
Four handlers, registered under canonical names — the channel-blind shape:
| Handler | Consumes | Emits |
|---|---|---|
message-matcher |
channel:message_received |
channel:message_matched |
channel-inbound |
channel:message_matched |
session:turn_requested |
chat-collector |
delivery:ui_streaming |
publishes to the application's streaming transport |
response-collector |
delivery:formatter |
writes a missive draft to the store |
After the orchestrator's turn settles, the router fans out one delivery:<destinationKind> event per destination on the session. That fan-out is wired by router.registerActor — not a handler of its own. Alongside the handlers, the package ships a routing.json template that wires these names together.
The minimum wiring
import { EventRouter } from '@cool-ai/beach-core';
import { createLLMActor } from '@cool-ai/beach-llm';
import { registerCanonicalHandlers } from '@cool-ai/beach-starter';
import { InMemoryMissiveStore } from '@cool-ai/beach-missives/stores';
import routingConfig from '@cool-ai/beach-starter/templates/routing.json' with { type: 'json' };
const router = new EventRouter();
const store = new InMemoryMissiveStore();
// 1. Register the orchestrator actor. The router handles turn lifecycle and
// destination fan-out internally; no additional plumbing needed.
const concierge = createLLMActor({ actorConfig, provider, registry: tools });
router.registerActor('my-orchestrator', concierge);
// 2. Register the canonical pipeline handlers.
registerCanonicalHandlers(router, {
resolveSession: (data) => data.threadId,
chatPublish: async (sessionId, parts) => { /* publish to the streaming transport */ },
store,
});
// 3. Load the routing config (replace YOUR_ORCHESTRATOR with 'my-orchestrator' first).
router.loadRoutingConfig(/* the routing template with YOUR_ORCHESTRATOR substituted */);
The orchestrator is the only component the application writes itself. It receives channel-blind session:turn_requested events (no channelId in the payload). After the actor's respond() settles, the router reads the session's destination set and emits one delivery event per destination.
All registerCanonicalHandlers options
interface CanonicalHandlerOptions {
resolveSession: (data: InboundEventData) => string | Promise<string>;
chatPublish?: (sessionId: string, parts: MissivePart[]) => Promise<void>;
store?: MissiveStore;
}
The fields are independent. The destination set on each session decides where replies go (populated by the inbound adapter at router.openSession() time). A chat-only application leaves store undefined; an email-only application leaves chatPublish undefined; a multi-channel application sets both. Whether a particular session uses streaming or formatter destinations is decided at session open, not at registration.
resolveSession
Beach asks the application to map an inbound event to a sessionId, because the strategy is genuinely application-specific. A few examples illustrate the spread:
// SSE — the sessionId arrives from the HTTP session, embedded in the inbound payload
resolveSession: (data) => data.sessionId as string
// Email — thread by RFC 5322 In-Reply-To, fall back to the origin messageId
resolveSession: (data) => (data.inReplyTo ?? data.origin?.messageId) as string
// File-mailbox — derive from the filename
resolveSession: (data) => path.basename(data.filename as string, '.txt')
// Anything async — load from a database
resolveSession: async (data) => {
const userId = data.userId as string;
return await db.getActiveSession(userId) ?? createSession(userId);
}
chatPublish and the ui-streaming destination
Streaming channels — SSE, WebSockets, voice — deliver the reply as it is produced. When a session's destination set carries a { kind: 'ui-streaming' } entry, the router emits a delivery:ui_streaming event for it after the turn settles; chat-collector subscribes to that event and invokes chatPublish to forward parts onto whichever transport the application uses.
The common pattern is Redis pub/sub:
chatPublish: async (sessionId, parts) => {
await redis.publish(`reply:${sessionId}`, JSON.stringify(parts));
}
The SSE endpoint subscribes to reply:<sessionId> and forwards each payload as an event-stream chunk.
store and the formatter:* destination
Batched channels — email, SMS, file-mailbox — hold the reply until the turn settles, then send a single message. When a session's destination set carries a { kind: 'formatter:<channel>', formatterChannel: '<channel>' } entry, the router emits a delivery:formatter event; response-collector subscribes to that event and writes a missive draft to the store. The outbound edge (the IMAP/SMTP wrapper, say) reads the store inside Manifest.onComplete and sends from there.
The choice of store depends on the application's durability needs:
import { InMemoryMissiveStore } from '@cool-ai/beach-missives/stores'; // tests
import { JsonFileStore } from '@cool-ai/beach-missives/stores'; // prototypes
import { SqliteStore } from '@cool-ai/beach-missives/stores'; // single-node production
import { RedisStore } from '@cool-ai/beach-missives/stores'; // multi-node production
Filter-and-distribute is no longer a starter option
Earlier versions of the starter exposed a filterAndDistribute option on registerCanonicalHandlers. From @cool-ai/beach-starter@1.1.0 the option is removed: the FilterAndDistribute primitive lives in @cool-ai/beach-core and is wired directly via callActor's filterAndDistribute parameter. See the filter-and-distribute guide for the new shape.
Multi-channel example
registerCanonicalHandlers(router, {
resolveSession: (data) => data.threadId, // first-layer field — channel-blind
chatPublish: async (sessionId, parts) => { await redis.publish(`reply:${sessionId}`, JSON.stringify(parts)); },
store: new SqliteStore('./missives.db'),
});
The registration is destination-agnostic. Each channel adapter populates its own destination set when it opens a session — chat opens with [{ kind: 'ui-streaming' }, { kind: 'audit' }]; email opens with [{ kind: 'formatter:email-html', formatterChannel: 'email-html' }, { kind: 'audit' }]. Multi-channel replies (a client emailed and texted; reply via both) work by populating both formatter:email-html and formatter:whatsapp destinations on the same session — the router emits two delivery events without any new code.
One application, two channels, one orchestrator. The orchestrator is channel-blind by construction — it sees a channel-blind { sessionId, turnId, inboundMessage } payload and emits parts, with no idea whether the reply is going to a chat panel, an inbox, both, or anywhere else.
Extending the scaffold
The scaffold is the canonical pipeline. Use it as it stands. Replacing one of the four canonical handlers with a custom implementation is, almost always, the wrong move: the handler is the contract Beach asks every application to keep, and an application that swaps it out has chosen to discard the architecture. At that point there is little reason to be using Beach at all.
The instinct to replace a canonical handler is, more often than not, a misdiagnosis. Ask first whether what is wanted is an edge component the pipeline does not yet have — a Channel Formatter at the outbound edge, a Composer specialist that writes channel-shaped prose around the orchestrator's structured output, a fresh inbound adapter for a new transport. Those are additions; the pipeline keeps its shape.
Three cases that look at first sight like reasons to leave the scaffold behind, and the additions that handle them properly:
- Human-in-the-loop approvals. The actor runner already supports a
'suspended'turn state for tools markedrequiresApproval: true. The orchestrator suspends, the UI renders the approval request, the user's decision routes back as anapproval-responseevent, and the existing scaffold resumes the turn. No custom orchestrator is needed; the discipline is in the four-part flow described in Reference: tool-registry. - Channel-specific reply formatting. A reply that needs to read as a chat message in one channel and as a formal email in another should not be reformatted upstream of the outbound edge. The orchestrator emits structured parts; a Channel Formatter at the edge — a small deterministic component, registered alongside the SMTP wrapper — converts those parts into the channel-native artefact. The orchestrator stays channel-blind. See the channel-aware-orchestrators entry in Anti-patterns.
- An application that is both a chat agent and an A2A peer. Add an A2A inbound adapter alongside the chat inbound; both feed the same router and the same canonical handlers. No replacement is required, and the orchestrator does not learn what protocol the request arrived on. See Being consumed by other applications.
In each case the addition sits at the edge and the canonical handlers stay where they are. The shape of the pipeline is the architecture; the desire to reach into the centre is, in itself, the diagnosis that an edge component is missing.
Related
- Getting Started — install Beach and run the canonical pipeline end to end.
- Creating routing rules — what
routing.jsonactually does. - Setting up email — wire a batched channel into the same scaffold.
- Pushing results to the user — background research fan-out patterns and how results route back to the actor.