Adding a Participant
Once a Beach application is running, every new piece of work takes one of two shapes: a deterministic handler or an LLM actor. This guide is the wiring story for both.
For the prior question of when to choose handler over actor, or actor over handler, see Actors vs Handlers.
Terminology note. This guide uses participant in its colloquial sense — any piece of work that runs inside the Beach interior, handler or actor. The rest of this guide is correct for most everyday wiring.
Three steps for every participant
- Implement the work.
- Register it. A handler is registered with the router; an actor is described as an
ActorConfigwith a tool list. - Wire routing rules so that events of the right shape reach it.
That is the whole story. There is no subclassing, no framework lifecycle, no plugin manifest.
Adding a handler
// 1. Implement
const fetchUserBookings = async (event, context) => {
const { userId } = event.data;
const bookings = await db.bookings.findByUser(userId);
await context.routeEvent({
source: 'bookings',
eventType: 'fetched',
data: { userId, bookings },
});
};
// 2. Register
router.register('fetch-user-bookings', fetchUserBookings);
// 3. Wire
router.loadRoutingConfig({
rules: [
/* … */
{ source: 'user', eventType: 'request_bookings', handler: 'fetch-user-bookings' },
],
});
If the handler is also a tool that the orchestrator should be able to call, register it with the ToolRegistry as well:
import { ToolRegistry } from '@cool-ai/beach-llm';
tools.register({
name: 'fetch-user-bookings',
description: 'Fetch all open bookings for the current user.',
scope: 'generalist', // shared domain; framework dispatches via routeEvent
inputSchema: {
type: 'object',
properties: { userId: { type: 'string' } },
required: ['userId'],
},
handler: async (args, ctx) => {
return await db.bookings.findByUser(args.userId);
},
});
scope: 'generalist' means the call touches shared domain and is always routed; the framework dispatches via routeEvent and observers can react. scope: 'specialist' keeps the call private to the calling actor — other actors cannot react mid-turn, though every specialist execution is still appended to the event log. See Reference: tool-registry for the two-axis (scope × routing) definition.
Adding an actor
Actors registered with router.registerActor handle full turns as the orchestrator. Specialist actors that perform focused work inside a handler (a classifier, a triage step) call callActor directly:
import { callActor } from '@cool-ai/beach-llm';
// 1. Define the config
const taskTriageConfig = {
id: 'task-triage',
model: 'claude-haiku-4-5',
systemPrompt: '…',
tools: [],
domainDataSchema: {
type: 'object',
properties: { class: { enum: ['schedule', 'archive', 'clarify'] } },
required: ['class'],
},
};
// 2. Wrap the actor in a handler
router.register('triage-handler', async (event, context) => {
const { sessionId, turnId, inboundMessage } = event.data;
const result = await callActor({
config: taskTriageConfig,
messages: [inboundMessage],
sessionId, turnId, slotKey: 'triage',
registry: tools, provider,
signal: context.signal,
});
const decision = result.respond.parts.find((p) => p.partType === 'domain-data')?.data;
await context.routeEvent({
source: 'assistant',
eventType: 'triage_complete',
data: { sessionId, turnId, ...decision },
});
});
// 3. Wire
router.loadRoutingConfig({
rules: [
{ source: 'channel', eventType: 'message_matched', handler: 'triage-handler' },
{ source: 'assistant', eventType: 'triage_complete',
handler: 'schedule-task',
when: { payload: { class: { equals: 'schedule' } } } },
{ source: 'assistant', eventType: 'triage_complete',
handler: 'archive-event',
when: { payload: { class: { equals: 'archive' } } } },
{ source: 'assistant', eventType: 'triage_complete',
handler: 'concierge',
when: { payload: { class: { equals: 'clarify' } } } },
],
});
The actor turns unstructured input into a structured decision. Deterministic routing rules dispatch to the right next step based on class.
Inserting a step into the canonical pipeline
The canonical pipeline routes channel:message_received → message-matcher → channel-inbound → session:turn_requested → orchestrator → [router fans out] → one delivery:<kind> per destination on the session. There are three positions in which to insert a participant:
Before the orchestrator. Replace the session:turn_requested rule so that it dispatches to the new handler first, and have that handler emit a derived event the orchestrator listens for.
Alongside the orchestrator. Use a cascade rule or a delivery-event rule to fan replies out to additional consumers — an analytics handler, an audit log, a partner agent.
After the user's reply. Use a cascade rule to fire a follow-on event when the orchestrator's reply matches a predicate.
What not to do
Do not register a handler under a name that has already been taken. The router throws at registration time.
Do not forget to register the tools an actor uses. ActorConfig.tools: ['unknown-tool'] throws at invocation, not at config load. Catch the error early by registering tools first.
Do not return data and expect downstream consumers to see it. Use context.routeEvent(). The router only checks return values for 'suppress-filtering'.
Do not put business logic in the orchestrator actor. The orchestrator is the turn entrypoint — it calls tools and emits respond(). Branching belongs in routing rules or downstream handlers, not in the actor's system prompt as a substitute for routing.
Do not forget callActor's slotKey. Pass a meaningful one — 'concierge.reply', 'triage.classify'. The slot key threads through the audit trail and the tool context.
Related
- Your first handler — the minimum handler example.
- Your first actor — the minimum actor example.
- Actors vs Handlers — when to use which.
- Creating routing rules — wiring participants together.
- Reference: tool-registry — exposing handlers as tools.
- Reference: respond-tool — actor structured output.