Skip to content

Console demo

The console renders what the runtime already records: activations, span trees, errors grouped by the closed reason vocabulary, per-model token spend and cache-hit ratio, the HITL approval queue, and per-entity-key timelines. Every one of those views is empty until something produces the record it renders — which is why a demo that only emits happy-path completions leaves most of the screen looking broken.

This example produces the whole vocabulary in one command, offline: a scripted [FakeLLM] serves every model call, so there is no API key, no broker, and no network anywhere in it.

Run it

With nothing else started, it prints a summary of everything the round produced:

uv run python -m examples.console_demo

Against a console started with beam-agents-console:

uv run python -m examples.console_demo --console console://localhost:8787

Or keep producing, so the console shows traffic arriving rather than a static snapshot — this is what docker compose runs:

uv run python -m examples.console_demo --console console://localhost:8787 --loop

The twelve scenarios

Each one exists because some part of the UI is unreachable without it.

Scenario What the pipeline does What the console gets
completion one model call, then Complete a completed activation, an LLM_CALL with token counts, and a StateSnapshot from an in-band export_request
multi_tool a model call, then two inline ctx.run_tool calls one TOOL_CALL span per tool, carrying beam_agents.tool_name — the per-tool volume panel
cache_hit the same request twice in one activation beam_agents.cache_hit=false, billed=true then true/false, both with real token counts — the cache-hit ratio
suspension_approved suspend for approval, resume on an approval that arrives in time one trace with two attempts, kind=start then kind=resume
suspension_denied the same, resumed by a denied approval a completed resume with nothing on .errors: a denial is an answer, not a failure
suspension_timeout suspend, and never answer reason=hitl_timeout on .errors — the timed-out approval view
tool_error an inline tool raises reason=activation_error with error.type=ToolError
activation_error the agent raises after a model call reason=activation_error with error.type=RuntimeError and the beam_agents.failure.* position scalars
budget_exceeded call the model past max_tokens_per_activation reason=budget_exceeded, its own group: a cost question, not a stack trace
orphaned_result deliver a ToolResult for a key with no live continuation reason=orphaned_result, detail=no_continuation:<intent_id>
intent_dead_letter stage an intent the outbox refuses to route reason=intent_dead_letter, built by the runtime's own intent_dead_letter_to_error off RunAgent's dead-letter branch
batch_overflow under BatchPolicy.ADAPTIVE, suspend and then keep the burst coming reason=batch_buffer_overflow, plus ttl_wiped_batch and ttl_wiped_suspension when working-memory GC reaches the still-suspended key

Two of those are worth a second look:

  • tool_error and activation_error share a reason. An inline tool's TOOL_CALL span is staged only after the call returns, so a tool that raises leaves no tool span at all — the activation failure is the whole record, and error.type is what tells the two apart. The console groups by reason and breaks down by error.type for exactly this case.
  • batch_overflow needs a live suspension. The size trigger flushes a buffer before it can reach its cap, so overflow is reachable only while a continuation defers flushing. The demo's batching branch suspends its first flushed batch and then keeps sending.

What it prints

69 records over 12 scenarios: 48 trace events, 13 errors, 1 snapshots, 7 outputs (11 committed activations)

completion
  keys      completion|000000|000
  status    completed
  events    ACTIVATION_START > LLM_CALL > ACTIVATION_END
  reasons   (none)
  tokens    in=11 out=7 cache_hits=0

multi_tool
  keys      multi_tool|000000|001
  status    completed
  events    ACTIVATION_START > LLM_CALL > TOOL_CALL > TOOL_CALL > ACTIVATION_END
  reasons   (none)
  tokens    in=11 out=7 cache_hits=0

cache_hit
  keys      cache_hit|000000|002
  status    completed
  events    ACTIVATION_START > LLM_CALL > LLM_CALL > ACTIVATION_END
  reasons   (none)
  tokens    in=22 out=14 cache_hits=1

suspension_approved
  keys      suspension_approved|000000|003
  status    suspended, completed
  events    ACTIVATION_START > LLM_CALL > INTENT_EMITTED > SUSPENDED > ACTIVATION_END > ACTIVATION_START > ACTIVATION_END
  reasons   (none)
  tokens    in=11 out=7 cache_hits=0

suspension_denied
  keys      suspension_denied|000000|004
  status    suspended, completed
  events    ACTIVATION_START > LLM_CALL > INTENT_EMITTED > SUSPENDED > ACTIVATION_END > ACTIVATION_START > ACTIVATION_END
  reasons   (none)
  tokens    in=11 out=7 cache_hits=0

suspension_timeout
  keys      suspension_timeout|000000|005
  status    suspended
  events    ACTIVATION_START > LLM_CALL > INTENT_EMITTED > SUSPENDED > ACTIVATION_END > ERROR
  reasons   hitl_timeout
  tokens    in=11 out=7 cache_hits=0

tool_error
  keys      tool_error|000000|006
  status    (none committed)
  events    ERROR
  reasons   activation_error
  tokens    in=0 out=0 cache_hits=0

activation_error
  keys      activation_error|000000|007
  status    (none committed)
  events    ERROR
  reasons   activation_error
  tokens    in=0 out=0 cache_hits=0

budget_exceeded
  keys      budget_exceeded|000000|008
  status    (none committed)
  events    ERROR
  reasons   budget_exceeded
  tokens    in=0 out=0 cache_hits=0

orphaned_result
  keys      orphaned_result|000000|009
  status    completed
  events    ACTIVATION_START > LLM_CALL > ACTIVATION_END > ERROR
  reasons   orphaned_result
  tokens    in=11 out=7 cache_hits=0

intent_dead_letter
  keys      intent_dead_letter|000000|010
  status    completed
  events    ACTIVATION_START > LLM_CALL > INTENT_EMITTED > ACTIVATION_END
  reasons   intent_dead_letter
  tokens    in=11 out=7 cache_hits=0

batch_overflow
  keys      batch_overflow|000000|011
  status    suspended
  events    ACTIVATION_START > INTENT_EMITTED > SUSPENDED > ACTIVATION_END > ERROR
  reasons   batch_buffer_overflow, batch_buffer_overflow, ttl_wiped_batch, ttl_wiped_batch, ttl_wiped_batch, ttl_wiped_batch, ttl_wiped_suspension
  tokens    in=0 out=0 cache_hits=0

11 activations produced

Run it twice with the same --seed and you get that output byte for byte. That is not a coincidence and it is not a fixture: trace identity is uuid5(entity_key, seq), span identity is uuid5(entity_key, seq, role, index), intent identity is uuid5(entity_key, seq, step_index), and the fake provider replays a script — so nothing in a round reads a clock or a randomness source. It is what makes the screenshots on the console page reproducible.

The --seed rides in every entity key (<scenario>|<seed>|<index>), so a different seed moves every trace id with it. That matters under --loop, because the store deduplicates on (trace_id, span_id, event_type) — the key docs/traces.md publishes — and a round that reused its predecessor's ids would be silently collapsed onto it instead of showing up as new traffic.

Pointing your own pipeline at the console

One AgentConfig, four keyword arguments:

examples/console_demo/pipeline.py
"""Point a pipeline at a running console, and fill it with every kind of record.

Run it offline, with no credentials, no docker, and no console — it prints a
per-scenario summary of everything the round produced:

    uv run python -m examples.console_demo

Run it against a console started with `beam-agents-console`:

    uv run python -m examples.console_demo --console console://localhost:8787

Or keep producing, so a console shows live traffic arriving rather than a static
snapshot (this is what `docker/compose.console.yaml` runs):

    uv run python -m examples.console_demo --console console://localhost:8787 --loop

The scenarios themselves live in `beam_agents.console._demo`, and this module
calls into it rather than forking it. That is deliberate: the Docker image runs
the same generator, and an example that reimplemented the twelve scenarios would
drift away from the one the compose stack actually starts — which is exactly the
kind of drift that leaves a `docker compose up` user staring at a console with
three of its twelve views empty.

What this module adds is the part a reader is looking for: `console_config`, the
whole adoption path in one `AgentConfig`. Every function a pipeline references is
module-level so the DirectRunner can pickle it by reference.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from beam_agents.console import ConsoleSinkResolver
from beam_agents.console._demo import build as build_demo
from beam_agents.console._demo import main as demo_main
from beam_agents.core.transform import AgentConfig

if TYPE_CHECKING:
    from collections.abc import Callable

    import apache_beam as beam

    from beam_agents.model.client import LLMClient

# Where a console started by `beam-agents-console` listens by default.
CONSOLE_URI = "console://localhost:8787"


def console_config(
    provider_factory: Callable[[], LLMClient], *, console: str = CONSOLE_URI
) -> AgentConfig:
    """The whole adoption path: one resolver, three sink URIs.

    `ConsoleSinkResolver` *wraps* the runtime's `DefaultSinkResolver` rather than
    replacing it, so every other scheme a pipeline already uses keeps behaving
    exactly as it does today and no module on the hot path is modified. Unlike
    `otlp://` — which the default resolver refuses for anything but traces,
    because the OTLP encoding cannot represent an error record or a state
    snapshot — `console://` is accepted for all three, since the console's native
    ingest is the protos themselves.

    Copy this into your own pipeline and every activation it runs shows up in the
    console; delete the four keyword arguments and the pipeline is byte-for-byte
    what it was before.
    """
    return AgentConfig(
        provider_factory=provider_factory,
        traces_to=console,
        errors_to=console,
        snapshots_to=console,
        sink_resolver=ConsoleSinkResolver(),
    )


def build(pipeline: beam.Pipeline, *, console: str | None = None, seed: int = 0) -> None:
    """Wire the demo's scenarios onto `pipeline`, delivering to `console`.

    `console=None` runs the same pipeline with its sinks unset, which is what
    makes the example runnable with nothing else started.
    """
    build_demo(pipeline, console=console, seed=seed)


def main(argv: list[str] | None = None) -> int:
    """Run the demo and report what it produced.

    Delegates to `beam_agents.console._demo`'s entry point, so the example and
    `python -m beam_agents.console._demo` accept the same flags and cannot
    disagree about what a round contains.
    """
    return demo_main(argv)


if __name__ == "__main__":
    raise SystemExit(main())

ConsoleSinkResolver wraps the runtime's DefaultSinkResolver instead of replacing it, so every other scheme a pipeline already uses is untouched and core/transform.py is not modified. Removing those four arguments returns the pipeline to byte-for-byte what it was.

What the demo deliberately does not do

  • It does not invent records. Every field the console shows comes from a TraceEvent attribute, an ActivationErrorRecord field, or a StateSnapshot field. The demo drives the runtime; it never hand-writes a record.
  • It does not fabricate durations. Spans are zero-width by design (start_ms == end_ms), because measuring elapsed time would need a wall-clock read in the hot path. The summary above reports token counts and event sequences, never a span width.
  • It does not stand up infrastructure. DirectRunner, a scripted TestStream for both clocks, and a FakeLLM. No Kafka, no BigQuery, no collector, no credentials.