npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cool-ai/beach-core

v0.13.4

Published

Event router, manifest registry, and open registries — the first half of Beach's architectural centre.

Readme

@cool-ai/beach-core

Owns the event router, manifest handler, and session lifecycle — Beach's architectural centre. Every cross-component message is a routeEvent() call; every asynchronous or suspended flow is a Manifest waiting for a result; every participant — deterministic or LLM-backed — is an EventHandler registered via register.

Home: cool-ai.org · Documentation: cool-ai.org/docs

Install

npm install @cool-ai/beach-core

EventRouter

Every cross-component message in a Beach application passes through routeEvent(). Nothing bypasses it.

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

const router = new EventRouter();

// Register handlers by name
router.register('handle_search', async (event) => {
  const { query } = event.data;
  // ... perform search
});

// Load declarative routing config
router.loadRoutingConfig({
  rules: [
    { source: 'user', eventType: 'search_requested', handler: 'handle_search' },
    // Payload-conditional dispatch — first match wins
    { source: 'user', eventType: 'message', handler: 'handle_urgent',
      when: { payload: { priority: { equals: 'high' } } } },
    { source: 'user', eventType: 'message', handler: 'handle_normal' },
  ],
});

// Dispatch an event
const { eventId } = await router.routeEvent({
  source: 'user',
  eventType: 'search_requested',
  data: { query: 'flights to Rome' },
});

routeEvent returns { eventId } — the framework-minted id (or the caller-supplied one, when event.eventId is set). The same id flows to cancelEvent(eventId), surfaces in RoutingDecisionEvent.eventId, and is what parentEventId references on descendant events. Callers that don't need the id can ignore the return value.

Payload predicate DSL

Routing when guards and cascade when clauses share a common predicate shape:

const when = {
  payload: {
    status: { equals: 'confirmed' },   // exact match
    region: { exists: true },          // field must be present
  },
  anyOf: [
    { payload: { departureDate: { exists: true } } },
    { payload: { travelMonth:   { exists: true } } },
  ],
  allOf: [
    { payload: { destination: { exists: true } } },
    { payload: { passengers:  { exists: true } } },
  ],
};

payload — all predicates must pass (AND). anyOf — at least one sub-clause must pass (OR). allOf — all sub-clauses must pass (AND). All three must be satisfied when present.

Derived events

The router has no separate cascade dispatcher. To fire a derived event when a triggering event matches, register a handler whose body inspects the event data and calls ctx.routeEvent(derived):

router.register('search_completed_handler', async (event, ctx) => {
  const data = event.data as { hasResults?: boolean };
  if (data.hasResults === true) {
    await ctx.routeEvent({
      source: 'derived',
      eventType: 'enrich_results',
      data: { ...event.data, productType: 'enrichment' },
    });
  }
});

router.loadRoutingConfig({
  rules: [
    { source: 'search', eventType: 'search_completed',
      to: [{ handler: 'search_completed_handler' }] },
    { source: 'derived', eventType: 'enrich_results',
      to: [{ handler: 'enrich_results' }] },
  ],
});

The minimum-context check, downstream suppression, and any async-context fetch all live in the handler.

State machine config

Per-component state coordinates declared in state-machine.json; populated on the Session at open time, readable as a typed state field inside handlers, and routable via guard / effect clauses on routing rules.

import { EventRouter, StateMachineRegistry, parseStateMachineConfig } from '@cool-ai/beach-core';

const stateMachineRegistry = new StateMachineRegistry(parseStateMachineConfig({
  components: {
    concierge: {
      coordinates: {
        mode:    { values: ['discover', 'search', 'build'], default: 'discover' },
        subMode: { values: ['initial', 'results_landed', 'basket_added'], default: 'initial' },
      },
      transitions: [
        { from: { mode: 'discover' }, to: { mode: 'search' } },
        { from: { mode: 'search' },   to: { mode: 'build'  } },
        { to: { subMode: 'results_landed' } },
        { to: { subMode: 'basket_added' } },
      ],
      onIllegalTransition: 'throw',
    },
  },
}));

const router = new EventRouter({ stateMachineRegistry });

Coordinates are closed enums — Beach rejects undeclared values at registration. Transitions are an explicit allowlist; 'throw' raises IllegalTransitionError on illegal patches, 'drop' silently discards (logs via onStateTransitionRejected).

State lives on Session.state as ReadonlyMap<componentId, ReadonlyMap<coordinate, value>>, eagerly initialised at openSession from declared defaults. Override via SessionConfig.initialState:

router.openSession({
  id: sessionId,
  destinations: [...],
  initialState: { concierge: { mode: 'search' } },
});

Three transition sources

1. RouteEvent.setState — channel-inbound writes carried alongside the inbound event. Applied before any handler sees it; stripped from the dispatched event the same way channelType is stripped:

await router.routeEvent({
  source: 'channel',
  eventType: 'mode_change',
  data: { sessionId },
  setState: { 'concierge.mode': 'search' },
});

2. RoutingRule.effect.setState — routing-rule effects applied before dispatch so the handler sees post-effect state:

{
  "source": "handler",
  "eventType": "package_results",
  "handler": "concierge",
  "guard":  { "state": { "concierge.mode": "search" } },
  "effect": { "setState": { "concierge.subMode": "results_landed" } }
}

3. Handler respond() part of type setState — handler-initiated transitions. Gated per-component by allowHandlerTransitions: true. The part is stripped from the reply before delivery (router instruction, not user content).

// Inside a handler:
return {
  parts: [
    { partType: 'response', text: 'Here are the options.' },
    { partType: 'setState', data: { 'concierge.mode': 'search' } },
  ],
  conversationState: 'awaiting-user',
};

Read-side guards on routing rules

{
  "rules": [
    { "source": "h", "eventType": "x", "handler": "a", "guard": { "state": { "concierge.mode": "search" } } },
    { "source": "h", "eventType": "x", "handler": "b", "guard": { "state": { "concierge.mode": "build"  } } }
  ]
}

Rules whose guard doesn't match are skipped in config order. If no rule matches at all, the event is silently dropped with a warn log — same shape as when-not-matched.

Observability

const router = new EventRouter({
  stateMachineRegistry,
  onStateTransition: ({ sessionId, source, changes }) => {
    // source: 'event' | 'effect' | 'part'
  },
  onStateTransitionRejected: ({ sessionId, source, rejections }) => {
    // rejections: [{ component, coordinate, from, to, reason }, ...]
  },
});

See the state machines guide and the decision doc for design rationale, the typed handler surface (LLMHandlerConfig<TState>, StateSchema), and common patterns.

Observability callbacks

RouterOptions accepts three optional callbacks for observability or testing:

const router = new EventRouter({
  onRoutingDecision: (decision) => {
    // Fires on every routing attempt — matched or unmatched.
    if (decision.matched) {
      console.log('routed to', decision.handler);
    } else {
      console.log('unmatched:', decision.reason); // 'no-rule' | 'no-when-match'
    }
  },
});

onRoutingDecision fires in all three dispatch paths: matched, no-rule-found (before RouteNotFoundError is thrown), and when-guard failed (before silent drop). The payload is a discriminated union — { matched: true; handler; ... } or { matched: false; reason: 'no-rule' | 'no-when-match'; ... }. sessionId is extracted defensively from event.data when present. handler on a matched decision reports the first target in the rule's to[]; observers wanting the full target list read the rule directly.

RoutingDecisionEvent carries an optional emitter (CAIB-278) — the component id of the handler that physically called ctx.routeEvent. Distinct from source, which is the event's logical source the handler chose to attribute. Absent for top-level entry points (webhook ingress etc.) where no handler is active. Inspect uses this so a handler calling routeEvent with a source that doesn't match its own component id no longer appears to do nothing.

CAIB-278 observer-surface extensions

Four additional RouterOptions callbacks surface what was previously invisible between routing arrows:

  • onCascadeFill(event) — fired immediately before the router cascades a child manifest's assembled value into a parent slot. Distinguishes cascade fills from producer fills.
  • onSlotFillIntercepted(event) — fired when the router intercepts an event carrying a manifestBinding and routes it into a slot instead of dispatching it. Routing is bypassed for these events; this hook is the only observable surface for them.
  • onStateTransition(event) — already existed; documented here for completeness. Fires on every accepted state-machine transition.
  • onHandlerError(event) — fired when a routed handler throws or rejects. Beach still rethrows; this hook surfaces the failure so silent stops stop being silent.

ManifestObserver gains two callback-lifecycle methods (CAIB-278 §7) — onManifestCallbackStarted and onManifestCallbackSettled — fired around every consumer-registered onComplete / onTimeout invocation. Carries duration and the settle outcome ('settled' / 'threw').

Manifests

A Manifest collects N expected slots before firing a callback. The low-level Manifest class is used directly for a Delivery Manifest at a channel edge; the ManifestHandler wraps it for router-integrated Assembly. For the full treatment see the Manifests guide and How to write a manifest planner.

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

const manifest = new Manifest({
  id: 'search:rome',
  eventId: currentEventId,     // optional — links the manifest to an event in beach-inspect
  expected: ['flights', 'hotels'],
  timeoutMs: 10_000,
  onComplete: async (filled) => {
    // filled: Map<string, SlotFill> — narrow on .status, then read .data
    const flights = filled.get('flights');
    if (flights?.status === 'filled') await assemble(flights.data);
  },
  onTimeout: async (filled) => {
    // filled: Map<string, SlotFill> — only the slots that arrived
    await assemblePartial(filled);
    return 'inject'; // 'inject' | 'events' | 'reset' | void
  },
});

manifest.fill('flights', flightResults);   // → 'accepted' | 'rejected'
manifest.fill('hotels',  hotelResults);    // → both required slots in → onComplete fires

Each slot value is a SlotFill discriminated union — { status: 'filled', data }, { status: 'errored', error }, or { status: 'timed-out', error } — so a producer error or a per-slot deadline fills the slot with a terminal status rather than leaving it empty, and the manifest still completes. A fill() against a manifest that is not open, or whose key is not expected, returns 'rejected' and the data is discarded — there is no orphan queue and no pre-registration buffering. Concurrent manifests are isolated; a fill is keyed to a manifest id.

To enable observability in beach-inspect, pass a ManifestObserver (e.g. ManifestEventStore from @cool-ai/beach-inspect) as the ManifestHandler's observer. It receives lifecycle events for creation, slot fills, completion, timeout, and cancellation. See the beach-inspect README.

Two positions — Assembly and Delivery

Manifest is position-neutral: one primitive, one API. The same class is used in two roles, distinguished only by who opens it and what its completion does — nothing in the code marks them apart.

Assembly Manifestdownstream of the orchestrator, aggregates dispatched work into one composed result.

  • Opened by: the router, from a ManifestPlannerFn.
  • Slots: the sub-results of the work (per-destination search results, sub-fetches, product variants).
  • Filled by: the dispatched handlers' replies, routed into their slots by the router.
  • Completion: hands the composed result to the next handler (re-injecting it into the orchestrator) or to a direct consumer.

Delivery Manifestupstream of the channel, gates when a batched outbound is sent.

  • Opened by: the application's inbound edge of a batched channel (email, SMS) when a message arrives.
  • Slots: typically main_reply; extensible for composite outbound artifacts (an attachment slot, a subject override).
  • Filled by: the edge, from the orchestrator's settled respond() parts — the orchestrator stays manifest-blind; the edge does the fill.
  • Completion: the edge formats for the channel and sends.

Streaming channels have no Delivery Manifest — their edge consumes parts as they arrive. Only batched channels need gating. Both positions can coexist in one turn — an Assembly Manifest gathering parallel research inside the turn, a Delivery Manifest outside it holding the email until the turn settles. They don't interact. Note the orchestrator's own summary cannot land in the Assembly Manifest that gathered the results: assembling them completes that manifest, and a fill after completion is dropped — so the summary lands in the Delivery Manifest instead. See Outbound Delivery Ownership.

The primitive knows nothing about channels, turns, sessions, or LLMs. Channel-specificity lives at the edges that open and subscribe — never in core.

Lifecycle

// Low-level Manifest (used directly for a Delivery Manifest)
manifest.status()              // 'open' | 'complete' | 'cancelled' | 'timed-out'
manifest.fill(key, data)       // 'accepted' | 'rejected' — rejected if not open or key unexpected
manifest.signalReady()         // complete now with whatever has filled
manifest.cancel()              // silently stop — no callbacks fire

// ManifestHandler (router-integrated Assembly)
mh.createManifest(spec)        // create from a ManifestSpec
mh.fill(manifestId, slotId, data)
mh.signalReady(manifestId)     // force completion with partial slots
mh.cancelByEvent(eventId)      // cancel every manifest tagged with this eventId → { unregistered, cancelled }

Auto-cleanup on event cancellation

A manifest created through ManifestHandler.createManifest(spec) with eventId set is cancelled when EventRouter.cancelEvent(eventId) fires (or when the triggering event settles via timeout / error), alongside every other handler-managed manifest with the same eventId. Tag each spec with the turn's event id:

mh.createManifest({
  id: `trip:${event.eventId}`,
  eventId: event.eventId,   // ← cleans up with the triggering event
  slots: ['flights', 'hotels'],
});

A standalone Manifest opened directly by an edge (a Delivery Manifest) is not handler-managed, so its lifecycle stays the edge's responsibility — set timeoutMs and handle onTimeout. cancelByEvent is exposed for direct use when a consumer wants to clean up an event's manifests outside the router lifecycle. It returns { unregistered, cancelled }.

Manifest Handler — EventRouter-orchestrated assembly

For Assembly Manifests that are created in response to routed events, ManifestHandler integrates directly with the router. The router creates manifests, intercepts result events, and cascades child completions into parent slots — without extra routing rules for each result type.

import { EventRouter, ManifestHandler } from '@cool-ai/beach-core';

const mh = new ManifestHandler();

const router = new EventRouter({
  manifestHandler: mh,
  onManifestComplete: (id, parentId, assembled, dispatchOnComplete, sessionId) => {
    // id               — the completed manifest's identifier
    // parentId         — undefined for root manifests; set for children
    // assembled        — Map<string, SlotFill> of every filled slot (narrow on .status)
    // dispatchOnComplete — mirrors the flag in ManifestSpec; always false for root manifests
    // sessionId        — the session that owns this manifest, or undefined
  },
});

Registering a planner

A ManifestPlannerFn creates the manifest tree when a trigger event arrives. Register it once at startup:

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

const planner: ManifestPlannerFn = (event, ctx) => {
  if (event.source !== 'search' || event.eventType !== 'requested') return null;
  const id = `search:${event.data.query}`;
  return {
    manifests: [{
      id,
      slots: [
        { id: 'flights', dispatch: { source: 'api', eventType: 'fetch', data: { query: event.data.query, kind: 'flights' } } },
        { id: 'hotels',  dispatch: { source: 'api', eventType: 'fetch', data: { query: event.data.query, kind: 'hotels'  } } },
      ],
    }],
    suppressRouting: true,
  };
};

router.registerManifestPlanner(planner);

The planner runs after eventStore.write and before dispatchRouting. When it returns null — the common case for most events — routing proceeds unchanged. When it returns a plan, the router creates the manifests, routes each slot's dispatch event, and skips routing the trigger event if suppressRouting is set.

A slot's dispatch event is routed with replyBinding = { manifestId, slotId } attached. The handler that processes it replies manifest-blind with session:reply; the router copies replyBinding onto the reply as manifestBinding and fills the slot. The handler names no slot — and if it throws, the slot is error-filled.

Planner context — ManifestPlannerContext

The second argument to ManifestPlannerFn carries two router-resolved fields:

interface ManifestPlannerContext {
  channelType: string | undefined;   // the session's channel-type, read off the session this turn
  session:     PlannerView | undefined; // the session minus its destinations binding
}

ctx.channelType is the session's channel-type, read off the session this turn ('streaming', 'batched', or any consumer-defined name); undefined for system-internal events with no channel binding. The planner is the only component that reads it. Use it to choose the tree shape:

  • 'batched' (e.g. email) — hold results and release one bundle.
  • 'streaming' (e.g. SSE) — children with dispatchOnComplete: true so each result streams as it lands.
const planner: ManifestPlannerFn = (event, ctx) => {
  if (event.source !== 'search') return null;
  const flights = { id: 'flights', dispatch: { source: 'api', eventType: 'fetch', data: { kind: 'flights' } } };
  const hotels  = { id: 'hotels',  dispatch: { source: 'api', eventType: 'fetch', data: { kind: 'hotels'  } } };

  if (ctx.channelType === 'batched') {
    return { manifests: [{ id: 'root', slots: [flights, hotels] }] };   // one bundle
  }
  // Streaming: each result its own child, dispatched as it lands, under a root that assembles.
  return {
    manifests: [
      { id: 'root', slots: ['root:flights', 'root:hotels'] },
      { id: 'root:flights', parentId: 'root', dispatchOnComplete: true, slots: [flights] },
      { id: 'root:hotels',  parentId: 'root', dispatchOnComplete: true, slots: [hotels] },
    ],
  };
};

ctx.session is a PlannerView — the open session minus its destinations binding — resolved from event.data.sessionId. Read it for staticSlots, tenantId, or other session state. Both fields are undefined when absent. Planners that accept only (event) continue to compile — the second argument is safely ignored.

Fill intercept

The intercept fires on a manifestBinding carried on the event (wrapper metadata), not on any manifestId/slotId placed in data. There are two ways a reply comes to carry one, and a consumer sets neither directly:

  • The router attaches replyBinding = { manifestId, slotId } to a slot's dispatch event; when the handler replies, the router context copies it onto the reply as manifestBinding.
  • A completing child manifest cascades its assembled value into its parent slot.

A planner-supplied event carrying manifestBinding is rejected — use fill: { value } for a plan-time literal, or a slot dispatch for a routed fill. A consumer may set the public replyBinding on an event it routes to re-invoke a handler, to capture that handler's reply into a slot (see Outbound Delivery Ownership). Filtering and cascade rules still run for intercepted events.

Nested manifests

Set parentId on a child spec to cascade completions up the tree. When the child completes, the router fills the parent slot automatically; onManifestComplete fires only when the root settles.

return {
  manifests: [
    { id: 'root',     slots: ['child-m1'] },                                              // parent slot = child's id
    { id: 'child-m1', parentId: 'root', slots: [{ id: 'flights', dispatch: {/* … */} }] },
  ],
};

Set dispatchOnComplete: true on a child spec to also fire onManifestComplete at that level — for streaming channels that need to dispatch sub-results as they land rather than waiting for the root.

Optional slots

Every slot is required by default. Pass a { id, required: false } object instead of a bare string to make a slot optional:

return {
  manifests: [{
    id: 'trip-root',
    slots: [
      'cards',                                   // required — manifest waits for this
      { id: 'sally_reply', required: false },    // optional — included if it arrives in time
    ],
    eventId: event.eventId,
  }],
};

The manifest completes as soon as all required slots fill. Optional slots that were filled before that point appear in assembled; any that hadn't arrived are absent. The object form of a slot declaration carries the optional flag plus the three things that can fill a slot — dispatch (an event whose reply fills it), projection (a transform run on producer data), and fill (a literal value the planner holds at plan time):

type SlotDecl =
  | string
  | {
      id: string;
      required?: boolean;
      dispatch?: RouteEvent;        // an event whose reply fills the slot
      fill?: { value: unknown };    // a plan-time literal — fills directly, no event, no projection
      projection?: SlotProjection;  // transform producer data before storing
    };

A slot's fill: { value } is the sanctioned way for a planner to fill a slot with content it already holds at plan time: the slot fills at createManifest, no event is routed, and no projection runs (the value is final). The nested { value } is deliberate — a literal may itself be undefined/null, so the wrapper signals "this slot has a literal", not the presence of the value. fill is mutually exclusive with dispatch and projection (declaring both throws). A planner never fills a slot by hand-setting manifestBinding on an event — that is rejected.

A spec with no required slots completes on the first fill that arrives, or on the next microtask after creation if no fill arrives first. This is valid but unusual — document the intent at the call site.

Slot projection — projection (CAIB-275)

A slot's object form takes an optional projection: (data: unknown) => unknown — a pure, synchronous transform applied to the producer's data before the slot is stored. Use it to normalise shape, extract a single field, or redact at the fill boundary so onComplete sees the already-projected value:

return {
  manifests: [{
    id: 'enrichment',
    slots: [{
      id: 'profile',
      projection: (raw) => ({ id: (raw as { id: string }).id, name: (raw as { name: string }).name }),
    }],
  }],
};

A throw routes the slot through fillWithError with code: 'PROJECTION'. The producer's raw output and the projection result are both surfaced to ManifestObserver.onManifestSlotFilled as data and projected, so beach-inspect can show both side-by-side without losing the un-projected payload. Cascade fills (a child manifest's assembled result landing in a parent slot) skip projection — the data has already passed through the child's per-slot transforms.

Forcing completion — signalReady()

mh.signalReady('search:rome');

Forces the manifest to complete with whatever slots have been filled so far. The completion callback fires immediately with a partial assembled map. Use this when a consumer can act on partial results rather than waiting for the timeout.

Event-scoped cleanup

Manifests created via ManifestHandler.createManifest(spec) with eventId set are cancelled and cleaned up when router.cancelEvent(eventId) fires, alongside any ManifestHandler-managed manifests with the same eventId. The handler purges its internal dispatch flags and event-membership records atomically with the registry cancel.

QueueObserver (CAIB-277)

A consumer-owned queue (p-queue, bottleneck, or any other implementation) reports admit / emit / errored lifecycle through QueueObserver so beach-inspect can surface queue pressure on the routing-decision arrow that scheduled the work. Beach does not ship a queue primitive — the bounded-concurrency guide stance is preserved verbatim. The observer is the contract; consumers wire their own queue.

import type { QueueObserver, QueueAdmittedEvent } from '@cool-ai/beach-core';

const myObserver: QueueObserver = {
  onAdmitted: (event) => { /* event.queuedDepth, event.runningDepth, event.triggerEventId */ },
  onEmitted:  (event) => { /* event.waitMs */ },
  onErrored:  (event) => { /* event.phase: 'admission' | 'running' */ },
};

@cool-ai/beach-inspect ships one wireInspectQueue engine with built-in pQueueBinding / bottleneckBinding bindings that wrap the consumer's queue / limiter and emit against this observer. queuedDepth and runningDepth are exposed separately rather than as a single depth — collapsing them would make diagnostic output ambiguous across queue implementations. triggerEventId on each event lets Inspect join queue activity to the routing-decision arrow that scheduled the work.

PartTypeRegistry

Startup validation for respond() part types. Call assertRegistered after all registrations are complete, before the application starts accepting requests.

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

// Register custom part types
PartTypeRegistry.register({ name: 'itinerary', description: 'A travel itinerary.' });

// Validate at startup — throws with the full list of missing names
PartTypeRegistry.assertRegistered(['domain-data', 'a2ui-surface', 'itinerary']);

Core registers the conversational signal parts: ack, thinking, response, clarify, error. Consumers register any further part types their handlers use.

Cooperative cancellation

The EventRouter propagates an AbortSignal through the handler chain so cooperative I/O (fetch, etc.) observes cancellation without re-plumbing. The signal originates from a per-event AbortController registered for each routeEvent invocation; an explicit router.cancelEvent(eventId) aborts it. Handlers that wire ctx.signal into their I/O get cancellation cooperatively, with no per-handler plumbing.

Downstream handlers read ctx.signal and pass it into their I/O calls:

router.register('research-runner', async (event, ctx: HandlerContext) => {
  const response = await fetch('https://search.example.com/...', {
    signal: ctx.signal,   // ← cooperative cancellation
  });
  // …
});

The signal inherits automatically across child events. A handler that emits a child via ctx.routeEvent({ ... }) carries the same signal forward unless an override is passed explicitly:

// Inherits parent's signal:
await ctx.routeEvent({ source: 'x', eventType: 'y', data: {} });

// Suppress inheritance for a specific child (e.g. fire-and-forget audit log):
await ctx.routeEvent(
  { source: 'audit', eventType: 'log', data: {} },
  undefined,
  { signal: undefined },
);

Filtering destinations and cascade events also receive the signal — every handler descending from a signal-bearing chain sees it on HandlerContext.signal.

Handlers without I/O remain unaffected. ctx.signal is absent when the event chain carries no cancellable origin; code that needs to detect cancellation state checks ctx.signal?.aborted.

cancelEvent(eventId) — cancel an in-flight event by id

const ok = router.cancelEvent(eventId);
// true  — controller found and aborted; cooperative I/O wired to ctx.signal stops
// false — no in-flight event with that id (already settled, or never dispatched)

Cooperative — Beach does not forcefully interrupt. Handlers ignoring ctx.signal run to completion; side effects already performed are not rolled back. Derived events emitted by a handler via ctx.routeEvent(...) carry an inherited signal automatically, so cancelling the parent aborts in-flight descendants that wire ctx.signal into their I/O.

Identity and authorisation

Beach propagates a Principal record with every event and exposes it on HandlerContext.principal. Beach does not issue Principal records — inbound channel adapters at the identity boundary construct them; the router carries them unchanged through every descendant event. Anonymous channels and system-internal events carry principal: undefined.

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

const principal: Principal = {
  principalId: 'user-42',
  claims: { 'holbrookmill-session': true },
  source: 'channel-sse',
};

await router.routeEvent({
  source: 'channel',
  eventType: 'message_received',
  data: { text: 'hello' },
  principal,
});

Inside a handler:

router.register('concierge', async (event, ctx) => {
  if (ctx.principal === undefined) {
    // anonymous or system-internal — handler decides what that means
  } else if (ctx.principal.claims?.['kind'] === 'agent') {
    // tighter policy for A2A peer agents than for direct users
    // (identity-kind is a consumer-defined claim; Beach carries none)
  }

  // child events inherit the principal automatically
  await ctx.routeEvent({ source: 'concierge', eventType: 'follow_up', data: {} });
});

The Authoriser interface is the consumer-pluggable policy boundary. The router invokes Authoriser.check(event, capability) at dispatch time when a matched routing rule declares requiresCapability. A false return or a thrown exception terminates the dispatch and emits a RouteAuthorisationDeniedEvent.

import { AllowAllAuthoriser, EventRouter } from '@cool-ai/beach-core';
import type { Authoriser } from '@cool-ai/beach-core';

const authoriser: Authoriser = new AllowAllAuthoriser();   // tests where auth is not the unit under test
const deny:        Authoriser = { async check() { return false; } };   // verify defensive-shape behaviour on denial

// Real consumer Authoriser — reads event.principal and event.data to decide.
const myPolicy: Authoriser = {
  async check(event, capability) {
    if (event.principal === undefined) return false;
    if (capability === 'project:update') {
      const data = event.data as { domainId?: string } | null;
      return data?.domainId === 'allowed-domain';
    }
    return false;
  },
};

const router = new EventRouter({ authoriser: myPolicy });

Choose one explicitly. The failure mode the explicit choice protects against: a consumer wires Beach without realising the Authoriser stub exists, and silently gets no-op auth in production. If the loaded config has any rule with requiresCapability but no Authoriser is configured, the router throws at construction and on every loadRoutingConfig call — adding the first capability-requiring rule without wiring an Authoriser fails loudly rather than silently denying every match.

CapabilityToken shape

Tokens granting a principal a named capability share one declared shape:

import type { CapabilityToken, Principal } from '@cool-ai/beach-core';
import { CAPABILITY_TOKENS_CLAIM_KEY } from '@cool-ai/beach-core';

const token: CapabilityToken = {
  principalId: 'user-1',
  capability: 'project:update',
  scope: 'domain:42',
};

const principal: Principal = {
  principalId: 'user-1',
  claims: {
    [CAPABILITY_TOKENS_CLAIM_KEY]: [token],
  },
};

The token type is the minimum Beach declares; Beach reads no fields structurally. Consumers needing audit / multi-issuer routing / expiry / signatures / delegation extend the type. The canonical attachment point on Principal.claims is 'capability-tokens' (kebab + plural).

requiresCapability routing rule

A rule opts into capability checking by setting requiresCapability. The router consults the configured Authoriser after the rule matches but before any side effect (effect or dispatch).

{
  "rules": [
    {
      "source": "inbound:chat",
      "eventType": "project_update",
      "to": [{ "handler": "project-updater" }],
      "requiresCapability": "project:update"
    }
  ]
}

Scope and resource semantics are extracted by the Authoriser from event.data — not declared on the rule. A rule says only "the dispatching principal must be authorised for capability X."

Auditing denials

When a check denies (or throws), the router emits a RouteAuthorisationDeniedEvent via routeEvent itself. Consumers wire an audit handler:

import {
  ROUTE_AUTHORISATION_DENIED_SOURCE,
  ROUTE_AUTHORISATION_DENIED_TYPE,
} from '@cool-ai/beach-core';
import type { RouteAuthorisationDeniedEventData } from '@cool-ai/beach-core';

router.register('audit-denied', async (event) => {
  const data = event.data as RouteAuthorisationDeniedEventData;
  console.warn(
    `Denied: principal=${data.principalId ?? '<anonymous>'} capability=${data.capability} ` +
    `reason=${data.reason}${data.error ? ` (${data.error})` : ''}`,
  );
});

router.loadRoutingConfig({
  rules: [
    /* ... your domain rules ... */
    {
      source: ROUTE_AUTHORISATION_DENIED_SOURCE,
      eventType: ROUTE_AUTHORISATION_DENIED_TYPE,
      to: [{ handler: 'audit-denied' }],
    },
  ],
});

The denied event carries principalId only — full Principal context (with claims, including the principal's tokens) is reachable via parentEventId lookup into the event log. Surfacing only the correlation key keeps tokens out of subscribing audit pipelines.

Beach does not canonicalise a claim or capability vocabulary. claims, capability, and scope are opaque strings whose meaning is defined by the consumer and their Authoriser implementation. Per the no-domain-logic rule: ship the mechanism, not the vocabulary.

Session lifecycle

register, openSession, and getSession wire handlers into the router's session model. Every participant — deterministic or LLM-backed — is an EventHandler; an LLM-backed handler is built with createLLMHandler() from @cool-ai/beach-llm and registered the same way.

register(id, handler)

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

// A deterministic handler. An LLM-backed handler has the same shape — its body
// runs the respond() loop instead. The router cannot tell the two apart.
const myHandler: EventHandler = async (event, context) => {
  // context.session is a SessionView (no destinations); reply by routing.
  await context.routeEvent({
    source: 'session',
    eventType: 'reply',
    data: { sessionId, eventId, parts: [{ partType: 'response', text: 'Done.' }], conversationState: 'complete' },
  });
};

router.register('my-handler', myHandler);

Inbound is a session:request event { sessionId, eventId, parts }. The handler reads context.session, does its work, and replies by routing a session:reply event — it never names a channel. The event-router, the sole reader of session.destinations, delivers the reply to each destination.

A handler is non-blocking by default: routeEvent returns its ack — { eventId } — the moment the handler is dispatched, without waiting for it. The handler runs on, and its results reach destinations later as session:reply events. That is the streaming path — the caller gets a correlation handle at once and the reply (or replies) arrive over time. Declare a handler blocking to make routeEvent await it instead and return only one complete response, no ack-then-stream:

router.register('settle-payment', settleHandler, { blocking: true });

Use blocking for an edge whose caller needs the result in hand before it proceeds; leave it non-blocking for anything that streams or fans work out as further events. The router treats deterministic and LLM-backed handlers identically — { blocking } is the only knob. router.drain() awaits every in-flight non-blocking handler: call it on graceful shutdown, or in a test that must let the background work settle before asserting. A non-blocking handler stays cancellable through cancelEvent(eventId) while it runs; its teardown is deferred until it settles.

openSession(config) / getSession(id)

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

const session = router.openSession({
  id: 'session-abc',           // optional — auto-generated if omitted
  destinations: [
    { kind: 'ui-streaming' },
    { kind: 'cache' },
  ],
  staticSlots: { userEmail: '[email protected]' },  // optional
});

// Later:
const existing = router.getSession('session-abc');  // Session | null

destinations is set once at session-open time by the inbound adapter — the interior never writes it. A session may carry several; the router delivers each reply to every destination. The interior is channel-blind: it reads context.session (a SessionView with no destinations) and replies by routing, never by naming a channel.

Other registries

import { ConversationStateRegistry, TransportRegistry, AuthTypeRegistry } from '@cool-ai/beach-core';

Same register / isRegistered / getAll pattern. Consumers register additional values at startup. Unknown values are rejected at the first use (parse errors, schema validation).

Not in this package

  • LLM invocation (@cool-ai/beach-llm).
  • Channel adaptors (@cool-ai/beach-channel-*) — though the channel-router that drives them lives here.

Related