@lovelaces-io/storyteller
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.
{ "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" } ] }
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.
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.
No pre-flattening. No defensive stringifying. report() takes any value and stores clean JSON.
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.
Three steps. That's it.
Call report() as things happen. Pass anything — a message, an error, an API response.
Call finish() when the operation ends. Everything reported becomes one record.
Audiences hear the story — console prints it, your database stores it, your own listener does whatever you need in fifteen lines.
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
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
// 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 + " ```", }), }); }, });
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
STORYTELLER_NARRATION=live and every beat prints as it happens — one compact line each.for the agents
npx @lovelaces-io/storyteller init installs, wires, and teaches every agent in the repo.AGENTS.md, llms.txt, and the snippet ship inside the package.STORYTELLER_FORMAT=ndjson — one JSON object per line, nothing else on the channel.chapter() links each subtask's record to its parent.One command. Installs the library, writes a configured storyteller, and teaches your agents to use it.