Audiences
Stories are delivered to audiences. Each one is a listener that decides what it wants to hear and what to do with it. Console is included; the rest are a few lines each.
Built in#
Console
Registered on every storyteller by default. In live narration it prints one compact line per beat; when a story finishes it prints a grouped, color-coded block. Remove it with story.audience.remove("console") if you'd rather be quiet.
Database
Hears finished stories only, and only at warn and oops. That's deliberate — persisting every info story is noise, and persisting beats would double-write content the record already holds.
import { dbAudience } from "@lovelaces-io/storyteller"; // Only stores warn and oops — filters out tell to reduce noise story.audience.add( dbAudience(async (event) => { await db.insert("story_logs", event); }) );
NDJSON
One JSON object per line, every beat and every story, nothing else on the channel. This is the format to hand a program: a log shipper, jq, or an agent reading a subprocess.
import { ndjsonAudience } from "@lovelaces-io/storyteller"; story.audience.remove("console"); story.audience.add(ndjsonAudience({ stream: process.stderr })); // or change nothing and run with STORYTELLER_FORMAT=ndjson
Write your own#
An audience is an object with a name and a hear function. That's the whole contract.
story.audience.add({ name: "slack", accepts: (event) => event.level === "Error", hear: async (event) => { await sendSlackAlert(event.title, event.error); }, });
Only the failures, to Discord
The one people actually want. Create a webhook in the channel's settings, put its URL in DISCORD_WEBHOOK_URL, and:
story.audience.add({ name: "discord", accepts: (event) => event.level === "Error", hear: async (event) => { await fetch(process.env.DISCORD_WEBHOOK_URL!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ content: "``` " + event.summarize({ colors: false, detail: "brief" }).text + " ```", }), }); }, });
accepts keeps everything below Error out of the channel. summarize() turns the record into a short readable block, which Discord renders as code. Swap the URL and the body shape for Slack, Teams, or anything with a webhook.
Who hears what#
Three levers, from broad to narrow:
hears— which kinds an audience wants:["note"],["story"], or both. Defaults to stories only, so an audience written before live narration existed keeps working unchanged.accepts— a rule about which emissions: by level, by origin, by anything on the record..to()— name the audiences for this one story or beat.
// Send this story only to the database, not the console story.finish("Slow query detected", { level: "warn" }).to("db"); // Send to multiple specific audiences story.finish("Critical failure", { level: "oops", error }).to("db", "slack");
Per beat, it's an option: story.report("Urgent", { to: ["discord"] }).
.to() on finish() must be called synchronously — delivery happens on the next microtask. Don't store the handle and call it after an await.
Managing
story.audience.has("console"); // true story.audience.names(); // ["console", "db"] story.audience.remove("console"); // quiet mode
When an audience fails#
A logging library that loses records in silence is worse than one that complains. When an audience throws or rejects, the failure goes to onAudienceError — never into your code. Without a handler, you get one throttled console warning per audience.
When an audience is too slow, live narration would otherwise pile up promises without limit. Deliveries in flight are capped per audience; past the cap, beats are dropped and counted on the closing story as droppedEmissions. The record itself still holds every beat — back-pressure costs you the stream, never the story.
const story = new Storyteller({ // Called instead of swallowing the failure. Without it: one throttled console warning. onAudienceError: (error, member, emission) => metrics.increment("storyteller.audience_failed", { audience: member.name }), // Deliveries in flight per audience. Past this, beats are dropped and counted. maxInFlight: 500, }); // A dropped beat never vanishes silently — the closing story says so // { ..., "droppedEmissions": 12 }