- Status: Stable
- Validated by: the
AgentSessionSchema/SessionMessageSchema/SessionContextSchemaZod definitions in@relavium/shared—SessionContextSchemalands with the event union (1.L.0);SessionMessageSchema/AgentSessionSchemaland with the agent-first sub-spine (1.V/1.X), as they reference the shared-ownedContentPart - Canonical home: the runtime contract for an
AgentSession— its lifecycle, message shape, context, and export-to-workflow contract - Related: workflow-yaml-spec.md, agent-yaml-spec.md, config-spec.md, sse-event-schema.md (the
session:*event namespace), ../shared-core/llm-provider-seam.md (theLlmMessageruntime type this maps to), ../shared-core/built-in-tools.md, ../shared-core/database-schema.md (theagent_sessions/session_messagestables), ../../architecture/agent-sessions.md, ../../decisions/0024-agent-first-entry-point-agentsession.md, ../../decisions/0026-session-export-to-workflow.md
An agent session is an ongoing, multi-turn conversation between a user and a single agent. It is
Relavium's agent-first entry point — a first-class peer of a workflow run that reuses the same
engine substrate (the AgentRunner, the ToolRegistry, the @relavium/llm seam, and the event
bus) rather than a parallel implementation; see
ADR-0024 for the decision and
agent-sessions.md for how it is built. This document is the
one canonical home for the session contract; it cites the event schema, the DB schema, and
the seam rather than restating them.
Enforced source of truth. The TypeScript shapes below are illustrative. The runtime-validated source of truth is the Zod schema set in
@relavium/shared, from which the types are inferred (ADR-0020). This document is the canonical human-readable contract; if the two diverge, this spec wins and the schema is corrected to it.
- A session binds one agent (an
.agent.yaml, agent-yaml-spec.md) for the whole conversation. There is no mid-session agent switching; multi-agent orchestration remains a workflow concern. The model and its memoizedfallback_chain, by contrast, belong to theAgentSessioninstance: the CLI's mid-chat reseat (/models) resumes a new instance on the chosen model and rebuilds the chain for it — the session id is unchanged, the instance is not (ADR-0059; behaviour in chat-session.md). Each instance still has exactly one model. - A session is multi-turn and stateful: each user message produces an assistant turn that may
include tool-call round-trips, exactly like a workflow
agentnode — the difference is the entry point and lifetime, not the execution. - A session is auto-persisted and resumable (below); it is not a workflow run and does not appear in run history. It can be exported to a workflow (export).
Request data handed to the controlled provider chain follows the seam's supported inert-data and ownership contract. The shared turn core captures before admission/money awaits and reuses its owned round through pre-attempt hooks and dispatch, retaining live cancellation and creating fresh owned tool rounds. The factory-measured first request can be reused by exact identity as described in the runner contract. Core reuse and Step 8's session measurement, atomic compaction and overflow recovery have independent acceptance; final wave review is accepted separately.
Hosts select primary delivery when constructing the session handle, following the stream consumption contract. The CLI attaches live subscribers before production; standalone iterated hosts must drain the primary stream and wire its readiness to the session. Session cancellation closes delivery without inventing a replayable transient event log.
stateDiagram-v2
[*] --> Idle: start(agentRef, context)
Idle --> Streaming: sendMessage(text)
Streaming --> Streaming: tool-call round-trip
Streaming --> Idle: assistant turn complete
Streaming --> Idle: abort (Esc) — turn ends, session lives (ADR-0057)
Idle --> Idle: setTurnPolicy (reseat-less mode change, ADR-0057)
Streaming --> Streaming: setTurnPolicy (stored; no effect this turn, applies next, ADR-0057)
Idle --> Idle: resume (reload from history.db)
Idle --> [*]: cancel / end
Idle --> Exported: export → .relavium.yaml
| Operation | Meaning |
|---|---|
| start | Open a session for an agentRef with an initial SessionContext. Allocates a sessionId and persists the session row. |
| sendMessage | Append the pending user to the working transcript, run one assistant turn through the shared turn core (streaming + tool-call loop), and retain the final assistant text on success. The host atomically persists the completed turn's durable messages, including content-free tool structure and an explicit terminal even for empty final text. Working context excludes tool pairs; carrying them to a later request is deferred (ADR-0095 §2). |
| setTurnPolicy | Set/clear the reseat-less mode policy (ADR-0057) — the advertise-filter + the interactive approval hook — on the same session instance (no reseat, no tool-context loss). Snapshotted at each turn start, so a change applies on the next turn. The ask / plan / accept-edits / auto enum lives in the host; this is its mode-agnostic engine projection. Callable in any state, including mid-turn; inert once cancelled (a cancelled session runs no further turn, so the policy is never read again). |
| abort | Mid-turn abort (ADR-0057 EA7): end the in-flight turn via its AbortSignal but keep the session alive — settle one session:turn_completed{stopReason:'aborted'} (no error), roll the pending user message back, and return to idle. Distinct from cancel (which is terminal): no session:cancelled, no new status. No-op when no turn is in flight; a concurrent cancel wins. A late abort that lands after the turn already resolved is also a no-op — that turn completes normally and its reply is kept (abort interrupts an in-flight turn only, never discards a finished one). |
| cancel | Abort the in-flight turn via AbortSignal and end the session (the terminal session:cancelled); the session stays resumable from its persisted transcript. |
| runUserCommand | Run a USER-invoked !-shell command (2.5.D, ADR-0061) — the additive method that routes a shell escape through the one run_command dispatch boundary, reusing the same dispatch-context construction as a turn (toolPolicy allowlist, fsScope, gateApproved:false, the mode-aware confirmAction): enforcePolicy(allowedCommands) before approval → spawn/shell:false. The caller pre-tokenizes the line into command + args (no shell expansion); the user grant of run_command is scoped to this one-off dispatch and never reaches the model. Returns a classified UserCommandOutcome (ran | denied{allowlist} | failed | cancelled) — no raw error escapes. Callable only when started + idle (a ! never races a turn); leaves the session idle. |
| resume | Reload a persisted session (messages + context) and continue. |
| compact | Summarise the older working context, retain the latest complete exchange and append a durable boundary marker. Callable only when started and idle; returns a typed CompactionResult. Memory-policy refusal precedes no-op detection, provider planning, events and egress. |
| trimHistory | Keep a suffix under a message-unit bound, starting at a user boundary; append a marker without an LLM call. Callable only when started and idle; returns a typed TrimResult. Memory-policy refusal precedes bound/no-op checks and mutation. |
| export | Serialize the session to a .relavium.yaml scaffold (export). |
The turn loop, tool dispatch, streaming, and fallback are the same code paths a workflow agent
node uses: the session is a thin wrapper over the correlation-agnostic turn core — the runAgentTurn
path the AgentRunner (1.O) also wraps for a workflow node — managing conversation state and context.
(1.V drives that turn core directly; it does not route through the run-only NodeExecutor the
AgentRunner exposes. "Same AgentRunner path" means the shared turn-core execution, not the
NodeExecutor surface.) The lifecycle emits the session:* event namespace — defined, with the run
namespace, in sse-event-schema.md (this spec does not
enumerate event names). 1.V keeps the conversation in-memory (the in-flight LlmMessage/ContentPart
form) and emits session events through an injected sink; wiring that sink onto the shared RunEventBus
(per-session sequenceNumber + gap/resync) is 1.W, and the durable SessionMessage
schema + persistence is 1.X.
The authored memory contract selects one request
projection, also used by the session's context estimate. It applies to fresh, resumed and model-reseated
instances. Selection and summary placement happen before same-role folding; generated summaries
remain untrusted user-role data and cannot change the authored system prompt. Each request owns
its message/content arrays and text parts, so request consumers cannot mutate the working transcript.
AgentSession.memoryPolicy returns the copied, frozen policy. automaticCompactionAllowed exposes
its resolved permission independently of whether a trigger or budget permits a call. compactionRefusal
and trimRefusal expose the same MemoryPolicyRefusal that the operations return:
{ kind: 'policy_refused', memory: 'none' | 'window', message: string }. The message is a fixed,
secret-free explanation. Lifecycle misuse still throws SessionStateError before a policy outcome.
Hosts use these getters before progress or bound validation; the engine remains the authority.
reconstructSessionState(record, messages) returns a text-only, unfolded SessionResumeState.
Alongside its messages, costs and turn counter, it supplies required completedTurnSpans: ordered,
non-overlapping { start, end } indices into that message array, with exclusive end. Each span
starts with a user and contains either that user alone for an empty final, or the user plus its final
assistant text. Structural durable rows are excluded. Legacy text remains outside spans rather than
being invented into a completed turn. The current pending user is tracked separately during a send.
Build resume state with this helper; AgentSession.resume validates span bounds, roles and text-only
parts before host cost callbacks, refuses invalid metadata with SessionStateError code
invalid_resume_state, and copies the admitted state. A compact/trim rebases surviving whole spans;
a failed, aborted or cancelled turn creates none.
Automatic work is permitted by the resolved memory policy, independently of funding. Pre-send
and after-turn triggers measure the actual next owned request (system, projected history, tools,
kept exchange and pending user), using the first attemptable entry's authoritative window and
shared output reservation. Billed input/cache/tool-round usage does not drive the trigger.
Unknown/custom windows and providers managing their own context skip automatic work. A trigger
compares input with compactThreshold × window, and input plus output reservation with the full
window. The irreducible kept exchange/pending user, tools, system and maximum summary wrapper
are compared with the full window; if they cannot fit, pre-send compaction is skipped and that
turn cannot start another recovery cycle. A failed/skipped pre-send operation sends the original
owned request. Budget refusal or cancellation sends no main request and settles the active turn.
SessionDeps.afterTurnCompaction: false disables only unused after-turn work; the CLI one-shot
uses it while retaining explicit summary permission at active entry points.
One inner operation shares the active turn's signal; idle compact() owns its own controller.
It greedily folds older whole messages through at most four budget-gated passes, keeping the
latest completed exchange, following legacy text and the explicit pending user verbatim.
Every summariser construction fits every known attemptable entry's measured input-plus-output
bound. An individually oversized foldable message uses disclosed head-and-tail truncation;
system text, running summary, kept exchange and pending user are never truncated. All passes
must succeed with nonempty text before one summary/history replacement and one boundary event.
Cancellation, refusal or failure installs no partial summary; engaged spend remains accounted.
keptTurnCount includes retained legacy user slots and empty-final exchanges, excluding the
pending user, so the persister advances only the dropped completed-history boundary.
For manual unknown/mixed-window compaction, each pass additionally respects the fixed 16,384-token
soft input bound. This is an operational bound, not model metadata or a fit guarantee. The
surface acknowledges a fixed unknown-window disclosure through SessionDeps.onCompactionStart
before the first admitted call can egress. A failed acknowledgement sends nothing. The callback
receives the current operation's signal and checks it before queued publication. Cancellation
races a pending acknowledgement, releases unused admission and returns the session to idle
without waiting for the terminal sink. Late acknowledgement/rejection is observed and grants
no egress authority. Automatic
paths never use the soft bound. All opened moments end with success, one failed-moment event,
or terminal session cancellation; first-admission refusal opens no moment.
CompactionResult distinguishes budget_refused {message, momentOpened} from ordinary failed,
cancelled, nothing_to_compact and policy refusal. Manual refusal returns its safe cap message
without a turn terminal. After-turn refusal preserves the successful reply and all history;
first-pass refusal emits a standalone side notice, later refusal closes the open moment once.
Budget refusal never trims. Ordinary after-turn failure retains the configured deterministic
trim fallback; cancellation does not trim. Active pre-send/recovery refusal settles one
budget_exceeded turn terminal naming the cap. Event shapes have one home in
sse-event-schema.md.
SessionDeps.maxTokensEstimate is an optional estimate-only fallback copied at construction. CLI
fresh/resumed/reseated chat and one-shot entry points pass the resolved config value, including when
no budget governor exists. The shared turn core estimates every current tool round before cross-provider
reasoning stripping and forwards required cap-plan/request identity, input estimate and the same frozen
fallback to admission. The canonical estimator, cap precedence, handoff and failure-evidence contract
lives in llm-provider-seam.md.
The same owned construction and cap authority govern measured session context and each compaction pass.
An unrecovered official, fixture-matched overflow settles the turn with context_overflow, never a
provider-authored message. The engine names the attempted model, its authoritative window
(or fixed size unknown wording) and whether tools already ran. The internal recoverability
fact is true only before any tool round or committed content; it is not persisted or emitted.
Before content/tool commitment, one genuine overflow may invoke compaction and one fresh measured
retry when automatic permission and an authoritative applicable window allow it. Observer failures
cannot forge this private invocation-bound evidence, even by rethrowing a genuine error from a
previous call. A failed/skipped prior pre-send operation suppresses
recovery. Nothing to fold or ordinary recovery failure retains the original overflow; budget refusal
settles budget_exceeded. Retry overflow is final. Main-attempt usage is accumulated across the two
attempts; summariser spend remains separate and never consumes a user-turn slot. A turn
that already dispatched tools is never restarted by this classification. Native Anthropic
context stops retain their actual usage through the failed turn and money ledger. The
LLM seam owns the dialect
criteria, custom-endpoint downgrade and admission-release evidence; the
CLI owns command-specific remedies.
A session carries a hard turn cap — a finite DoS fail-safe on the number of turns it will run (engine
default 50, overridable at construction; 0/absent ⇒ the default). It is distinct from two other
limits and must not be conflated with either: [chat].max_messages (a history-trim threshold that
silently continues the session — config-spec.md) and the turn core's within-turn
maxToolTurns tool-loop guard. A sendMessage past the cap ends loudly, with no egress:
session:turn_completed carries stopReason: 'error' + error.code: 'turn_limit'
(sse-event-schema.md) — never a silent stop; the within-turn
maxToolTurns guard surfaces the same turn_limit code through the same event. The cap is an engine-API
knob (SessionDeps.maxTurns); a surface maps the [chat].max_turns config field onto it at construction
time — that surface field was added in build-phase 2 (workstream 2.M); see the [chat] block in
config-spec.md.
Only provider-engaged turns consume a slot, including failed or aborted turns whose trusted host
callback throws. Engagement is recorded on actual non-error provider chunks and chain-owned provider
invocation evidence, independently of a budget hook, the exception type or a positive token count.
Local preparation, credential, deadline setup and provider-method lookup refusals before invocation
consume no slot.
Accounting uses an internal outcome carrier without mutating the thrown value, including frozen
classified exceptions. Terminal usage retains known prior attempts and an observed valid terminal
usage chunk; an unreported current attempt adds no
invented usage. Ordinary raw callback/money errors remain visible to the API caller after the fixed,
secret-free session terminal. If prototype or diagnostic-field inspection during session presentation itself throws, the fixed
terminal still uses canonical accounting and the original throwable is rethrown. A successful turn
counted before its later durability flush is counted
once even if that flush fails; its known quantities outrank unrelated exception metadata for every
error class. A later-round budget pause retains earlier engagement and usage. Terminal cancel()
retains its sole cancellation event.
Provider diagnostic normalization and cause classification follow the normal turn taxonomy, with opaque
provider causes kept private. Nested diagnostics must retain actual invocation evidence and cannot
permit another send past the hard turn cap.
The shared turn driver records the exact throwable escaping its attempt observer. Inline generated
responses preserve that observer failure without entering provider classification, even when the
observer throws an LlmProviderError; genuine provider failures retain their normal mapping. A later
tool round guards budget-error inspection so a throwing prototype or diagnostic cannot replace the
original callback failure. External emit/readiness/record and chain clock/backoff failures have
call-local origin distinct from internal admission settlement. Workflow callers refuse retry/gate
and failure-writer authority from that origin. Admission/money causes are unwrapped only with exact
current pre-attempt provenance, never solely by error class.
Session preserves classified-error delivery and raw rejection while using fixed internal,
non-retryable observer presentation; the callback diagnostic cannot become public authority.
Post-success completion and commitment-flush callbacks carry the same exact observer provenance;
readable classified errors use fixed internal, non-retryable presentation, while raw/opaque failures
retain their original rejection. Already engaged turns keep their real usage and consume one slot.
The session attempts its fixed terminal with canonical accounting; a throwing sink cannot guarantee
publication, and a sink recording before throwing may see multiple notification drafts. Compaction uses the same
captured outcome and preserves observer origin through its start/finish lifecycle notifications.
Readable classified observer failures return a fixed private-safe failed result; ordinary raw or
opaque observer failures reject with the original value. Actual cancellation and genuine pre-egress
budget refusal retain their existing handling. Controller-factory failures in send, compaction and
user commands release running/abort state and reject the original value without classified delivery.
A throwing turn-start notification receives the same cleanup before any provider call. Terminal
cancellation keeps precedence; a refused initialization consumes no provider-engaged slot.
Compaction clears its running/abort state on every exit. If a trusted controller factory raises
abort or cancel before returning its controller, the returned signal carries that recorded intent
before work begins; terminal cancellation permits no later start/compaction notification or command.
Cold compaction planning also rechecks terminal cancellation after provider resolution and before
arming a controller. Cancellation there permits no credential or provider call and keeps the session
closed; idle abort remains a no-op and a warm memoized plan does not rerun the resolver.
If the resolver starts another real session operation, compaction rechecks its idle precondition
and throws the existing lifecycle refusal before arming a second controller. The running operation
retains the controller targeted by abort and cancel.
A successful commitment flush rechecks terminal cancellation before installing the reply or
publishing completion. Known usage, cost and provider-engaged turn consumption remain accounted.
Compaction estimates the prospective summary/history projection before installing it and rechecks
terminal cancellation after the provider-supplied estimator. A cancellation during that estimator
retains the previous history and summary. Ordinary estimator failure remains best-effort; the existing
EA7 late-abort no-op after successful summarisation remains unchanged.
SessionContext is the workspace situation a session runs against, auto-detected from the launching
surface and overridable by the user.
interface SessionContext {
workingDir: string; // workspace root (auto-detected; overridable)
activeFile?: string; // the surface's active file, if any
selection?: { file: string; startLine: number; endLine: number };
gitRef?: string; // current branch / commit, for provenance
fsScopeTier: 'sandboxed' | 'project' | 'full'; // same tiers as workflows; default sandboxed
variables?: Record<string, string>; // session-scoped {{ctx.*}} values — plaintext, NO secrets (§ Tools, secrets)
}fsScopeTier and the command allowlist are the same filesystem-scope tiers and allowedCommands
policy a workflow uses (see built-in-tools.md
and workflow-yaml-spec.md); the chat-mode defaults
(fs_scope, the command allowlist, default_model, max_turns (the hard turn cap → SessionDeps.maxTurns),
max_messages, and an optional pre-egress cost
cap max_cost_microcents / on_exceed — the same ADR-0028
governor a workflow budget uses) live in the [chat] block of config-spec.md and
reference those canonical homes — they are not re-declared here.
SessionMessage is the persistence / transcript type for a turn. It is append-only (mirroring
the run-event log): messages are never edited or deleted, only appended.
interface SessionMessage {
id: string;
sessionId: string;
sequenceNumber: number; // monotonic per session
role: 'system' | 'user' | 'assistant' | 'tool';
content: SessionContentPart[]; // session-only durable union: structural tools, handle-only media, signature-less reasoning
modelId?: string; // canonical model id for an assistant turn (fallback-aware; mirrors session_messages.model_id)
compaction?: { droppedThroughSequence: number }; // ADR-0062: present ONLY on a role:'system' compaction/trim boundary marker — the durable seq through which older messages are superseded (mirrors session_messages.compaction_dropped_through_sequence)
timestamp: string; // ISO 8601
}The session-only SessionContentPart union is owned by @relavium/shared
(ADR-0095). Its exact structural
tool fields, byte-size units and engine id form live in
the database contract. It refuses raw arguments,
results, continuation signatures and unknown fields instead of silently dropping them. Non-tool media
remains handle-only; tool attachments carry only handle metadata, never result filenames or transcripts.
The generic DurableContentPart used by run/event/IPC positions is unchanged.
SessionMessage is mapped into the seam's LlmMessage, rather than copied. The next turn sees the
text-only projection: carrying earlier tool rounds is deferred. Both forms are Relavium-owned, so
no vendor SDK type crosses the seam (ADR-0011).
A completed turn persists its user row, ordered structural call/result rows, and one terminal
assistant row containing a text part, even when that text is empty. The additive toolHistory
field on the completed-turn event supplies only
structure; streaming tool inputs and result summaries never feed transcript persistence.
The persister commits the entire exchange and session totals atomically. A failure latches
and stops later model/command egress. Admission checks the live latch at each provider attempt and
effect preparation, and immediately before every actual tool dispatch, including unjournaled tools,
a cached idle-command key and a cost-write failure after provider admission. The dispatch check runs
after asynchronous approval and preparation. Its refusal proves dispatch never started and releases
any prepared claim. Settling or discarding an already-started effect remains available to record its outcome.
Errors and aborts commit no transcript; their billed cost remains real.
Resume, model reseat, export, boundary mapping and effect disclosure share the pure structural history
projection in @relavium/shared; existing core exports remain available. completedSessionTurns
selects its completed exchanges; resumableMessageSequences and resumableTurnBoundarySequences
supply the matching host boundary seeds. A tool-call row
with preamble text is never a terminal, and an unfinished exchange rolls back. An empty-final turn
restores its user message alone and counts as a completed turn. Compaction/trim boundaries retain
whole turns; /trim still takes message units. Export reads all historical completed turns,
including those superseded by a working-context boundary. The engine's internal projection-event
emit boundary requires keptTurnCount for both compaction and trimming, so current producers cannot
fall back to message count. The public event schema still accepts older events without that additive
field; the persister's legacy compatibility does not weaken the current producer requirement.
Legacy empty-final compatibility. Before explicit empty terminals, the writer could persist a successful empty final as a user row alone. A bare user immediately followed by another user retains its text in resumed/reseated context, matching the old projection. It prefixes the next completed export prompt and has its own compaction/trim boundary slot. It does not create a terminal row or add to the reconstructed hard turn cap. The final bare user still rolls back, and interrupted structural exchanges stay excluded. Legacy prefix text without a later terminal remains resumed context and export metadata; it does not invent an exported node. Explicit empty-terminal turns retain their ordinary distinct identity. No historical row is rewritten. The legacy rollback is a read projection: a later append can make a previously trailing bare user nontrailing, preserving the old archive's ambiguity rather than permanently deleting its text.
Relationship to the run
messagestable. A session's messages are persisted insession_messages, bound to a session — distinct from the existing per-step runmessagestable, which is bound to astep_executionsrow within a workflow run. The two are deliberately separate (different lifecycle and FK parent); see the table definitions in database-schema.md. They share a shape family but must not be merged, to avoid coupling the session and run persistence stories.
A session uses the same tool surface as a workflow agent: the built-in ToolRegistry
(built-in-tools.md), the same FS-scope tiers, and the same
mandatory guardrails (run_command allowlist; git_commit behind approval). Per
ADR-0029:
- a session inherits the agent's tools and may only narrow them, never escalate;
- a
secret-typed value is never interpolated into a prompt or tool text; context.variables(the{{ctx.*}}map) is plaintext supplied by the surface that is echoed verbatim in thesession:startedevent payload and persisted in the session row — it MUST NOT carry an API key or any secret. Provider keys and MCP named credentials use their keychain-backed resolvers. Workflowsecretinputs have a separate caller-supplied admission contract; this does not authorise putting resolved credentials in sessioncontext.variables;http_request/ MCP egress is subject to the same SSRF policy as a workflow.
The user's own conversational content typed into a session is the user's data: it is persisted in
the history.db (on the CLI surface, unencrypted at rest, guarded by 0600/0700 OS permissions per
ADR-0050; the desktop's SQLCipher-encrypted store
is a separate surface) and is not a managed secret — this boundary is stated in
security-review.md.
A session emits on the session:* namespace — its single canonical home is
sse-event-schema.md, which defines the SessionEvent
union, the shared base envelope, and sequenceNumber gap-detection. The steering events
(agent:directive_injected / agent:context_compacted / agent:context_cleared) are reserved
in Phase 1; the steering channel narrative lives in
agent-sessions.md.
Per ADR-0026, a session exports to a
.relavium.yaml scaffold that the author reviews before committing:
- completed logical turns become a linear chain of
agentnodes, in order, carrying the agent binding, resolved prompts and the deduplicated union of resolved structural tool names, per ADR-0095 §1 and §3. An empty terminal assistant text still completes a turn; tool preambles do not create extra nodes; - the full transcript is preserved in the workflow's durable
metadatafield, including content-free tool structure and user/terminal assistant text. It survives parse → serialize round-trips. Tool arguments/results are excluded, while user-authored and assistant text can contain sensitive content. Prompt interpolation neutralisation does not redact that text; - parallel / conditional / loop structure is not auto-inferred — the author adds it on the canvas.
Successful CLI chat-export writes a fixed continuity/review notice to stderr, including
in JSON mode. It also warns that historical agent snapshots may need edits to pass current
authored validation before running. Live /export renders the same notice in the interactive chat/Home transcript
and uses stderr on plain/JSON drivers; the machine event stream is unchanged. A scaffold does not recreate the
session's working context: summarised, trimmed or omitted context may not transfer, and no raw
model-issued tool arguments/results are available to restore. Historical persisted turns and
content-free tool structure are mapped as below. Retained user/assistant text, @ attachments,
! output and authored agent literals still require review before running or committing. The
notice does not redact them, and there is no raw-tool-content export opt-in.
The export produces the format owned by workflow-yaml-spec.md; the
mapping (session turn → agent node, transcript → metadata) is the contract owned here. The
desktop "Export to Canvas" affordance and the CLI relavium chat-export both drive this one contract.
Precise mapping (1.Z). Given a loaded AgentSessionRecord + its ordered SessionMessage[], the
exporter builds a WorkflowDefinition deterministically (no wall-clock / randomness, so the artifact is
reproducible; snapshots satisfying current authored validation round-trip):
- Nodes — a single
inputnode (id: input), then oneagentnode per COMPLETED logical turn insequenceNumberorder (id: turn-1,turn-2, … — 1-based), then oneoutputnode (id: output). A logical turn begins at its user row and ends at its terminal assistant text part (empty text counts). Preserved nontrailing legacy bare-user text prefixes the next completed prompt; it creates no extra node. Structural tool preambles/results do not create nodes. An interrupted exchange is omitted from the chain and remains in the full metadata. Eachagentnode carriesagent_ref= the session'sagentSlug;prompt_template= the user text, with interpolation openers neutralised (omitted if empty);tools= the deduplicated union of resolved names in its structural calls, including admitted MCP ids. The fixed unresolvedunknown_toolmarker never becomes a grant. Nomodel/temperature/max_tokens/retry/output_schemaare emitted — those are authoring concerns the user adds on the canvas, not replay fields. - Edges — a straight linear chain
input → turn-1 → … → turn-n → output(just{ from, to }); when a session has no completed turn the chain isinput → output. No parallel/conditional/loop edges (ADR-0026). - Workflow
id— deterministic ASCII kebab-case, matchingkebabIdSchema. The title undergoes NFKD compatibility decomposition, combining-mark removal, lowercase and explicit dotless-i (ı→i) and sharp-s (ß→ss) mappings; other non-ASCII runs become separators. The title slug is limited to 128 characters with no trailing separator. This is not universal transliteration. An absent or unusable title usesexported-session-<session-id>when the session id is legal lowercase kebab-case and at most 111 characters (ordinary UUIDs survive unchanged). Other external ids useexported-session-id-plus four-digit hexadecimal UTF-16 encoding of their first 24 code units. Generated workflow ids are bounded and renameable, not guaranteed unique: normalisation and truncation can collide. Full original identity stays in metadata; the default export filename still uses the session id, not the generated workflow id. agents— the session's frozenagentSnapshot(an inlineAgent) is emitted as the soleagents[]entry soagent_refresolves. Historical snapshots retain their ordered fallback entries and attempt budgets even when current authored admission rejects them (for example, repeated model/provider tuples). Such a scaffold requires deliberate author repair before it parses/runs; export never silently deduplicates the frozen agent or rewrites history. When no snapshot was captured,agentsis omitted andagent_refresolves against the workspace agent registry at author time (the file still parses —agent_refresolution is the engine's job, not the schema's).metadata— the full persisted transcript, including content-free tool structure, under a single reserved key:metadata.relaviumExport = { source: 'session', sessionId, agentSlug, title?, createdAt, updatedAt, messages: SessionMessage[] }. It is a real schema field (z.record), so it survives parse → serialize round-trips.- Determinism + exclusions — the YAML emitter (1.Z,
serializeWorkflow; 1.L is parse-only) sorts map keys alphabetically and preserves array order, soparse → serializeis byte-stable. The strictSessionContentParttranscript refuses raw model-issued tool arguments/results and continuation signatures (ADR-0095). User conversational text remains user data in the export.
- Validated against
AgentSessionSchema/ strictSessionMessageSchema/SessionContextSchema(Zod, in@relavium/shared) — invalid input fails fast, like every other authored/runtime contract (ADR-0023). - Persisted in the global
history.db(agent_sessions+session_messages; on the CLI surface unencrypted at rest,0600/0700-guarded per ADR-0050); the DDL is canonical in database-schema.md. Credential retrieval uses the OS keychain and does not copy application-held keys into rows, messages or event payloads (keychain-and-secrets.md); this is distinct from the sensitive content a user can supply in their conversation.
The session store also validates every denormalized metadata field against the canonical body on
write and read. A text projection must equal its canonical text; structural toolCalls must equal
the canonical call parts; name and toolCallId must match the appropriate single part; finishReason
is a fixed stop-reason value on an assistant row. Unknown fields and malformed JSON are refused with
fixed boundary errors. This prevents metadata from becoming a second tool-content channel. User
conversational text, including ! output and @ file injection, remains user data at rest.
A shipping persisted host attaches a required usage writer independently of its optional cost governor. Fresh creation alone establishes a complete-zero basis; resume/reseat explicitly adopt the existing owner and preserve pending, unknown and historical uncertainty. Pending acknowledgement precedes conversational, tool-round and summariser invocation. Complete actual money and coverage resolve atomically through one direct writer, rather than a second bus subscriber. The CLI waits for retained producers and accounting writes before closing that writer or SQLite. One-shot agent runs use an opaque hidden owner without a transcript or resume privilege.
Public cost completion follows that direct writer's acknowledgement. The persisted host refreshes the amount, missing-price count and coverage together at accounting and lifecycle boundaries, including the next turn after resume/reseat; construction-time counters cannot overwrite a newer durable sum. Content frames reuse the cached projection. The event contract owns the acknowledgement, projection-failure and uncertainty rules.