Creating Routing Rules
The EventRouter is the only path along which components in a Beach application talk to each other, and routing rules are how the application tells it what goes where. The rules are declarative JSON, so a reader of the repository can see exactly how an event flows without reading any handler code.
This guide covers the three rule kinds — routing, filtering, and cascade — and the predicate DSL they share.
The three kinds of rule
| Kind | What it does | Cardinality |
|---|---|---|
| Routing | Dispatches an event to exactly one handler. The handler is the primary work unit. | 1:1 |
| Filtering | Fans an event out to additional destinations after routing. Each destination may apply a data shape. | 1:N |
| Cascade | Fires a derived event when the original matches a predicate. The cascade target is itself a routed event. | 1:N |
Routing is required. Filtering and cascade are optional layers built on top of it.
Routing rules
router.loadRoutingConfig({
rules: [
{ source: 'channel', eventType: 'message_received', handler: 'message-matcher' },
{ source: 'session', eventType: 'turn_requested', handler: 'orchestrator' },
{ source: 'assistant', eventType: 'reply_ready', handler: 'reply-dispatcher' },
],
});
Each rule says: "an event with this source and this eventType goes to that handler". The handler must already have been registered with router.register(name, fn) when an event arrives for it.
Payload-conditional routing
Several rules can share the same (source, eventType) provided each one adds a when predicate. The first match wins, evaluated in declaration order.
router.loadRoutingConfig({
rules: [
// Urgent travel queries take a dedicated triage actor
{ source: 'user', eventType: 'message', handler: 'urgent-triage',
when: { payload: { priority: { equals: 'high' } } } },
// Default route — everything else
{ source: 'user', eventType: 'message', handler: 'concierge' },
],
});
A user message with data: { priority: 'high', text: '...' } matches the first rule. A user message without priority falls through to the second.
When no rule matches, routeEvent() throws RouteNotFoundError. The router never silently drops an event.
Retry semantics
Mark a rule retryable: true and any failure inside its handler will pass through the retry queue with exponential backoff:
{ source: 'a2a', eventType: 'peer_message_received', handler: 'peer-handler', retryable: true }
Retry exists for transient failures — a network blip, a peer agent briefly overloaded. Logic errors, where a handler throws because its input was malformed, should fail fast. Do not mark them retryable.
The predicate DSL
Routing when clauses, cascade when clauses, and the other predicate sites in Beach all share one shape:
type PayloadClause = {
payload?: Record<string, FieldPredicate>; // all must pass — AND
anyOf?: PayloadClause[]; // at least one — OR
allOf?: PayloadClause[]; // all — AND
};
type FieldPredicate =
| { equals: unknown } // exact value match
| { exists: boolean }; // field present (true) or absent (false)
payload, anyOf, and allOf may all appear in one clause; all three must hold for the rule to match. A few worked examples:
// Exact match — booking confirmations
{ payload: { status: { equals: 'confirmed' } } }
// Field must exist — only handle messages with a payload field
{ payload: { destination: { exists: true } } }
// Either departure date or travel month — at least one must be present
{ anyOf: [
{ payload: { departureDate: { exists: true } } },
{ payload: { travelMonth: { exists: true } } },
] }
// Both destination and number of passengers — both required
{ allOf: [
{ payload: { destination: { exists: true } } },
{ payload: { passengers: { exists: true } } },
] }
// Combined — a high-priority message with a destination
{
payload: { priority: { equals: 'high' } },
allOf: [
{ payload: { destination: { exists: true } } },
],
}
The predicate evaluator runs against event.data only — never against headers or context.
Filtering rules
Once routing has dispatched an event to its handler, filtering can fan the same event out to further destinations. The pattern earns its place when one event needs to reach several consumers — a browser SSE renderer, a Redis archive, a partner agent — and each one wants its own data shape.
router.loadFilteringConfig({
rules: [
{
source: 'researcher', eventType: 'results_ready',
destinations: [
{ to: 'browser-renderer' }, // full data
{ to: 'redis-archive', shape: 'full' }, // full data, made explicit
{ to: 'partner-agent', shape: 'summary' }, // shaped down
],
},
],
});
Each destination's to is a registered handler name. shape references a data-shape transform registered at startup:
router.registerShape('summary', (data) => ({
resultCount: (data as { results: unknown[] }).results.length,
generatedAt: new Date().toISOString(),
// … only the fields a partner agent needs
}));
If shape is omitted (or is 'full'), the destination receives the original data unchanged.
Filtering does not affect routing. The original event still goes to its routed handler. Filtering destinations run in parallel after the routed handler completes; their failures are isolated from one another and from the routed handler.
Suppressing filtering
A routed handler can return 'suppress-filtering' to skip the filtering pass for the event in hand:
router.register('orchestrator', async (event, context) => {
if (eventLooksMalformed(event)) {
return 'suppress-filtering';
}
// … normal work
});
Use this sparingly. Filtering exists so that observers — the audit log, a partner agent, a metrics handler — see every event the routed handler sees. Suppression hides the event from those observers as well, which is occasionally what you want and usually not.
Cascade rules
A cascade rule fires a derived event when the triggering event matches a predicate. The pattern earns its place when one event should produce a follow-on event with additional context — a user message that should also kick off a research handler, an order confirmation that should also dispatch a shipping notice.
router.loadCascadeConfig({
rules: [
{
name: 'enrich-search',
when: { event: 'search_completed', payload: { hasResults: { equals: true } } },
cascade: {
handler: 'enrich-results',
productType: 'destination',
minimumContext: ['userId'],
},
},
],
});
The when predicate adds an event field to the standard predicate clause; that field matches eventType. When the triggering event matches, a new event is emitted:
{
source: 'cascade',
eventType: 'enrich-results', // = cascade.handler
data: {
...originalEventData,
...mergedContext, // session context + cascadeContextProvider result
productType: 'destination',
},
parentEventId: <triggering event's eventId>,
}
The cascade event then routes through the standard routeEvent() path — there is a routing rule for cascade:enrich-results that points at the handler.
minimumContext and suppression
Cascade rules often need context that is not on event.data alone — userId, tenantId, a session-scoped feature flag. Declare those as minimumContext:
cascade: {
handler: 'enrich-results',
productType: 'destination',
minimumContext: ['userId', 'tenantId'],
}
If the minimumContext fields are not available — neither in event.data, nor in the context passed to routeEvent(event, context), nor in the cascadeContextProvider — the cascade is suppressed. The point of suppression is to prevent downstream handlers from seeing partially-populated cascade events that look valid but lack the auth context they need to behave correctly.
When the context is loaded asynchronously, register a provider:
const router = new EventRouter({
cascadeContextProvider: async (event) => {
return await sessionStore.get((event.data as any).sessionId);
},
});
The provider runs once per routeEvent() call and is merged with any synchronous context the caller passed in. Provider values win on conflict.
Detecting suppression
Pass onCascadeSuppressed to RouterOptions to surface suppression events:
const router = new EventRouter({
onCascadeSuppressed: ({ ruleName, missingFields, event }) => {
metrics.increment('cascade.suppressed', { rule: ruleName });
// optionally emit a fallback event, log, etc.
},
});
Without the callback, the router logs suppressions through console.warn.
A complete example
A personal-organiser-style application brings routing, filtering, and cascade together:
import { EventRouter } from '@cool-ai/beach-core';
const router = new EventRouter({
cascadeContextProvider: async (event) => {
return await sessionStore.get((event.data as any).sessionId);
},
onCascadeSuppressed: ({ ruleName, missingFields }) => {
console.warn(`[cascade suppressed] ${ruleName} missing: ${missingFields.join(', ')}`);
},
});
// Handlers
router.register('triage', triageHandler); // LLM
router.register('schedule-task', scheduleTaskHandler); // deterministic
router.register('archive-event', archiveEventHandler); // deterministic
router.register('reminder-poke', reminderPokeHandler); // deterministic
router.registerShape('audit', (data) => ({
type: 'event-audit',
at: new Date().toISOString(),
fields: Object.keys(data as object),
}));
// Routing — every email goes to triage; high-priority email takes a dedicated path
router.loadRoutingConfig({
rules: [
{ source: 'email', eventType: 'received',
handler: 'urgent-triage',
when: { payload: { priority: { equals: 'high' } } } },
{ source: 'email', eventType: 'received', handler: 'triage' },
{ source: 'assistant', eventType: 'task_decision',
handler: 'schedule-task',
when: { payload: { decision: { equals: 'schedule' } } } },
{ source: 'assistant', eventType: 'task_decision',
handler: 'archive-event',
when: { payload: { decision: { equals: 'archive' } } } },
],
});
// Filtering — every triage decision also goes to audit
router.loadFilteringConfig({
rules: [
{
source: 'assistant', eventType: 'task_decision',
destinations: [
{ to: 'audit-log', shape: 'audit' },
],
},
],
});
// Cascade — when a task is scheduled with a future deadline, set up a reminder
router.loadCascadeConfig({
rules: [
{
name: 'schedule-reminder-on-deadline',
when: {
event: 'task_scheduled',
payload: { hasDeadline: { equals: true } },
},
cascade: {
handler: 'reminder-poke',
productType: 'reminder',
minimumContext: ['userId', 'timezone'],
},
},
],
});
The orchestrator emits one assistant:task_decision event after each turn. The router dispatches it to either schedule-task or archive-event based on the LLM's decision; the audit log receives a copy through filtering; and if the task was scheduled with a deadline, a reminder is queued through cascade — provided userId and timezone are available in the session context.
Common shapes
A "log-everything" filtering rule
{
source: 'assistant', eventType: 'reply_ready',
destinations: [{ to: 'event-log', shape: 'audit' }],
}
A "trigger downstream agent" cascade
{
name: 'notify-billing-agent',
when: { event: 'order_completed', payload: { totalGbp: { exists: true } } },
cascade: {
handler: 'a2a-peer-call:billing-agent',
productType: 'order',
minimumContext: ['orderId', 'userId'],
},
}
The peer-call handler in @cool-ai/beach-transport translates the cascade event into an A2A message/send request to the billing agent.
A guarded routing rule
{
source: 'channel', eventType: 'message_received',
handler: 'authenticated-flow',
when: { allOf: [
{ payload: { sessionId: { exists: true } } },
{ payload: { authenticated: { equals: true } } },
] },
}
Falls through to a downstream unauthenticated-flow rule if the message does not carry both fields.
Pitfalls
Two rules without a when for the same (source, eventType). The first match wins; the second is dead code. Add a when to disambiguate, or remove the second rule.
Filtering destinations that need to be transactional with the routed handler. Avoid. Filtering runs after the routed handler and isolates its failures. When transactional fan-out is what is wanted, dispatch the additional events from inside the routed handler with context.routeEvent().
Cascade rules with minimumContext: [] that nonetheless reach for fields. The handler will receive whatever the merged event-data and context happen to contain; if the field is missing, the handler crashes. Declare what is needed in minimumContext and let suppression handle missing context cleanly.
Routing rules that expect implicit ordering. The router has no notion of "this rule runs before that one" beyond declaration order for when-guarded rules that share the same (source, eventType). When ordering across distinct events matters, model it as cascade rules.
Related
- Using the starter scaffold — the canonical routing config.
- Pushing results to the user — cascade rules in the research-fanout pattern.
- Manifests — what cascade events often feed into.
- Reference: design principles — principle 3.2 (declarative configuration).