▶ runtime-narrative

Core Concepts

Four pieces make up the entire mental model: a story is a named execution scope, stages are its named steps, both emit plain events, and renderers turn those events into output. Everything else in the library is a variation on this pipeline.

The pipeline: Story → Stages → Events → Renderers

Here's the shape of a single story with three stages, the last of which fails:

text
┌───────────────────────────────────────────────────────────┐
│  story("Import Customers")                                │
│    ├── stage("Load CSV")    ──► StageStarted             │
│    │                        ──► StageCompleted           │
│    ├── stage("Validate")    ──► StageStarted             │
│    │                        ──► StageCompleted           │
│    └── stage("Insert")      ──► StageStarted             │
│                             ──► FailureOccurred          │
│                             ──► StoryCompleted           │
│                                      │                   │
│                               renderers.handle(event)    │
│                               [ConsoleRenderer,          │
│                                JsonRenderer, ...]        │
└───────────────────────────────────────────────────────────┘

Nothing in this pipeline is hidden or magic: a story tracks state and dispatches events, stages register themselves against the active story, events are inert data, and renderers are plain objects that get called once per event. Understanding this shape is enough to predict how every feature in the library behaves — diagnostics, background analysis, custom renderers, and framework middleware are all built on exactly these four pieces.

What a story is

A story is a named execution scope. It owns a StoryRuntime that tracks all stages registered within it, maintains a progress counter, and dispatches events to attached renderers. A story emits StoryStarted on entry and StoryCompleted on exit, regardless of success or failure. When an exception escapes a story without being caught inside it, the story emits FailureOccurred with rich diagnostics before emitting StoryCompleted.

The reason this is a context manager rather than, say, a decorator-only API or a global logger call: a story needs a clear boundary where "this unit of work is done" is unambiguous, so it can compute duration, final status, and progress percentage deterministically — and so nested stages know which story to register against without being passed a reference explicitly.

What stages are

Stages are the named steps inside a story. Each stage emits StageStarted on entry and, if it completes without error, StageCompleted with its duration. If a stage raises an exception, it marks the stage as failed and lets the exception propagate to the story boundary, where it is caught and turned into FailureOccurred. Stages can nest: opening a stage() inside another stage() records parent_stage_name on the inner record.

Stages don't catch exceptions themselves — they let them propagate. This is deliberate: the story boundary is the single place where "what actually failed, and where" gets decided, so diagnostics logic only has to live in one place instead of being duplicated at every stage.

The seven events

Events are plain dataclasses with no behavior. The seven events are StoryStarted, StageStarted, StageCompleted, FailureOccurred, StoryCompleted, LLMAnalysisReady, and LogRecorded. All seven are part of the stable public API and importable directly from runtime_narrative — see the Event Reference for every field on every event. Any renderer that understands these types can do arbitrary things with them.

Keeping events as inert data — no methods, no side effects — is what makes it safe to attach more than one renderer to the same story: a ConsoleRenderer for a human at a terminal, a JsonRenderer for a log aggregator, and an OtelRenderer for traces can all receive the exact same event without any of them mutating state the others depend on.

What renderers are

Renderers receive every event via their handle method. There is no base class and no registration system — any object with handle(self, event: object) -> None qualifies. Async renderers declare async def handle(self, event: object) and are awaited only when the story itself is running asynchronously (via async with story(...)).

This is the extension point for everything downstream: SQLite persistence, Prometheus metrics, alert routing, and OpenTelemetry spans are all just renderers that happen to do something other than print to a terminal. See Renderers for the built-in set.

Context propagation via ContextVar

The library uses two ContextVar values defined in runtime_narrative/context.py:

  • current_story — holds the active StoryRuntime (or None)
  • current_stage_stack — holds a list of currently open StageRecord objects for nesting

Because ContextVar propagates into async tasks via contextvars.copy_context(), you never thread the story runtime through function arguments. Any function called inside a with story(...) or async with story(...) block can open a stage by calling stage("name") — the active story is found automatically. This works correctly in asyncio tasks, threads spawned by asyncio.to_thread, and chained coroutines.

This is also why stage() raises a RuntimeError when called outside an active story(): there's no implicit story to attach to, and an implicit fallback would silently produce orphaned stages that never get rendered.

The renderer protocol

A renderer is any object with a handle method — no import, no subclassing:

python
class MyRenderer:
    def handle(self, event: object) -> None:
        if type(event).__name__ == "FailureOccurred":
            print(f"FAILURE: {event.error_type}: {event.error_message}")

Async variant:

python
class MyAsyncRenderer:
    async def handle(self, event: object) -> None:
        await some_io_operation(event)

Async renderers are only awaited during async stories

Async renderers are only awaited when emit_async is called — which happens during async with story(...) entry/exit. Stage events inside a synchronous with story(...) block use emit() (sync), so async renderer handle coroutines are not awaited for those events. If you need async handling of stage events, use async with story(...).

The analyzer protocol

Failure analyzers follow the same no-base-class philosophy as renderers. An analyzer needs one method:

python
class MyAnalyzer:
    def analyze_failure(
        self,
        *,
        story_name: str,
        stage_name: str,
        failure,          # FailureSummary
        stage_timeline: str,
        progress_percent: int,
    ) -> str | None:
        ...

    # Optional async variant — if absent, analyze_failure is called via asyncio.to_thread
    async def analyze_failure_async(self, *, ...) -> str | None:
        ...

The analyzer receives a FailureSummary (error type, message, filename, lineno, traceback text, exception chain) plus context about where in the story the failure occurred. Returning None is equivalent to "no analysis available." See Custom Failure Analyzers for a worked example.

Next step

With the pipeline in mind, head to story() for the full constructor signature, parameter reference, and StoryRuntime API.