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.
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: 'router', // call goes through routeEvent — observable
inputSchema: {
type: 'object',
properties: { userId: { type: 'string' } },
required: ['userId'],
},
handler: async (args, ctx) => {
return await db.bookings.findByUser(args.userId);
},
});
scope: 'router' means the call flows through the router and is therefore observable. scope: 'specialist' keeps the call private to the calling actor. See Reference: tool-registry.
Adding an actor
// 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 respond = await sessionManager.runTurn({
sessionId, turnId, slotKey: 'triage',
actorId: taskTriageConfig.id,
actorConfig: taskTriageConfig,
provider, registry: tools,
inboundMessage,
});
const decision = respond.parts.find((p) => p.partType === 'response')?.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 → assistant:reply_ready → reply-dispatcher. 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 filtering rule to fan assistant:reply_ready 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 handler. The orchestrator handler is plumbing — it calls runTurn() and routes the reply. Branching belongs in routing rules, not in the handler body.
Do not forget runTurn()'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.