Standard tool call
The model emits a native tool call. It reports as ToolCallStarted → ToolCallCompleted with no graph_key and no parent_call_id — a top-level call.
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.
Five reporting channels carry runtime activity. Pick the narrowest one that matches the boundary you are crossing and the guarantee you need.
| Channel | Carrier type | Role | Stability | Consume 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. |
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.
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.
The model emits a native tool call. It reports as ToolCallStarted → ToolCallCompleted with no graph_key and no parent_call_id — a top-level call.
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.
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.
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.
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" }
]
}
}
}
}
The OTel sink derives span names from the same typed events, so both modes produce comparable spans.
| Span name | Source event | Notes |
|---|---|---|
lash.tool | ToolCallStarted / ToolCallCompleted | One 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_code | ProtocolStep (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_projection | ProtocolStep (observation diagnostic) | Observation-projection diagnostics get their own name. |
lash.protocol_step | ProtocolStep (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.
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.
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.exec | exec_command | shell, bash |
shell.start | start_command | long_running_command, pty |
shell.write | write_stdin | send_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.
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.
TraceTokenUsageThe 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.
RemoteUsageThe 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 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.
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#[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 boundaryRemoteTurnActivity 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 boundaryTRACE_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.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.
This page maps the channels. Follow each to its owning guide for the wiring recipe.