Non-LLM Actors

Beach's router invokes an ActorFn once per turn-advancement step. LLM actors are one kind; deterministic logic is another. The framework treats them identically — same lifecycle, same RespondCall output, same observability hooks. Choosing one over the other is a question of what kind of work this turn-step does, not a question of how Beach wraps it.

This guide is the wiring story for non-LLM actors. For the broader question of when to advance a turn (vs. handle an event without advancing), see Adding a Participant.

When a deterministic ActorFn is the right shape

Use it when the work this turn-step does is:

  • Triage classification. Inbound shape (length, language, predicate-match, regex) decides which downstream specialist runs next. The decision is mechanical; an LLM would just paraphrase the rule.
  • Rules-engine evaluation. Pre-declared rules over the mailbox state produce a typed decision. The rules engine is the load-bearing logic; the LLM would only restate its output.
  • Database-backed responses. A FAQ lookup, account-state query, or knowledge-base hit answers the user directly without LLM paraphrasing. (LLMs are still useful for composing the response; the lookup itself is deterministic.)
  • Queue runners. A queue runner takes an inbound request, decides what specialists to dispatch, and produces a RespondCall declaring the turn-state shape. No LLM call at this layer.

When a deterministic ActorFn is the wrong shape

  • Event-router handlers that consume + emit events without advancing a turn. The actor axis and the EventRouter handler axis are orthogonal — the same logical unit can register on both. Use a router handler when routing or transforming events; use an ActorFn when advancing a turn.
  • Free-text generation, multi-step reasoning, paraphrasing. Use createLLMActor — that is the LLM's strength.
  • Anything that needs the LLM's tool-loop machinery. A deterministic ActorFn produces one RespondCall per invocation. For multi-step LLM tool-calling, createLLMActor is the right wrapper.

The shape

import { ActorFn, ActorInvokeOptions, RespondCall } from '@cool-ai/beach-core';

const triageActor: ActorFn = async (opts: ActorInvokeOptions): Promise<RespondCall> => {
  const last = opts.messages[opts.messages.length - 1];
  const text = typeof last?.content === 'string' ? last.content : '';
  const decision = text.length < 20 ? 'too-short' : 'route-to-specialist';
  return {
    parts: [{ partType: 'response', text: `Triage: ${decision}` }],
    turnState: 'complete',
  };
};

router.registerActor('task-triage', triageActor);

The function signature is (opts: ActorInvokeOptions) => Promise<RespondCall>. The router wraps the call with the same lifecycle every actor gets: cancellation signal, session lookup, and delivery fan-out to session destinations.

ActorInvokeOptions carries:

  • messages — the conversation history, including the triggering inbound message.
  • sessionId, turnId, slotKey — the turn's identity fields.
  • signal — an AbortSignal for cooperative cancellation. Wire it into any I/O the actor does.

RespondCall is:

  • parts — the turn's output parts (response, domain-data, a2ui-surface, and so on).
  • turnState'complete' for a settled turn; other values when the turn is not yet done.

Worked example: a classifier with three downstream specialists

import { ActorFn } from '@cool-ai/beach-core';

type Decision = 'flights' | 'hotels' | 'general';

function classify(text: string): Decision {
  const lower = text.toLowerCase();
  if (lower.includes('flight')) return 'flights';
  if (lower.includes('hotel') || lower.includes('booking')) return 'hotels';
  return 'general';
}

const triageActor: ActorFn = async (opts) => {
  const last = opts.messages[opts.messages.length - 1];
  const text = typeof last?.content === 'string' ? last.content : '';
  const decision = classify(text);
  return {
    parts: [{ partType: 'domain-data', dataType: 'triage-decision', data: { decision } }],
    turnState: 'complete',
  };
};

router.registerActor('triage-classifier', triageActor);

After the actor settles, the router fans the reply out to the session's destinations. A routing rule on the resulting delivery:* event can inspect the domain-data part and dispatch the correct specialist. See Adding a Participant for the full pattern with classified routing rules.

Cooperative cancellation

Actors doing I/O (a database lookup, a cache fetch, a peer call) must wire opts.signal into their I/O so cancelTurn() propagates cleanly:

const dbLookupActor: ActorFn = async (opts) => {
  const result = await db.query('SELECT …', { signal: opts.signal });
  return {
    parts: [{ partType: 'response', text: result.summary }],
    turnState: 'complete',
  };
};

Actors without I/O can ignore opts.signal — the work is synchronous from Beach's perspective and finishes before cancellation can interrupt.

What registerActor does NOT change

  • Routing rules. After the actor settles, the router emits one delivery:* event per destination in the session. Routing rules react to those events exactly as they do for LLM actors. The rest of the pipeline does not know or care which kind of actor settled the turn.
  • Filtering. FilterAndDistribute does not distinguish between LLM actor output and deterministic actor output — both produce a RespondCall.
  • Observability. The actor runner records the same lifecycle events — started, settled, cancelled, timed-out — for deterministic actors as for LLM actors.
  • Durable execution. DurableExecutor checkpoints wrap the actor call identically regardless of whether the actor calls an LLM or does its own work.

Related