@lovelaces-io/storyteller

Your logs should tell a story

Report work as it happens. Get one clean record when it's done. For the humans and the agents doing the work — with zero dependencies.

sync.js console.log ×8 one story
{
  "level": "Warning",
  "title": "Payment retry succeeded",
  "durationMs": 4000,
  "notes": [
    { "timestamp": "14:30:00", "note": "User clicked checkout" },
    { "timestamp": "14:30:01", "note": "Cart validated" },
    { "timestamp": "14:30:03", "note": "Card declined",
      "error": { "message": "gateway timeout" } },
    { "timestamp": "14:30:04", "note": "Retry succeeded" }
  ]
}
$ npx @lovelaces-io/storyteller init
Get Started GitHub
0 dependencies TypeScript-first ESM + CJS MIT

Or watch it happen

Collecting the whole story and emitting it at the end is right for an audit record. It's wrong when the work takes forty seconds and someone — a person, or an agent — wants to know what's going on right now.

$ 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

Every beat carries a storyId and a gap-free sequence, so whoever holds the stream can rebuild exactly the record the collected mode would have produced. Nothing is lost either way.

Hand it anything

No pre-flattening. No defensive stringifying. report() takes any value and stores clean JSON.

anything.ts
story.report(await response.json());        // any API payload
story.report(caughtError);                 // cause chain preserved
story.report(new Map([["region", "us-east"]]));
story.report(circularObject);              // marked, never thrown
story.report({ deployToken: "dt-9f2c-abc" });      // → "[redacted]"

Errors keep their cause chain. Circular references become [Circular → path]. Oversized values get an explicit truncation marker instead of vanishing. Secret-looking keys are redacted. The normalizer never throws — a hostile object can't break your logging.

How it works

Three steps. That's it.

1

Report the work

Call report() as things happen. Pass anything — a message, an error, an API response.

2

Finish the story

Call finish() when the operation ends. Everything reported becomes one record.

3

Anyone can listen

Audiences hear the story — console prints it, your database stores it, your own listener does whatever you need in fifteen lines.

Two files, the whole idea

One narrates a payment. The other decides which stories reach a Discord channel — fifteen lines, no dependency, accepts does the filtering.

report as it happens, finish once

checkout.ts
import { Storyteller, dbAudience } from "@lovelaces-io/storyteller";

const story = new Storyteller({
  origin: { who: "checkout-service", where: { app: "web" } },
});

// Store warnings and errors in your database
story.audience.add(
  dbAudience(async (event) => await db.insert("logs", event))
);

// Report each beat as it happens
story.report("User submitted payment", {
  who: { id: "user:413" },
  what: { amount: 49.99, currency: "USD" },
});

story.report("Charging card", { where: "stripe" });
story.report(await gateway.charge(payment));

// Finish — one record, delivered to every audience
story.finish("Payment completed");

only the failures, to a channel

discord.ts
// Only the failures, straight to your Discord
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 + "
```",
      }),
    });
  },
});

One log, whoever did the work

An agent's automated run and a developer's manual debugging session produce the same record, in the same shape. Ask what happened and get both.

for the humans

  • Watch it live. STORYTELLER_NARRATION=live and every beat prints as it happens — one compact line each.
  • Read one record, not forty lines. Who, what, in what order, how long, how it ended.
  • Send it where you look. Console, your database, a Discord channel. An audience is fifteen lines.
  • Hand it anything. An error, an API response, a Map. It becomes clean JSON and never throws.

for the agents

  • One command to adopt. npx @lovelaces-io/storyteller init installs, wires, and teaches every agent in the repo.
  • Guidance where agents look. AGENTS.md, llms.txt, and the snippet ship inside the package.
  • Output a program can parse. STORYTELLER_FORMAT=ndjson — one JSON object per line, nothing else on the channel.
  • Nested work stays a tree. chapter() links each subtask's record to its parent.
0 dependencies TypeScript-first ESM + CJS MIT ~11 KB gzipped

Ready to tell better stories?

One command. Installs the library, writes a configured storyteller, and teaches your agents to use it.

$ npx @lovelaces-io/storyteller init
Read the Docs View on GitHub