> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prodexlabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Event System

> Topics, state variables, event hooks, and event listeners, the primitives for cross-component coordination and dynamic behavior.

## Overview

The Event System is what elevates ProDex from a process-flow simulator to a system that can model sophisticated operational logic. It's built on **four** primitives:

* **Topics**: pub/sub messaging between components
* **State Variables**: mutable runtime state that lives on the model
* **Event Hooks**: actions that fire on a component's own lifecycle events
* **Event Listeners**: actions that fire when a subscribed topic is emitted

Together they enable patterns that aren't expressible with connection-based flow alone: pull-based production (kanban), backpressure, shift-driven capacity changes, cross-component coordination. A user who only knows about sources, processes, and sinks is using a fraction of what the platform can model. If you're still getting oriented on components and flow, read the [Simulation overview](/product/simulation-overview) first — this page covers the coordination layer on top.

Every flow component in the Modeler exposes both an **Event Hooks** and an **Event Listeners** section in its configuration panel: they're distinct first-class objects, not variants of the same thing. Topics and State Variables are managed in the **Lookups** modal, opened from the **Lookups** button at the bottom of the Modeler's Library panel. The modal has four tabs: *Constants*, *Lookup Tables*, *State Variables*, and *Topics*.

## Topics

A **Topic** is a named channel components can publish to and subscribe to. When a component publishes on a topic, every component that has an Event Listener subscribed to that topic reacts.

Topics enable coordination that isn't expressible through flow connections alone: one component can trigger behavior in another without being directly wired to it.

Create and rename Topics in the **Topics** tab of the Lookups modal. Once a Topic exists, components publish to it via an `emit` action and subscribe to it by adding an Event Listener whose `topic` field references it.

<Warning>
  **Topics carry no payload.** An `emit` action takes only a topic name; an Event Listener fires on the topic name alone with no message data. If a downstream listener needs information about *why* the topic was emitted, the canonical pattern is to set a state variable just before the emit and have the listener read that state variable.
</Warning>

**Example, kanban signal:** when a downstream buffer drops below a threshold, an event hook emits the topic `replenish`. An upstream Source has an Event Listener subscribed to `replenish` that runs a `release` action, generating more material.

## State Variables

A **State Variable** is a named, mutable value that lives on the model (not on any individual entity). State variables persist across entities: they retain their value as entities flow through the model, and they can be read from any expression.

Use state variables for anything that represents the state of the *system* rather than the state of a single entity: current shift, machine uptime counters, inventory levels, demand signals, rolling averages.

Define State Variables in the **State Variables** tab of the Lookups modal. Each has a name (**SCREAMING\_SNAKE\_CASE is enforced** — `CURRENT_SHIFT`, not `currentShift`), a `type` (one of `number`, `text`, `boolean`), an optional description, and a **required** `initial` value matching the declared type. Once defined, read them by name from any expression and write to them with an `assign` action in any Event Hook, Event Listener, or scheduled action.

<Info>
  In results data, each state variable's initial value lands as a `t=0` row in the `state_variable_activity` table — except variables aliased into a sub-model via [state variable mappings](/reference/model-nodes#aliased-state-variables), which delegate to the parent and emit no row of their own.
</Info>

### State Variables vs. Entity Attributes

* **Entity attribute**: belongs to a specific entity and moves with it through the flow. Defined on the entity type.
* **State Variable**: belongs to the model and persists independently of entities. Defined at the model level via the Lookups modal.

If you want the same value visible to every component at every moment, use a state variable. If you want the value to track a specific entity as it moves through the flow, use an attribute.

## Event Hooks

An **Event Hook** is a rule on a component that fires actions when one of *that component's own* lifecycle events occurs. Hooks live in the **Event Hooks** section of every flow component's config panel: pick the event from a dropdown, optionally add a condition, and configure one or more actions.

### Canonical Events per Component

Each event is only valid on specific component types and runs its expressions in a specific [context](/reference/expressions#expression-contexts). UI labels strip the `on_` prefix and use spaces: `entity created` in the UI corresponds to `on_entity_created` in the schema.

| Component   | Events                                                 | Context                                                                       |
| ----------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- |
| Source      | `entity created`                                       | single-entity (the newly created entity)                                      |
| Combiner    | `entity consumed`, `batch assembled`, `entity created` | single-entity / multi-entity (for `batch assembled`) / single-entity (output) |
| Separator   | `entity consumed`, `entity created`, `batch created`   | single-entity / single-entity (each output) / multi-entity (all outputs)      |
| Transformer | `entity consumed`, `entity created`                    | single-entity                                                                 |
| Buffer      | `entity entered`, `entity exited`, `contents changed`  | single-entity / single-entity / **multi-entity** (current contents)           |
| Station     | `entity entered`, `entity exited`, `contents changed`  | single-entity / single-entity / **multi-entity** (current contents)           |
| Process     | `process started`, `process completed`                 | single-entity                                                                 |
| Router      | `entity routed`                                        | single-entity                                                                 |
| Resource    | `slot freed`, `slot working`                           | no-entity                                                                     |
| Sink        | `entity terminated`                                    | single-entity                                                                 |

A few things to notice:

* **`entity entered` / `entity exited`** only fire on Buffer and Station. Processes don't emit them: use `process started` / `process completed`. Routers don't emit them: use `entity routed`.
* **The multi-entity events are where aggregations live.** `contents changed` on a Buffer or Station (`on_contents_changed` in the schema — the same event name on both), `batch assembled` on a Combiner, and `batch created` on a Separator all run in multi-entity context — conditions and assignments there use [aggregation functions](/reference/expressions#aggregation-functions) (`SUM`, `MEAN`, `COUNT`, `MAX`, `MIN`, `ANY`, `ALL`, `MODE`, `N_UNIQUE`) over the entities in scope — for example `MEAN(weight)` for the average across the batch. A buffer threshold check is `COUNT() > 10` on `contents changed`, not a per-entity condition.
* **`slot freed` / `slot working`** are on Resources, not Stations, and run in **no-entity** context. The naming is unusual: `slot working` fires when a slot transitions from FREE to allocated.
* **Per-entity values in multi-entity context** (`ENTITY_TYPE`, `ENTITY_AGE`) are only reachable *inside* an aggregation body — `MAX(ENTITY_AGE)` works, bare `ENTITY_AGE` doesn't. In single-entity context both are available directly (`ENTITY_AGE` is `SIM_TIME` minus the entity's creation time).
* **`entity_type` filter** is only valid on Combiner `on_entity_consumed` and Separator `on_entity_created`. Other event hooks fire for every entity regardless of type.

<Note>
  **Within one hook, actions run sequentially — across hooks, order is not specified.** The `actions[]` list of a single hook or listener executes in order, so an `assign` followed by an `emit` in the same rule is safe. But if two *separate* hooks subscribe to the same event, the order they run in isn't specified — don't rely on side effects from one becoming visible to the other.
</Note>

### Action Types

When a hook fires, it executes one or more actions. The Action dropdown offers (UI labels in code, schema names in parens):

* **`assign`**: write to a state variable. Example: `assign(CURRENT_SHIFT, "night")` or `assign(WIP_COUNT, WIP_COUNT + 1)`.
* **`emit`**: publish a message to a topic. Example: `emit("replenish")`.
* **`pause`**: pause a component (stops accepting and processing).
* **`resume`**: resume a paused component.
* **`release`** (`release_entity`): release entities on demand. Valid on Sources (releases new entities) and Buffers (releases queued entities). `quantity` accepts an integer or a DSL expression — evaluated in **no-entity** context on a Source, and in **multi-entity** context over the current contents on a Buffer (aggregations apply). The result is truncated to a non-negative integer; zero or less releases nothing. An optional `filter` (per-entity boolean DSL) selects *which* queued entities qualify — it's **Buffer-only** and rejected by the validator on a Source.
* **`set capacity`** (`set_capacity`): change the capacity of a Resource at runtime.

<Warning>
  **Capacity decreases are non-preemptive.** `set_capacity(0)` on a Resource does not interrupt a job that's already running — the in-flight unit finishes first, then the slot goes away. When you model breakdowns or downtime windows this way, expect the last job to spill past the boundary.
</Warning>

### Per-Component Action Restrictions

Not every action is valid on every component:

| Component                                                        | `assign` | `emit` | `release` | `pause` | `resume` | `set capacity` |
| ---------------------------------------------------------------- | -------- | ------ | --------- | ------- | -------- | -------------- |
| Source                                                           | ✓        | ✓      | ✓         | ✓       | ✓        |                |
| Buffer                                                           | ✓        | ✓      | ✓         | ✓       | ✓        |                |
| Resource                                                         | ✓        | ✓      |           |         |          | ✓              |
| Station, Process, Router, Sink, Combiner, Separator, Transformer | ✓        | ✓      |           |         |          |                |

In short: `set capacity` is valid only on Resource. `release / pause / resume` are valid only on Source and Buffer. `assign` and `emit` are valid everywhere.

### Conditions

Every event hook has an optional `condition` field, a boolean DSL expression evaluated each time the event fires. If false, the action list is skipped. The condition runs in the **same context as the actions** (single-entity for entity-bound hooks, no-entity for resource slot hooks, multi-entity for batch and contents-changed hooks).

Conditions live at the hook level, not the action level: a hook's actions either all run or all skip.

### SELF

Inside any hook or listener, **`SELF` resolves to the hosting component's id** at runtime. That makes rules copy-pasteable across components: `BUFFER_LEVEL(SELF)`, `STATION_WIP(SELF)`, `RESOURCE_AVAILABLE(SELF)` all follow the rule wherever it's attached. `SELF` is **not** available in [scheduled actions](/reference/schedules#scheduled-actions) (no component context there) or in top-level component fields.

## Event Listeners

An **Event Listener** is a rule on a component that fires actions when a subscribed **topic** is emitted, decoupled from any lifecycle event. Listeners live in the **Event Listeners** section of every flow component's config panel, a sibling to Event Hooks, with its own *Add Listener* button.

Each listener has:

* **`topic`** *(required)*: the name of a declared topic
* **`condition`** *(optional)*: boolean DSL expression (the code builder calls this parameter `when`; the stored schema field is `condition`)
* **`actions[]`**: same six action types as Event Hooks, with the same per-component restrictions

<Note>
  **Event Listeners always run in no-entity context, regardless of host component.** A listener on a Source can't reference entity attributes, even though a Source's `on_entity_created` hook can. Listeners are reactions to model-level signals, not to specific entities: there's no entity in scope unless an action explicitly pulls one in (e.g., `release`).
</Note>

### Hooks vs. Listeners

The two reaction primitives look similar but behave differently:

|                      | Event Hook                                   | Event Listener                                       |
| -------------------- | -------------------------------------------- | ---------------------------------------------------- |
| Triggered by         | Component's own lifecycle event              | Topic emission                                       |
| Context              | Varies by event (no-entity / single / multi) | Always no-entity                                     |
| Where it lives       | Component's `event_hooks[]` array            | Component's `event_listeners[]` array                |
| Subscription         | Implicit (component fires its own lifecycle) | Explicit (`topic` field references a declared topic) |
| When to reach for it | Reacting to *this component's* behavior      | Reacting to model-level coordination signals         |

### Subscriptions Are Static

Subscriptions are declared at model build time and never change at runtime. There's no `subscribe` or `unsubscribe` action. If you need different listeners active under different conditions, gate them with the listener's `condition` field.

## ModelNode and the Event System

[Model Nodes](/reference/model-nodes) can host **Event Listeners** but not lifecycle Event Hooks: the schema accepts an `event_hooks[]` array on a ModelNode, but no canonical lifecycle events are documented for ModelNodes. Listeners (topic-based) work fully and are the right tool for ModelNode-level coordination. Topics and state variables bridge across the ModelNode boundary via mappings — a bridged emission shows up as two rows in the emission dataset (origin + delivery).

## Avoiding Event Loops

Event-driven rules can feed themselves. Keep three guidelines in mind:

1. **Don't let a component's own lifecycle trigger its own `release`.** A Source whose `entity created` hook releases another entity, or a Buffer whose `entity exited` hook releases the next one unconditionally, is a self-amplifying loop — drive releases from *downstream* signals (a kanban topic) instead.
2. **Keep the topic graph a DAG in your head.** If a listener on topic A emits topic B, and a listener on B emits A, every emission cascades forever. Chain topics in one direction and gate re-entrant paths with a `condition` on a state variable.
3. **Flow-graph cycles are fine.** Routing entities back through an upstream Process (rework loops) is a perfectly valid model — the hazard is event loops, not connection loops.

## Scheduled Actions

A [Schedule](/reference/schedules) can also fire actions at specific simulation times. Schedules are a **separate mechanism** from Event Hooks: a schedule fires its own actions at declared times and does **not** fire any component lifecycle hook — the topic is the bridge between the two. Scheduled actions are more restricted than Event Hook actions:

* **Only `assign` and `emit`** are valid as scheduled actions.
* Component-bound actions (`pause`, `resume`, `set capacity`, `release`) are not available in schedules. Use a scheduled `emit` to a topic, then attach an Event Listener that listens for that topic and performs the component-bound action.

This restriction keeps schedules declarative: a schedule declares "at time T, the world is in state X," and state changes propagate through the event system.

## Authoring Events with Dexter

When [Dexter](/product/ai-assistant) wires up event logic, it uses the simulation builders — the same vocabulary this page describes:

```python theme={null}
sim.listener("replenish", actions=[sim.release_entity(quantity=5)], when="BUFFER_LEVEL(SELF) < 20")
sim.hook("on_batch_assembled", actions=[sim.assign("WIP_COUNT", "WIP_COUNT + COUNT()")])
```

Action builders mirror the six action types (`sim.assign`, `sim.emit`, `sim.pause`, `sim.resume`, `sim.release_entity`, `sim.set_capacity`); `sim.topic(name)` declares topics and `sim.state(name, type=..., initial=...)` declares state variables. Component builders also expose the common events directly as `on_*` keyword arguments (`on_entered`, `on_created`, `on_contents_changed`, ...) — the explicit `sim.hook(...)` form is for when you also need an `entity_type` filter or a `condition`.

## The Event System in Results Data

Three [results tables](/product/results-and-analytics#querying-simulation-data) capture what the event system actually did during a run:

* **`event_lookup`** — a *dimension* table with one row per action of every hook and listener, written at setup (nothing appends during the run). Columns include `component_id`, `config_type` (`"hook"` / `"listener"`), `event_or_topic`, `entity_type_id`, `condition`, `action_index`, `action_type`, and the typed columns `variable_name` / `value_expr` / `topic` / `capacity_expr` (null for `pause`/`resume`/`release_entity` actions).
* **`topic_activity`** — one row per executed `emit`. `trigger_source` distinguishes `schedule` / `hook` / `listener` / `bridge`; `trigger_component_id` is null on schedule and bridge rows; `bridged_from` carries the source-side topic (with its hierarchical prefix) on bridge rows. Schedule-fired emits carry whole-second timestamps; hook and listener emits keep sub-second precision.
* **`state_variable_activity`** — one row per `assign`, with `variable_name`, `value_type`, and typed `value_number` / `value_text` / `value_boolean` columns. `trigger_component_id` is null on scheduled assigns and on the `t=0` initial-value rows.

If a shift change or kanban signal "didn't seem to fire", these tables are where you check.

## Common Patterns

**Kanban / Pull-Based Production.** Downstream components signal upstream sources when they need more material. Combine a state variable (current WIP) with an Event Hook that emits to a topic when WIP drops below a threshold, plus an Event Listener on the Source that listens for the topic and runs a `release` action.

**Backpressure.** When a downstream buffer is full, pause or slow upstream arrivals. An Event Hook watches buffer level changes and updates a state variable that the Source's arrival-rate expression reads.

**Shift-Driven Capacity Changes.** Resources change capacity by time of day. A scheduled `emit` on a `shift_start_night` topic fires at the shift boundary; an Event Listener on the Resource listens for that topic and runs `set capacity` with the night value.

**Cross-Component Coordination.** Two distant components that aren't connected by flow can still coordinate through a shared topic. One publishes, the other reacts via an Event Listener.

## When to Reach For the Event System

Most simple models don't need events: a Source, a few Processes, a Sink, and you're running. Reach for events when:

* You need behavior that spans multiple components without direct flow
* You need the model to react to operational rules (shift schedules, priority changes, demand surges)
* You're modeling lean or pull systems where downstream pulls from upstream
* You need to track and react to cross-cutting metrics (rolling WIP, utilization)

If you find yourself duplicating logic in many expressions to coordinate behavior, that's a signal to introduce a State Variable or a Topic.
