The rendering system provides a pluggable pipeline for formatting and outputting agent session events. It separates what events look like (renderers) from where they go (sinks), allowing any combination.

Architecture

Event Stream  Renderer  iodata  Sink 1 (TTY)
                                   Sink 2 (File)
                                   Sink 3 (JSONL)
                                   Sink 4 (Callback)

A renderer transforms canonical event maps into formatted text (iodata). A sink writes that text to a destination. One renderer feeds multiple sinks simultaneously. The orchestrator (Rendering.stream/2) wires them together.

Quick Start

alias AgentSessionManager.Rendering
alias AgentSessionManager.Rendering.Renderers.CompactRenderer
alias AgentSessionManager.Rendering.Sinks.TTYSink

# Render a live event stream to the terminal
Rendering.stream(event_stream,
  renderer: {CompactRenderer, []},
  sinks: [{TTYSink, []}]
)

Rendering.stream/2

The main entry point. Consumes an Enumerable of event maps, renders each event, and writes to all sinks.

Rendering.stream(event_stream,
  renderer: {renderer_module, renderer_opts},
  sinks: [
    {sink_module_1, sink_opts_1},
    {sink_module_2, sink_opts_2}
  ]
)

Returns :ok on success or {:error, reason} on failure.

Lifecycle

  1. Initrenderer.init(opts) and sink.init(opts) for each sink
  2. Render loop — For each event in the stream:
    • renderer.render_event(event, state){:ok, iodata, new_state}
    • sink.write_event(event, iodata, state) for each sink
  3. Finishrenderer.finish(state) → final iodata written to sinks
  4. Cleanupsink.flush(state) then sink.close(state) for each sink

Event Format

Renderers receive canonical event maps as emitted by adapter event callbacks. At minimum:

%{type: atom(), data: map()}

Common fields include :timestamp, :session_id, :run_id, and :provider. See the Events and Streaming guide for the full event type reference.

Event Types Handled by Renderers

Event TypeDescriptionKey Data Fields
:run_startedRun beganmodel
:message_streamedText content deltadelta or content
:tool_call_startedTool invocation begantool_name, tool_input, tool_call_id
:tool_call_completedTool invocation finishedtool_name, tool_output
:token_usage_updatedToken counts updatedinput_tokens, output_tokens
:message_receivedComplete messagecontent, role
:run_completedRun finishedstop_reason
:run_failedRun failederror_code, error_message, provider_error
:run_cancelledRun cancelled
:error_occurredError during runerror_code, error_message, provider_error

Unknown event types are rendered as catch-all entries rather than dropped.

Renderers

CompactRenderer

Single-line token format with ANSI colors. Streams text and tool events inline.

{CompactRenderer, color: true}

Token legend:

TokenMeaning
r+Run started
r-Run completed (with stop reason suffix, e.g. r-:end)
t+NameTool call started
t-NameTool call completed
>>Text stream follows
tk:N/MToken usage (input/output)
msgComplete message received
!Error or failure
?Unknown event type

Options:

  • :color — Enable ANSI color output. Default true.

Example output:

r+ sonnet >> Hello! I'll read that file for you. t+Read t-Read tr:defmodule Foo... >> The file contains... r-:end
12 events, 2 tools

VerboseRenderer

Line-by-line bracketed format. Each structured event gets its own line with labeled fields. Streamed text is written inline between bracketed lines.

{VerboseRenderer, []}

Example output:

[run_started] model=claude-sonnet-4-5-20250929 session_id=ses_123
Hello! I'll read that file for you.
[tool_call_started] name=Read id=tu_001 input={"path":"/foo.ex"}
[tool_call_completed] name=Read output=defmodule Foo...
The file contains a module definition.
[run_completed] stop_reason=end_turn
--- 8 events, 1 tools ---

StudioRenderer

The StudioRenderer produces CLI-grade interactive output designed for watching agent sessions live in a terminal. It replaces dense token streams with:

  • Status symbols during tool execution ( running, success, failure)
  • Human-readable tool summaries instead of raw JSON
  • Clean text streaming with proper indentation
  • Visual separation between reasoning and tool actions

Quick Start

Rendering.stream(event_stream,
  renderer: {StudioRenderer, []},
  sinks: [{TTYSink, []}]
)

Options

OptionTypeDefaultDescription
:colorbooleantrueEnable ANSI colors
:tool_output:summary | :preview | :full:summaryTool output verbosity
:indentinteger2Base indentation (spaces)
:ttybooleanauto-detectOverride TTY detection

Tool Output Modes

:summary — One line per tool:

 Read lib/sink.ex (72 lines)
 Ran: mix test (exit 0, 156 chars, 3.2s)

:preview — Summary + last 3 lines of output:

 Ran: mix test (exit 0, 3.2s)
 .....
 5 tests, 0 failures
 Finished in 1.2 seconds

:full — Complete tool output:

 Ran: mix test (exit 0, 3.2s)
 Running ExUnit with seed: 12345
 .....
 5 tests, 0 failures

Non-TTY Fallback

When output is piped or redirected, StudioRenderer automatically:

  • Disables cursor control (no line overwriting)
  • Prints both status and summary as separate lines
  • Disables color (unless forced)

PassthroughRenderer

No-op renderer that returns empty iodata for every event. Use with sinks that process raw events directly (CallbackSink, JSONLSink).

{PassthroughRenderer, []}

Sinks

TTYSink

Writes rendered output to a terminal device, preserving ANSI color codes.

{TTYSink, device: :stdio}

Options:

  • :device — IO device to write to. Default :stdio.

FileSink

Writes rendered output to a plain-text log file with ANSI codes stripped.

{FileSink, path: "session.log"}

Options (one required):

  • :path — File path to write to. FileSink opens and owns the file.
  • :io — Pre-opened IO device. FileSink writes to it but does not close it. Useful when you need to write a header before starting the rendering pipeline.
# Pre-opened IO device example
{:ok, io} = File.open("session.log", [:write, :utf8])
IO.write(io, "Session started at #{DateTime.utc_now()}\n\n")

Rendering.stream(events,
  renderer: {CompactRenderer, []},
  sinks: [{FileSink, io: io}]
)

File.close(io)  # caller manages lifecycle

JSONLSink

Writes events as JSON Lines (one JSON object per line). Ignores rendered text and serializes raw events directly.

{JSONLSink, path: "events.jsonl", mode: :full}

Options:

  • :path — File path to write to. Required.
  • :mode:full (default) or :compact.

Full mode preserves all event fields with ISO 8601 timestamps:

{"ts":"2026-02-09T12:00:00Z","type":"run_started","data":{"model":"claude-sonnet-4-5-20250929"},"session_id":"ses_123","run_id":"run_456"}

Compact mode uses abbreviated type codes and millisecond epoch timestamps:

{"t":1707464400123,"e":{"t":"rs","m":"sonnet-4-5-20250929"}}
{"t":1707464400200,"e":{"t":"ms","l":12}}
{"t":1707464400300,"e":{"t":"ts","n":"Read"}}
{"t":1707464400400,"e":{"t":"tc","n":"Read","l":245}}
{"t":1707464400500,"e":{"t":"rc","sr":"end"}}
Compact CodeFull Type
rsrun_started
msmessage_streamed
tstool_call_started
tctool_call_completed
tutoken_usage_updated
mrmessage_received
rcrun_completed
rfrun_failed
rxrun_cancelled
ererror_occurred

CallbackSink

Forwards raw events to a callback function. Use for programmatic event processing — aggregation, forwarding to GenServer, broadcasting via PubSub.

{CallbackSink, callback: fn event, _iodata -> handle(event) end}

Options:

  • :callback — A 2-arity function (event, iodata) -> term(). Required.

Multi-Sink Pipelines

The rendering system's power comes from combining multiple sinks in a single pipeline. All sinks receive every event simultaneously:

alias AgentSessionManager.Rendering
alias AgentSessionManager.Rendering.Renderers.CompactRenderer
alias AgentSessionManager.Rendering.Sinks.{TTYSink, FileSink, JSONLSink, CallbackSink}

{:ok, log_io} = File.open("session.log", [:write, :utf8])
IO.write(log_io, "=== Session Log ===\n\n")

event_count = :counters.new(1, [:atomics])

Rendering.stream(event_stream,
  renderer: {CompactRenderer, []},
  sinks: [
    {TTYSink, []},
    {FileSink, io: log_io},
    {JSONLSink, path: "events.jsonl", mode: :full},
    {JSONLSink, path: "events-compact.jsonl", mode: :compact},
    {CallbackSink, callback: fn _event, _iodata ->
      :counters.add(event_count, 1, 1)
    end}
  ]
)

File.close(log_io)
IO.puts("Processed #{:counters.get(event_count, 1)} events")

Writing a Custom Renderer

Implement the AgentSessionManager.Rendering.Renderer behaviour:

defmodule MyApp.MarkdownRenderer do
  @behaviour AgentSessionManager.Rendering.Renderer

  @impl true
  def init(_opts), do: {:ok, %{events: 0}}

  @impl true
  def render_event(%{type: :run_started, data: data}, state) do
    {:ok, "## Run Started\n\nModel: `#{data[:model]}`\n\n", %{state | events: state.events + 1}}
  end

  def render_event(%{type: :message_streamed, data: data}, state) do
    {:ok, data[:delta] || "", %{state | events: state.events + 1}}
  end

  def render_event(%{type: :tool_call_started, data: data}, state) do
    {:ok, "\n\n### Tool: #{data[:tool_name]}\n\n", %{state | events: state.events + 1}}
  end

  def render_event(_event, state) do
    {:ok, "", %{state | events: state.events + 1}}
  end

  @impl true
  def finish(state) do
    {:ok, "\n\n---\n_#{state.events} events processed_\n", state}
  end
end

Writing a Custom Sink

PubSubSink (Built-in)

ASM now ships a built-in AgentSessionManager.Rendering.Sinks.PubSubSink. It broadcasts events via Phoenix PubSub and ignores rendered text unless include_iodata: true is set.

Requires the optional phoenix_pubsub dependency:

# mix.exs
{:phoenix_pubsub, "~> 2.1"}
alias AgentSessionManager.Rendering.Sinks.PubSubSink

# Broadcast to per-session topics
{PubSubSink, pubsub: MyApp.PubSub, scope: :session}

# Broadcast to a static topic
{PubSubSink, pubsub: MyApp.PubSub, topic: "agent:events"}

# Dynamic topics (single or multi-topic)
{PubSubSink, pubsub: MyApp.PubSub, topic_fn: fn event ->
  alias AgentSessionManager.PubSub.Topic
  [
    Topic.build_session_topic("asm", event[:session_id]),
    Topic.build_run_topic("asm", event[:session_id], event[:run_id])
  ]
end}

Subscribers receive {:asm_event, session_id, event} by default.

See the PubSub Integration guide for full documentation, including the event-callback bridge for non-rendering usage.

Before (custom)

If you need a custom sink for non-standard behavior, implement the AgentSessionManager.Rendering.Sink behaviour directly:

defmodule MyApp.PubSubSink do
  @behaviour AgentSessionManager.Rendering.Sink

  @impl true
  def init(opts) do
    topic = Keyword.fetch!(opts, :topic)
    {:ok, %{topic: topic}}
  end

  @impl true
  def write(_iodata, state), do: {:ok, state}

  @impl true
  def write_event(event, _iodata, state) do
    Phoenix.PubSub.broadcast(MyApp.PubSub, state.topic, {:agent_event, event})
    {:ok, state}
  end

  @impl true
  def flush(state), do: {:ok, state}

  @impl true
  def close(_state), do: :ok
end

Integration with Event Streams

Rendering.stream/2 accepts any Enumerable. In practice, event streams come from adapter execution via SessionManager.run_once/4 using an event callback and Stream.resource:

defp build_event_stream(store, adapter, prompt) do
  parent = self()
  ref = make_ref()
  callback = fn event -> send(parent, {ref, :event, event}) end

  Task.start(fn ->
    result = SessionManager.run_once(store, adapter,
      %{messages: [%{role: "user", content: prompt}]},
      event_callback: callback)
    send(parent, {ref, :done, result})
  end)

  Stream.resource(
    fn -> :running end,
    fn
      :done -> {:halt, :done}
      :running ->
        receive do
          {^ref, :event, event} -> {[event], :running}
          {^ref, :done, _result} -> {:halt, :done}
        after
          120_000 -> {:halt, :done}
        end
    end,
    fn _ -> :ok end
  )
end

See the rendering examples for complete runnable scripts using live providers.