Get started in under a minute
Install, tell a story, see the output. Three steps.
Step 1: Install
$ npm install @lovelaces-io/storyteller Step 2: Tell a story
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.
{ "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.
| Field | Type | Description |
|---|---|---|
who | string | object | Who did it — user ID, service name, role |
what | string | object | What was involved — action, field, amount |
where | string | object | Where it happened — component, service, route |
error | Error | unknown | Any error — automatically normalized to serializable form |
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
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
story.audience.add({ name: "slack", accepts: (event) => event.level === "oops", hear: async (event) => { await sendSlackAlert(event.title, event.error); }, });
Targeting specific audiences
// 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
story.audience.has("console"); // true story.audience.names(); // ["console", "db"] story.audience.remove("console"); // quiet mode
API reference
Storyteller
| Method | Returns | Description |
|---|---|---|
note(text, context?) | this | Add 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() | this | Clear accumulated notes without telling a story |
summarize(options?) | FormattedReport | Preview current notes as a formatted report |
Audience registry
Method Returns Description 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
Function Returns Description 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
Option Type Default Description detail"brief" | "normal" | "full""normal"How much detail to include colorsbooleantrueInclude ANSI color codes noteLimitnumber50Maximum notes to include showDatabooleantrueInclude JSON data block timezonestringsystem IANA 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.