Domain-typed event envelope — the vocabulary root for everything that
travels the llm.* / agent.* / workflow.* streams. domain/type are
closed atom unions, usage/tool are the promoted commons consumers
actually query, and anything provider-specific rides raw (the documented
verbatim carve-out).
Build with llm/2, agent/2, or workflow/2 — each validates type
against its domain's closed union and each :path frame against the
closed frame-kind union (see "path" below), returning {:ok, t()} | {:error, {:bad_type, domain, type} | {:bad_frame, frame}}. Never
construct the struct directly.
to_wire/1 / from_wire/1 are the struct-in-BEAM / JSON-at-the-boundary
pair (the house DecisionRecord.to_event pattern): to_wire/1 always
succeeds and produces a string-keyed map with nil ids omitted and nil
usage/tool serialized as absent keys, never null. from_wire/1 is the
fallible, tolerant inverse — unknown top-level keys are ignored, an unknown
"type" for the domain is {:error, {:bad_event, {:bad_type, ...}}}, and
anything else unparseable is {:error, {:bad_event, reason}}. It never
raises and never calls String.to_atom/1 — types are resolved through
fixed lookup maps built from the closed unions.
Type unions are closed per release; tolerance for unknown telemetry frames lives at collection (collectors count + debug-log + drop), never here.
path — materialized call-path provenance
path is an ordered list of typed frames naming the chain of scopes that
contain this event, outermost first, innermost last:
["workflow:wf_123", "workflow_step:step_5", "agent:sess_9"] reads as "an
agent session, inside step_5, inside workflow wf_123." One event, in
isolation, recreates its full containment lineage and depth; the empty list
(the default) means "no recorded scope" (a top-level event).
List.last(path) is the innermost scope the event belongs to. For a
leaf event — an LLM call or usage record, not itself a scope — that frame is
also its immediate container (the step or session that made the call). For a
scope-lifecycle event — the start/stop/terminal of a scope — the innermost
frame IS that scope itself (a workflow.step_start ends in its own
workflow_step: frame; an agent.terminal ends in its own agent: frame),
so the scope's spawner is the previous frame. Read the chain, not a fixed
offset from the end.
Each frame is "<kind>:<id>" where kind is one of a closed union per
release (workflow | workflow_step | agent | conversation) and id is
a non-empty string. conversation is reserved for a future
conversation-scoped caller and is not produced by anything in this
release. Requests are leaf events, not scopes — there is no req: frame;
request_id stays a promoted id field on the event itself.
path is the containment/spawn axis: "what scopes am I inside." It is
deliberately distinct from a data-dependency axis some callers track
separately ("whose output did I consume", e.g. a step's first upstream
dependency) — a step can depend on another step's output without either
containing the other. Do not conflate the two; this module only carries the
containment axis.
Constructors validate every frame against the closed kind set and the
non-empty-id rule, returning {:error, {:bad_frame, frame}} for the
first offender (compile-time fixed kind list — never String.to_atom/1
on caller-supplied data): we only ever write frames whose kind this
release knows. to_wire/1 includes the "path" key only when the list is
non-empty (same omit-when-absent idiom as the id fields). from_wire/1
treats path as malformed-optional data, the house RouteResponse-style
posture for tolerant boundary parsing, and validates shape only — a
frame is a well-formed "<kind>:<id>" pair with both parts non-empty, but
the kind is not checked against the closed union. A future release may add
a kind this version does not know; erasing a whole path because one frame
names an unrecognized-but-well-formed scope would turn an additive change
into a reader-breaking one, so unknown-kind frames pass through intact. A
missing key is []; a non-list, or any frame that is not a well-formed pair,
degrades the whole path to [] rather than erroring — old persisted rows
and forward/backward version skew both read cleanly.
Summary
Functions
Build an agent.* event. See llm/2 for attrs.
Parse a wire-form map back into a t(). Fallible and tolerant: unknown
top-level keys are ignored, a non-map or a map missing "domain"/"type"
is {:error, {:bad_event, {:invalid_wire, term()}}}, an unrecognized
"domain" is {:error, {:bad_event, {:bad_domain, term()}}}, and an
unrecognized "type" for the domain is {:error, {:bad_event, {:bad_type, domain, term()}}}. Never raises; never calls String.to_atom/1.
Build an llm.* event. type must be one of llm_type/0; attrs is a
keyword list or map carrying :seq (default 0), :ts (default
System.monotonic_time(:nanosecond)), the four correlation ids, :usage,
:tool, :path (default [] — see the moduledoc), and :raw (default
%{}). Returns {:error, {:bad_frame, frame}} if any :path frame fails
validation (bad kind, empty id, or non-binary frame).
Render a t() to its wire form: a string-keyed map, correlation ids nested
under "ids" (nils omitted, "ids" itself always present), "usage"/
"tool" present only when not nil, "path" present only when non-empty,
"raw" always present.
Build a workflow.* event. See llm/2 for attrs.
Types
@type agent_type() ::
:session_open
| :session_reattach
| :turn_start
| :turn_end
| :terminal
| :error
@type domain() :: :llm | :agent | :workflow
@type llm_type() ::
:request_start
| :request_stop
| :reasoning
| :tool_call
| :usage
| :turn_complete
| :exception
@type t() :: %Mimir.Event{ domain: domain(), path: [String.t()], raw: map(), request_id: String.t() | nil, seq: non_neg_integer(), session_id: String.t() | nil, step_id: String.t() | nil, tool: %{id: String.t() | nil, name: String.t()} | nil, ts: integer(), type: llm_type() | agent_type() | workflow_type(), usage: %{input_tokens: non_neg_integer(), output_tokens: non_neg_integer()} | nil, workflow_id: String.t() | nil }
@type workflow_type() :: :step_start | :step_stop | :step_exception
Functions
@spec agent(agent_type(), keyword() | map()) :: {:ok, t()} | {:error, {:bad_type, :agent, term()} | {:bad_frame, term()}}
Build an agent.* event. See llm/2 for attrs.
Parse a wire-form map back into a t(). Fallible and tolerant: unknown
top-level keys are ignored, a non-map or a map missing "domain"/"type"
is {:error, {:bad_event, {:invalid_wire, term()}}}, an unrecognized
"domain" is {:error, {:bad_event, {:bad_domain, term()}}}, and an
unrecognized "type" for the domain is {:error, {:bad_event, {:bad_type, domain, term()}}}. Never raises; never calls String.to_atom/1.
"path" is malformed-optional data: a missing key parses to [], and a
non-list or any element that is not a well-formed "kind:id" pair degrades
the whole path to [] rather than failing the parse (a topology hint is
never worth rejecting an otherwise-valid event over). Shape is checked, not
the kind — unknown-but-well-formed kinds from a newer producer pass through.
@spec llm(llm_type(), keyword() | map()) :: {:ok, t()} | {:error, {:bad_type, :llm, term()} | {:bad_frame, term()}}
Build an llm.* event. type must be one of llm_type/0; attrs is a
keyword list or map carrying :seq (default 0), :ts (default
System.monotonic_time(:nanosecond)), the four correlation ids, :usage,
:tool, :path (default [] — see the moduledoc), and :raw (default
%{}). Returns {:error, {:bad_frame, frame}} if any :path frame fails
validation (bad kind, empty id, or non-binary frame).
Render a t() to its wire form: a string-keyed map, correlation ids nested
under "ids" (nils omitted, "ids" itself always present), "usage"/
"tool" present only when not nil, "path" present only when non-empty,
"raw" always present.
@spec workflow(workflow_type(), keyword() | map()) :: {:ok, t()} | {:error, {:bad_type, :workflow, term()} | {:bad_frame, term()}}
Build a workflow.* event. See llm/2 for attrs.