Skip to content

Events & event log ​

Every meaningful runtime moment in an OpenExpertise run fires an event. Events are:

  • Emitted via EventBus.emit() from the scheduler, dispatchers, and StateStore.
  • Subscribed by any number of listeners — pino for stdout logging, JsonlEventSink for the durable log, the TUI Dashboard for live render.
  • Stored as one JSON object per line in .openexpertise/runs/<run-id>.jsonl.

The event types ​

ts
type RunEvent =
  | { type: 'run.started'; run_id; ts; args? }
  | { type: 'run.finished'; run_id; ts; status: 'success' | 'failed' | 'partial' }
  | { type: 'node.ready'; run_id; node_id; ts; phase? }
  | { type: 'node.started'; run_id; node_id; ts; phase? }
  | { type: 'node.finished'; run_id; node_id; ts; phase?; metrics? }
  | { type: 'node.failed'; run_id; node_id; ts; phase?; error: string }
  | { type: 'node.skipped'; run_id; node_id; ts; phase?; reason: string }
  | { type: 'state.write'; run_id; node_id; field; ts }
  | { type: 'node.tokens'; run_id; node_id; ts; input_tokens; output_tokens; model? }
  | { type: 'node.activity'; run_id; node_id; ts; activity: string }

Every event has type and ts. Every node-scoped event has node_id. The schema is exhaustive — adding a new event type is a deliberate change to packages/core/src/events/bus.ts.

Where each event comes from ​

EventEmitter
run.startedrunExperience() before the scheduler runs
run.finishedrunExperience() after the scheduler returns
node.readyScheduler when all predecessors are done
node.startedScheduler before invoking a dispatcher
node.finishedScheduler after a successful dispatcher run; includes metrics if returned
node.failedScheduler when a dispatcher throws (and on_error doesn't recover)
node.skippedScheduler when a predecessor failed/skipped, or when: evaluated false
state.writeStateStore on every successful write to a field
node.activityLLM-touching dispatchers (agent, skill, cli-agent) at activity transitions
node.tokensAgent + skill dispatchers after each LLM call, using result.usage

Reading the log back ​

The JSONL file is one event per line. Order is write order, which with the parallel scheduler can be non-chronological. oe inspect sorts by ts before rendering:

bash
oe inspect r-abc123 --experience examples/review-branch

Output (sorted by ts ascending):

INFO: run.started     run_id=r-abc123 args={pr_id:"PR-1"}
INFO: node.ready      node_id=fetch_diff
INFO: node.started    node_id=fetch_diff
INFO: node.activity   node_id=fetch_diff activity="reading fixture"
INFO: state.write     node_id=fetch_diff field=diff
INFO: node.finished   node_id=fetch_diff
INFO: node.ready      node_id=seed_dimensions
INFO: node.started    node_id=seed_dimensions
INFO: state.write     node_id=seed_dimensions field=dimensions
INFO: node.finished   node_id=seed_dimensions
INFO: node.activity   node_id=bug_review activity="calling claude-sonnet-4-6"
INFO: node.tokens     node_id=bug_review input_tokens=1240 output_tokens=187
INFO: node.activity   node_id=bug_review activity="validating structured output"
INFO: state.write     node_id=bug_review field=findings
INFO: node.finished   node_id=bug_review
... etc ...
INFO: run.finished    status=success

Pipe to jq or grep for analysis:

bash
cat .openexpertise/runs/r-abc123.jsonl | jq -c 'select(.type=="state.write")'
# → every state-mutation event

For a shareable view of the same trace, add --html to get a self-contained run report — the DAG colored by each node's status (success / failed / skipped), an events timeline, and per-node duration & tokens:

bash
oe inspect r-abc123 --experience examples/review-branch --html -o run-report.html

It's one file with no external assets, so you can attach it to a PR or hand it to a teammate.

Why JSONL? ​

  • One line = one event. Append-only writes are atomic at the OS level.
  • Trivially grep-able, jq-able, awk-able.
  • The event log survives crashes — JsonlEventSink uses synchronous appendFileSync for crash-safety.
  • Replayable — you can deserialize and re-render any past run.

Subscribing programmatically ​

ts
import { EventBus, runExperience } from '@openexpertise/core'

const events = new EventBus()

events.subscribe((event) => {
  if (event.type === 'node.tokens') {
    metrics.incrementCounter('tokens_in', event.input_tokens)
    metrics.incrementCounter('tokens_out', event.output_tokens)
  }
  if (event.type === 'node.failed') {
    pagerDuty.alert({ runId: event.run_id, nodeId: event.node_id, error: event.error })
  }
})

await runExperience({ ..., events })

The bus is in-process pub-sub. Any number of subscribers can attach; errors in one don't abort the run.

Custom event subscribers ​

A subscriber is just a function. Some useful ones:

  • Prometheus metrics — bump counters on node.tokens and gauges on node.finished.metrics.cost_usd.
  • PagerDuty — alert on node.failed.
  • Audit log — write state.write events to an immutable external store.
  • Cost tracker — sum node.tokens across runs to compute provider spend.

None of these are built into OpenExpertise V1 — they're three to ten lines of integration each.

What's NOT an event ​

Things the runtime does not emit:

  • Per-byte LLM token output. No streaming. If you want token-by-token rendering, you need a streaming LLM client (not in V1).
  • Tool internals. When a tool runs fetch() or queries a database, no event fires. Tool implementations are your code; instrument them yourself if you need it.
  • Scheduler internal state. "How many nodes are in the ready queue right now?" — not exposed.

→ Continue with Scheduler.

Released under the MIT License.