reporting/channels

Lash reports what a turn is doing on several channels at once, each with a different carrier type, audience, and stability contract. This page is the map: which channel to consume for which job, what fires during standard versus code-block execution, why every structured record carries the runtime tool name, and the schema-evolution rules that keep the channels stable.

The Channels

Five reporting channels carry runtime activity. Pick the narrowest one that matches the boundary you are crossing and the guarantee you need.

ChannelCarrier typeRoleStabilityConsume when
SessionEvent lash_sansio::SessionEvent Low-level runtime/debug stream: text and reasoning deltas, tool start, LLM request/response, token usage. Runtime-internal. Not an app contract; shape follows the runtime. You render an in-process debug or TUI view and want raw runtime signals.
TurnActivity / TurnEvent lash_core::TurnActivity (wraps TurnEvent) Stable, app-facing semantic signals with identity (id + correlation_id). Additive within the workspace; the exhaustive match is the drift guard. Not versioned, not #[non_exhaustive]. You drive an app UI or fold live turn activity. See Turns and streams.
TraceSink / TraceRecord lash_trace::TraceRecord (TraceEvent) Durable diagnostics: every provider call, tool call, prompt build, usage delta, protocol step, and Lashlang graph update. Versioned JSONL schema. TRACE_SCHEMA_VERSION = 2. You need billing, audit, offline analysis, OpenTelemetry export, or a rendered trace. See Tracing.
ProcessEventSink lash_core::ProcessEventSink Best-effort freshness push for durable background-process events, in per-process append order. Never truth (ADR 0017). Terminal events do not ride it; reconcile from the durable event log. You render a live process log. Await terminal state through the work driver, not the sink.
RemoteTurnActivity lash_remote_protocol::RemoteTurnActivity Versioned wire mirror of TurnActivity, deliberately narrower than the in-process enum. External stability boundary. protocol_version == REMOTE_PROTOCOL_VERSION (currently 7). Activity crosses HTTP, queues, or another process. See Remote protocol.
Two of these are not application contracts.

SessionEvent is the runtime's own low-level stream and follows the runtime; ProcessEventSink is a freshness overlay, never a source of truth. Apps build on TurnActivity in-process, on RemoteTurnActivity across a boundary, and read durable history from TraceRecord or the session read view — not from the two internal channels.

The relationship between the two app channels is deliberate: TurnEvent is the in-process source, and RemoteTurnEvent is its wire mirror. The mirror is narrower on purpose — four host-internal variants collapse into one generic diagnostic envelope (see Schema evolution) so the external contract does not churn every time a runtime-internal signal is added.

Standard And Code-Exec Reporting

A tool call reports the same way whether the model called it natively (standard mode) or a Lashlang code block called it (RLM / code-exec mode). One shared seam guarantees it.

Both modes route every tool call through the same tool-execution seam in lash-core. That seam emits exactly one ToolCallStarted and one ToolCallCompleted per call on both the TurnEvent channel and the TraceEvent channel, and the OpenTelemetry sink turns each pair into one lash.tool span. There is no per-protocol emission path to drift out of sync; the decision is recorded in docs/adr/0018-per-tool-telemetry-emits-from-one-shared-seam.md. What differs between the two modes is only the containment a call reports.

Standard tool call

The model emits a native tool call. It reports as ToolCallStartedToolCallCompleted with no graph_key and no parent_call_id — a top-level call.

Code block

A Lashlang block reports as CodeBlockStarted → … → CodeBlockCompleted. The start carries a graph_key; the completion repeats it and lists the tool_call_ids that ran inside.

Tool inside a block

Each tool the block invokes reports the enclosing block's graph_key. A child of a batch dispatch also carries parent_call_id — the batch call's id.

Consume the containment keys, not emission order.

graph_key answers "which code block was this tool call in?" and matches the enclosing CodeBlockStarted.graph_key. parent_call_id answers "which batch spawned it?". A UI groups rows from these two fields directly instead of guessing containment from the order events arrive. Both are Option, and both are omitted when absent.

One code block, its tools, and a batch child
flowchart TD CBS["CodeBlockStarted
graph_key = G"] --> T1["ToolCallStarted / Completed
exec_command
graph_key = G"] CBS --> B["ToolCallStarted / Completed
batch (call: c-batch)
graph_key = G"] B --> C1["ToolCallStarted / Completed
read_file
graph_key = G · parent_call_id = c-batch"] B --> C2["ToolCallStarted / Completed
read_file
graph_key = G · parent_call_id = c-batch"] T1 --> CBC["CodeBlockCompleted
graph_key = G · tool_call_ids = [...]"] C1 --> CBC C2 --> CBC

The exec-code completion diagnostic

Code-block execution also writes a diagnostic to the trace with a per-tool roll-up.

On the trace channel, an exec_code_completed diagnostic rides a ProtocolStep record (plugin_id: "runtime"). Its free-form payload carries a tool_calls array of { call_id, name, duration_ms, status } plus a tool_call_count equal to the array length. Because ProtocolStep payloads are opaque JSON, this roll-up was added without bumping TRACE_SCHEMA_VERSION.

{
  "type": "protocol_step",
  "plugin_id": "runtime",
  "payload": {
    "diagnostic": {
      "phase": "exec_code_completed",
      "payload": {
        "duration_ms": 43,
        "observation_count": 2,
        "tool_call_count": 2,
        "tool_calls": [
          { "call_id": "call-3", "name": "exec_command", "duration_ms": 41, "status": "success" },
          { "call_id": "call-4", "name": "read_file",    "duration_ms":  2, "status": "success" }
        ]
      }
    }
  }
}

OpenTelemetry span names

The OTel sink derives span names from the same typed events, so both modes produce comparable spans.

Span nameSource eventNotes
lash.toolToolCallStarted / ToolCallCompletedOne per tool call, standard and code-exec. Keyed by tool:<call_id>; carries lash.tool.name, lash.tool.status, lash.tool.duration_ms.
lash.exec_codeProtocolStep (exec diagnostics)The exec_code_started / exec_code_completed / exec_code_failed phases collapse into this one family; the precise phase rides the lash.protocol.diagnostic_phase attribute.
lash.observation_projectionProtocolStep (observation diagnostic)Observation-projection diagnostics get their own name.
lash.protocol_stepProtocolStep (other)Plain protocol steps that carry no diagnostic phase.

The span-name and attribute mechanics are owned by Tracing → OpenTelemetry Export; this page only notes that per-tool spans and the lash.exec_code family exist in both modes.

Tool Names: Runtime vs Surface

Every structured channel carries a tool's runtime name. The surface name — the Lashlang call path — appears only inside code text.

A tool has two names. The runtime name (the second argument to ToolDefinition::raw) is what the LLM tool schema advertises and what the runtime dispatches on; it is the name field on TurnEvent::ToolCall*, TraceEvent::ToolCall*, the lash.tool.name OTel attribute, the CLI's [tool] <name> line, and the tool_calls diagnostic roll-up. The surface name is the Lashlang call path (module.operation) registered by with_lashlang_binding; it appears only in Lashlang source, tool descriptions, and the code payload of a CodeBlockStarted event.

The runtime name travels; the surface name stays in code.

A model writes await shell.exec({ cmd: "…" }), but the tool call it produces reports as name: "exec_command" on every channel. When you match, filter, or label tool activity, match the runtime name. Reach for the surface name only when you are reading or generating Lashlang source.

The built-in shell catalog is the canonical example of the split (crates/lash-tools/src/shell/mod.rs):

Surface (Lashlang call path)Runtime name (on every channel)Aliases
shell.execexec_commandshell, bash
shell.startstart_commandlong_running_command, pty
shell.writewrite_stdinsend_stdin, poll_command

The mapping is not a central table in the codebase: each tool declares its runtime name and its Lashlang binding on its own ToolDefinition. The pattern — runtime name for dispatch and reporting, surface path for authoring — holds for every catalog, not just the shell.

Usage Accounting

Token counts have one canonical shape and two deliberate boundary copies, each fenced by a compile-forced drift guard.

The canonical counter is lash_sansio::TokenUsage: five buckets — uncached input_tokens, total output_tokens, cache_read_input_tokens, cache_write_input_tokens, and reasoning_output_tokens. Reasoning is a subset of output, not an additive total. UsageTotals wraps it with a derived total_tokens.

TraceTokenUsage

The trace channel's copy (lash-trace). The two converters that build it in lash-core destructure their source exhaustively (no ..), so adding a runtime bucket is a compile error until the trace mirror is extended.

RemoteUsage

The wire channel's copy (lash-remote-protocol). Its From converters destructure the core type exhaustively too, keeping the versioned wire format from silently dropping a new bucket.

The copies are intentional, and the compiler keeps them honest.

The trace and remote schemas are separate stability boundaries, so they hold their own usage structs rather than serializing the internal type. The exhaustive destructure is the mechanism that stops the three from drifting: extend one bucket upstream and the build breaks until each boundary copy is extended too.

Schema Evolution

Each channel evolves under its own rule. The in-workspace channel leans on the compiler; the two external boundaries carry version numbers.

TurnEvent — additive, compiler-guarded
Evolves additively within the workspace. New variants and new fields are added freely, and the enum is deliberately not #[non_exhaustive] so every in-repo consumer's exhaustive match — above all the From<TurnEvent> for RemoteTurnEvent conversion — fails to compile until it handles the new shape. The exhaustive match is the drift guard. There is no version number on TurnEvent; the compiler is the contract. Every Option field is skip_serializing_if = "Option::is_none", so absent fields never appear on the wire and adding one does not disturb existing consumers.
RemoteTurnActivity — the external wire boundary
The versioned wire mirror is the external stability boundary. RemoteTurnActivity carries protocol_version (equal to REMOTE_PROTOCOL_VERSION); validate() rejects an envelope from an unsupported version. The wire enum is deliberately narrower than TurnEvent: the four host-internal variants — QueuedWorkStarted, PluginRuntime, QueuedInputAccepted, and QueuedMessagesCommitted — collapse into a single RuntimeDiagnostic { kind, data } so the external contract does not churn when a runtime-internal signal changes. Every other variant maps one to one, containment keys included. Bump REMOTE_PROTOCOL_VERSION for a breaking wire change.
TraceRecord — the durable diagnostics boundary
TRACE_SCHEMA_VERSION governs the durable JSONL schema. Adding a new TraceEvent variant, or adding an optional (skip-when-None) field to an existing payload, is additive and does not bump the version — older readers skip the unknown variant or field. Renaming a field, removing a field, or changing the meaning of an existing field does bump it. The free-form Custom { name, payload } and ProtocolStep { plugin_id, payload } payloads are opaque JSON; adding to or reshaping data inside them never forces a bump — which is exactly why the exec_code_completed tool_calls roll-up shipped additively.
One rule of thumb.

Inside the workspace, let the exhaustive match catch drift. At a boundary that outlives a single build — the wire, the durable trace — a version number does the job the compiler cannot, and additive-only changes stay off the version dial.

Where Next

This page maps the channels. Follow each to its owning guide for the wiring recipe.

read on ·