Your First Handler
A handler in Beach is a function that processes a routed event. No prompt, no LLM, no model — just code. Handlers are how deterministic work joins the pipeline: database queries, API calls, transforms, side effects.
For the prior question of when to reach for a handler rather than an LLM actor, see Actors vs Handlers.
The minimum
import { EventRouter } from '@cool-ai/beach-core';
const router = new EventRouter();
router.register('fetch-tasks', async (event, context) => {
const { userId } = event.data as { userId: string };
const tasks = await db.tasks.findByUser(userId);
await context.routeEvent({
source: 'tasks',
eventType: 'fetched',
data: { userId, tasks },
});
});
router.loadRoutingConfig({
rules: [
{ source: 'user', eventType: 'request_tasks', handler: 'fetch-tasks' },
],
});
await router.routeEvent({
source: 'user',
eventType: 'request_tasks',
data: { userId: 'user-42' },
});
That is a handler. The signature is (event, context) => Promise<void | 'suppress-filtering'>. Within it the handler can:
- Read
event.data— the payload routed in. - Call
context.routeEvent()— emit follow-on events. The router inheritsparentEventIdandtenantIdautomatically. - Read
context.triggerEvent— inspect the originating event, useful for parent-event chains. - Throw — the router catches the exception, surfaces it in the audit log, and (if the rule is
retryable: true) retries with backoff. - Return
'suppress-filtering'— skip the filtering fan-out for this specific event.
Three shapes a handler will take
Data fetcher
router.register('fetch-tasks', async (event) => {
const { userId } = event.data as { userId: string };
return await db.tasks.findByUser(userId);
});
The router mostly ignores the return value (only 'suppress-filtering' is checked), so the shape above only makes sense when the handler is also registered as a tool. See Reference: tool-registry.
Transform-and-emit
router.register('summarise-tasks', async (event, context) => {
const { tasks } = event.data as { tasks: Task[] };
const summary = {
total: tasks.length,
overdue: tasks.filter((t) => t.due < Date.now()).length,
today: tasks.filter((t) => isSameDay(t.due, Date.now())).length,
};
await context.routeEvent({
source: 'tasks',
eventType: 'summarised',
data: summary,
});
});
The handler reads input, computes, and emits a follow-on event. The next handler in the chain reacts to tasks:summarised.
Side-effecting
router.register('schedule-task', async (event) => {
const { task, deadline } = event.data as ScheduleArgs;
await taskStore.create({ task, deadline });
await calendarApi.addEvent({ title: task, when: deadline });
});
Side effects in deterministic handlers are perfectly normal — write rows, call APIs, send messages. The work is bounded and the inputs are typed.
What not to do
Do not parse free text in a handler. If event.data.text reads "remind me on Friday morning", that is an LLM's job: extract the deadline upstream in an actor with a domainDataSchema, and route the structured result here.
Do not call LLM APIs from inside a handler. If LLM reasoning is needed, that is an actor invocation. Use callActor, or wrap the work as a tool that the orchestrator can decide to call.
Do not hold per-handler state. Handlers run once per event and should be stateless. Persist anything that needs to outlive the call in the database, in the session store, or in the missive store.
Do not return data and expect downstream consumers to see it. Use context.routeEvent() to emit a follow-on event. The return value is reserved for 'suppress-filtering'.
Do not mark a handler retryable: true for logic errors. The retry queue is for transient failures — a network blip, a peer briefly overloaded. A handler that throws because its input was malformed will throw on every retry; that is a bug, not a transient condition.
Wire it as a tool for the orchestrator
If the handler should be callable by an LLM actor as a tool, register it with the ToolRegistry as well:
import { ToolRegistry } from '@cool-ai/beach-llm';
const tools = new ToolRegistry();
tools.register({
name: 'fetch-tasks',
description: 'Fetch all tasks 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.tasks.findByUser(args.userId);
},
});
The orchestrator can now list 'fetch-tasks' in its tools array and call it during the tool loop.
scope: 'router' means the call flows through routeEvent() and is therefore observable by filtering rules and by the audit log. scope: 'specialist' keeps the call private to the calling actor — leaner, but invisible to downstream observers. See Reference: tool-registry.
Wire it into the canonical pipeline
The canonical pipeline routes channel:message_received → message-matcher → channel-inbound → session:turn_requested → orchestrator → assistant:reply_ready → reply-dispatcher. A handler can be inserted at any point along the chain.
Before the orchestrator — for instance, a triage step:
router.loadRoutingConfig({
rules: [
{ source: 'session', eventType: 'turn_requested', handler: 'triage-handler' },
// triage-handler emits assistant:triage_complete and dispatches to one of two paths
{ source: 'assistant', eventType: 'triage_complete',
handler: 'concierge',
when: { payload: { class: { equals: 'needs-orchestrator' } } } },
{ source: 'assistant', eventType: 'triage_complete',
handler: 'auto-acknowledge',
when: { payload: { class: { equals: 'simple-acknowledge' } } } },
],
});
Alongside the orchestrator — for instance, an analytics handler:
router.loadFilteringConfig({
rules: [
{ source: 'assistant', eventType: 'reply_ready',
destinations: [{ to: 'analytics-recorder' }] },
],
});
After the reply — for instance, a follow-up scheduler driven by cascade:
router.loadCascadeConfig({
rules: [
{
name: 'schedule-followup-on-action',
when: { event: 'reply_ready', payload: { actionTaken: { exists: true } } },
cascade: { handler: 'schedule-followup', minimumContext: ['userId'] },
},
],
});
Creating routing rules covers the full pattern surface.
Related
- Actors vs Handlers — when to use which.
- Adding a participant — the full handler-and-actor wiring story.
- Reference: tool-registry — exposing handlers as tools.
- Creating routing rules — wiring handlers into the pipeline.