Get started in under a minute

Install, tell a story, see the output. Three steps.

Step 1: Install

terminal
$ npm install @lovelaces-io/storyteller

Step 2: Tell a story

app.ts
import { Storyteller } from "@lovelaces-io/storyteller";

const story = new Storyteller({
  origin: { who: "my-service" },
});

story.note("User clicked checkout");
story.note("Cart validated", { what: { items: 3 } });
story.tell("Checkout started");

Step 3: See the output

The console audience (included by default) prints a color-coded report. The underlying event is a clean JSON record you can store anywhere.

output (JSON record)
{
  "timestamp": "2026-03-31T14:30:00.000Z",
  "level": "Information",
  "title": "Checkout started",
  "origin": { "who": "my-service" },
  "durationMs": 0,
  "notes": [
    { "timestamp": "...", "note": "User clicked checkout" },
    { "timestamp": "...", "note": "Cart validated", "what": { "items": 3 } }
  ]
}

That's it. One record. The whole story.

The two modes

Storyteller has two distinct outputs. Keep them separate in your head — it makes everything clearer.

Story (JSON record)

Clean, serializable data. This is what you store — in a database, a log file, a monitoring system. JSON.stringify(event) gives you the record.

Report (formatted text)

Colorized, human-readable output. This is what you read — in a console, a terminal, a debug view. formatStory(event) gives you the report.

Notes & context

Every note can carry four optional context fields. Use whichever ones make sense.

FieldTypeDescription
whostring | objectWho did it — user ID, service name, role
whatstring | objectWhat was involved — action, field, amount
wherestring | objectWhere it happened — component, service, route
errorError | unknownAny error — automatically normalized to serializable form
context example
story.note("Card charged", {
  who: { id: "user:413", role: "member" },
  what: { amount: "$49.99", method: "visa" },
  where: "stripe-api",
});

story.note("Write failed", {
  where: "primary-db",
  error: new Error("connection timeout"),
});

Notes are collected in chronological order and emitted together when you tell the story.

Story levels

Three levels. Pick the one that fits.

tell

Everything went well. Informational. The happy path.

story.tell("Checkout completed")

warn

Something was off but it's handled. Worth knowing about.

story.warn("Payment retried")

oops

Something broke. Pass the error for automatic normalization.

story.oops("Failed", error)

Audiences: who hears your stories

Stories are delivered to audiences. Think of them as listeners — each one decides what to do with the story.

Console (default)

Prints a color-coded, grouped report. Included automatically on every Storyteller instance.

Database

database audience
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);
  })
);

Custom audience

custom audience
story.audience.add({
  name: "slack",
  accepts: (event) => event.level === "oops",
  hear: async (event) => {
    await sendSlackAlert(event.title, event.error);
  },
});

Targeting specific audiences

targeting
// Send this story only to the database, not the console
story.warn("Slow query detected").to("db");

// Send to multiple specific audiences
story.oops("Critical failure", error).to("db", "slack");

Managing audiences

audience management
story.audience.has("console");    // true
story.audience.names();           // ["console", "db"]
story.audience.remove("console"); // quiet mode

Shared instance

Use useStoryteller() when you want the same instance across your app — services, middleware, components all contributing notes to the same story.

singleton
import { useStoryteller } from "@lovelaces-io/storyteller";

// First call creates the instance
const story = useStoryteller({
  origin: { who: "api-server" },
});

// Same instance everywhere
const sameStory = useStoryteller();
console.log(story === sameStory); // true

API reference

Storyteller

MethodReturnsDescription
note(text, context?)thisAdd a timestamped note with optional who/what/where/error
tell(title){ to }Tell a success story
warn(title){ to }Tell a cautionary story
oops(title, error?){ to }Tell an error story
reset()thisClear accumulated notes without telling a story
summarize(options?)FormattedReportPreview current notes as a formatted report

Audience registry

MethodReturnsDescription
audience.add(member)thisRegister an audience (replaces existing with same name)
audience.remove(name)thisUnregister an audience by name
audience.has(name)booleanCheck if an audience is registered
audience.names()string[]List all registered audience names

Formatting

FunctionReturnsDescription
formatStory(event, options?)FormattedReportFormat a story event as human-readable text
writeStoryReport(stories[], options?)stringFormat multiple stories grouped by day
formatDuration(ms)stringConvert milliseconds to human duration (e.g. "3.4s")

Report options

OptionTypeDefaultDescription
detail"brief" | "normal" | "full""normal"How much detail to include
colorsbooleantrueInclude ANSI color codes
noteLimitnumber50Maximum notes to include
showDatabooleantrueInclude JSON data block
timezonestringsystemIANA timezone for formatting
localestring"en-US"Locale for date formatting

Output reference

This is exactly what a story record looks like. Every field serves a purpose.

JSON.stringify(event)
{
  "timestamp": "2026-03-31T14:30:03.420Z",    // when the story was told
  "level": "Warning",                         // Information | Warning | Error
  "title": "Payment retry succeeded",         // the story's headline
  "origin": {                                  // where this story comes from
    "who": "payment-service",
    "where": { "app": "web", "page": "checkout" }
  },
  "durationMs": 3420,                         // first note to last note
  "notes": [                                   // chronologically sorted
    {
      "timestamp": "2026-03-31T14:30:00.000Z",
      "note": "Card declined by processor",
      "error": { "message": "insufficient funds" }
    },
    {
      "timestamp": "2026-03-31T14:30:02.000Z",
      "note": "Retrying with backup processor"
    },
    {
      "timestamp": "2026-03-31T14:30:03.420Z",
      "note": "Payment approved",
      "what": { "amount": "$42.00" }
    }
  ],
  "error": {                                   // top-level error (from oops)
    "name": "CardDeclinedError",
    "message": "Insufficient funds"
  }
}

Real-world examples

API request lifecycle

middleware.ts
const story = new Storyteller({
  origin: { who: "api", where: { service: "users" } },
});

story.note("Request received", { what: { method: "POST", path: "/users" } });

try {
  const user = await createUser(body);
  story.note("User created", { what: { id: user.id } });
  story.tell("User registration complete");
} catch (error) {
  story.oops("User registration failed", error);
}

Background job tracking

sync-job.ts
const story = new Storyteller({ origin: { who: "sync-worker" } });

story.note("Starting daily sync");
const records = await fetchRecords();
story.note(`Fetched ${records.length} records`);

let failures = 0;
for (const record of records) {
  try {
    await processRecord(record);
  } catch {
    failures++;
  }
}

story.note(`Processed with ${failures} failures`);

if (failures > 0) {
  story.warn("Sync completed with errors");
} else {
  story.tell("Sync completed");
}

For AI agents

Storyteller is designed to be used by AI coding agents as well as human developers. Structured JSON output, clear types, zero config.

  • llms.txt — quick context file for AI agents
  • AGENTS.md — full usage guide, patterns, and anti-patterns
  • GitHub — source code, issues, and discussions