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 inherits parentEventId and tenantId automatically.
  • 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_receivedmessage-matcherchannel-inboundsession:turn_requested → orchestrator → assistant:reply_readyreply-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