Collected & live

Two ways to hear the same story. Collected gives you one record when the work ends. Live gives you every beat the moment it happens — and still gives you the record.

Collected — the default#

Beats are kept until you call finish(), then emitted together as one record. This is the right shape for an audit log: one row per operation, complete and self-contained.

It's the wrong shape when the work takes forty seconds and someone — a person or an agent — wants to know what's going on right now.

Live#

Set narration to live and each report() is emitted immediately. The console prints one compact line per beat:

$ STORYTELLER_NARRATION=live node sync.js
live
14:30:00  info  checkout / web  User submitted payment  {amount=49.99}
14:30:01  info  checkout / web  Charging card  {stripe}
14:30:03  warn  checkout / web  Card declined  {gateway timeout}
14:30:03  info  checkout / web  Retrying
14:30:04  info  checkout / web  Charge succeeded

Live adds emissions. It never removes them — the story still lands at finish(). An audience that only wants beats says so with hears: ["note"], rather than by silencing the record.

Turning it on

narration.ts
// Per instance
const story = new Storyteller({ narration: "live" });

// At runtime — takes effect on the next report()
story.narrate("live");
story.narrate("collected");

// One urgent beat out of an otherwise collected story
story.report("Disk at 97%", { level: "warn", live: true });

The environment variable wins when nothing is set in code, so you can switch a deployed process to live narration without touching it.

Nothing is lost#

Every beat carries the storyId of the story it belongs to, and a sequence number assigned the instant you call report() — gap-free from zero.

That means whoever holds the streamed beats can order and group them back into exactly the record collected narration would have produced. Neither mode is the lesser one. This is the guarantee the whole design rests on, and it's enforced by tests.

Order by sequence, never by arrival time. Audiences are asynchronous, and a slow one lands late.

Configuration#

Every option can come from the environment, so the same file behaves differently for a person at a terminal and a program reading a pipe.

VariableValuesEffect
STORYTELLER_NARRATIONcollected · liveWhether beats stream as they happen
STORYTELLER_FORMATtext · ndjsonWhich default audience is registered
STORYTELLER_LEVELinfo · warn · oopsMinimum level delivered — filters the closing story too
STORYTELLER_COLOR0 · 1Force colors off or on

Unrecognized values fall back to the default rather than throwing. Output format is deliberately not inferred from whether stdout is a terminal — output that changes shape when a process is piped is a debugging afternoon nobody asked for.