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.

db.ts
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.

ndjson.ts
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.

custom.ts
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:

discord.ts
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:

targeting.ts
// 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

registry.ts
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.

robust.ts
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 }