---
name: flow
description: "Live flow execution snapshot — agent-driven via tools, host-driven via connector_mutate"
version: "0.1.0"
entry: ./adapter.ts
keywords:
  - flow
  - orchestration
  - state-machine
---

The flow connector holds the live `FlowExecution` snapshot for one run and
bridges three ownership roles:

- **Agent** — drives nodes by calling tools (`start_node`, `request_approval`,
  `request_input`, `complete_node`, `skip_node`, `fail_node`, plus the
  read-only `get_state`, `get_globals`, `get_available`, `validate_node`,
  `build_handoff`).
- **Runtime** — resolves available `router` nodes with bounded expressions,
  drains available `function` and `check` nodes through `NodeExecutor`, drains
  available `subprompt` nodes through `SubpromptRunner` on a second, isolated
  driver instance, parks available `gate` nodes through durable approval
  state, and drives available `sub-flow` nodes inline before an agent turn is
  considered.
- **Host** — the runner applies out-of-turn mutations through direct adapter
  methods (`applyApproval`, `applyInput`, `retryNode`, `setAutonomousMode`,
  `cancel`, `hydrate`) in response to platform actions, forwards persistence
  snapshots through `onStateChange`, and serializes permitted agent turns via
  `SessionStimulusBus`.

Every state mutation runs through a single `mutate()` pipeline that clones
the current snapshot, applies the change, recomputes derived fields via
`computeFlowStateFromSnapshots`, rolls up the flow-level status, and fires
the registered `onChange` callback once. Consumers downstream (platform
gateway, frontend hook) see latest-wins snapshots.

Unlike the data connectors in this directory the flow adapter is not
declared in `skaile.yaml` — it is constructed by the runner per session
and bound to the active `FlowDefinition`.

The adapter normalizes authored definitions at its connect/start boundary.
All seven strict v2 kinds — `run.kind: agent`, `run.kind: subprompt`,
`run.kind: function`, `run.kind: check`, `run.kind: gate`, `run.kind: router`,
and `run.kind: sub-flow` — are active. Among legacy definitions, explicit
`type: skill` nodes normalize into the agent shape and exact `type: sub-flow`
nodes with `data.flow` normalize into the active inline-child shape. Other
legacy kinds normalize to false-precondition router placeholders: they are
tracked but never become available, so their outgoing hard flow edges remain
blocking. A selected
available or running agent receives its instruction, resolved
bindings with origins, asset requirements, and completed predecessor
context through `build_handoff`. Functions and checks receive resolved raw
values as environment variables while provenance remains on the executor
request. They inherit the session environment and run from the session
workspace. Configured stdout/stderr captures map only to `fields.<name>`
output keys and remain attached to both completed and failed node executions.
When `control.timeoutSec` fires the runtime aborts that attempt through the
optional second parameter of `NodeExecutor.execute`. `SessionNodeExecutor`
honors it by killing the spawned process *group* (`SIGTERM`, escalating to
`SIGKILL` after a 2s grace) and settling only once the child has exited — and
the drain gates the next deterministic spawn on that settlement, so a `retries`
budget can never put two live processes on one node, even for a command that
ignores `SIGTERM`. The gate is bounded rather than unconditional: an executor
that never honors the abort delays its own retry by that budget instead of
wedging the drain. The spawn is `detached` so the kill reaches the command's own
children; a shutdown sweep group-kills whatever is still live at process exit,
since a detached child no longer inherits the runtime's group signals.
The parameter is optional and ignoring it is supported: an executor that
declares only `(request)` still compiles and behaves exactly as before — the
node still fails on timeout, only its spawned work goes unreaped. The abort is
also fired when an `executionGeneration` mismatch discards the result, but that
point is reached only after the attempt settled, so it releases an executor that
resolves ahead of its work rather than killing a live process: a cancel cannot
interrupt the await itself.
A subprompt receives its interpolated instruction as a plain-text prompt, not
environment variables — see below.

A `check` runs the same deterministic process as a function, through the same
`NodeExecutor` seam, but its exit status is a pass/fail verdict rather than a
plain success/failure. A clean exit inside `successExit` completes the node
exactly like a function — no approval, no agent turn, dark. A clean exit
outside `successExit` is a fail *verdict*: the runtime derives evidence — each
compared binding's value, whether *every compared value was runtime-obtained*
(`"verified"`) or at least one was agent-obtained (`"asserted"` — see
`engine/check-provenance.ts`), and whether the check's inputs could have
observed anything beyond the flow's own authored state — plus the process
record (`runtime`, `command`, `cwd`, `successExit`) so a persisted attempt is
self-describing, and attaches it to the attempt. The taint check is
**transitive**: each executed node persists the origins it resolved
(`NodeExecution.inputOrigins`), so a deterministic node that carries an
agent's output forward stays agent-obtained and cannot launder it. Provenance
is derived and cannot be asserted by flow configuration. A producer completed
before that field existed reads as agent-obtained — fail-closed, so a run that
straddled the upgrade reports `"asserted"` rather than a false `"verified"`.
`provenance` is present only when there was at least one compared value;
`inputs` defaults to `"live"` (the floor for any check that runs a process)
and is `"flow-local"` only when every compared value's origins are all
`literal`/`default`/`flow-input`. A check cannot declare `control.optional` —
rejected at parse time, since an optional verification is close to a
contradiction either way it could resolve.
Once the automatic retry budget (`control.retries`) is exhausted, `onFail:
'fail'` (the alternative) fails the node terminally, while `onFail: 'escalate'`
(the default) parks it at `awaiting_approval` with an `Approval` naming the
failed check, its exit status, and the provenance label when one applies.
Escalation hands the verdict to a human, and both decisions are terminal in
their own direction. A `"rejected"` decision ends the node `failed` — there is
no revision path and no way back to `available`. An `"approved"` decision is an
override: `applyApproval` demands a non-empty `feedback` reason, then completes
the node so its dependents unblock. The override records a decision, not an
observation — the attempt's `CheckEvidence.passed` stays `false` and the
failure stays in `errorHistory`/`outputHistory`; only the node's live `error`
clears, because no `complete` node carries one. The one check the override does
not get past is its own output contract: when a downstream node consumes the
check's declared `contract.output.fields` and the failed attempt does not
satisfy them, `applyApproval` refuses, because completing the node there would
write an execution that fails to hydrate. A spawn failure or a timeout
produced no verdict at all, so it is always a plain node failure, carries no
evidence, and never escalates regardless of `onFail`.

A `subprompt` is `function`-shaped for everything except how the work is
performed and how its output provenance is classified. Instead of
`NodeExecutor`, an available subprompt hands its interpolated instruction —
plus a JSON-response directive when `contract.output.fields` is declared —
to a `SubpromptRunner`. The production implementation, `SessionSubpromptRunner`,
constructs a **second, isolated** `AgentDriver` instance per call: same
backend, provider, resolved model, and credentials as the session driver
(threaded through from the same values the two production wiring sites
already resolved for it — no new credential channel), but no Skaile system prompt, no MCP servers, no connector capabilities, and no access to the session conversation, bounded to a single turn. It does retain the backend's own native tool surface (a coding backend keeps its file and shell tools, rooted at the session workspace) — isolation here means a separate conversation, not a sandbox. The
instance is started, sent one prompt, and killed afterward; isolation comes
from the separate instance, never `resetSession()` (which mutates the shared
session). `run.model` resolves through a pure `resolveModelTier` in
`bridge/src/models.ts`: a configured override first, then the provider's
built-in tier. `default` and an absent tier fall back to the session model;
`small`/`deep` fail the node instead of substituting one, so an explicit
cost or quality request is never silently served by a different model. With no declared output contract the response is
prose, carried verbatim as the summary; with one declared, the response is
additionally parsed as JSON into `fields` and validated through the same
`validateConsumedNodeOutput` seam every other kind uses. The parsed payload is
the whole reply (a code fence around all of it is stripped) or, when that does
not parse, the reply's sole bracket structure — so a narrated
`Here's the classification:\n{…}` still lands in `fields`. Extraction is
fail-closed and never repairs: two candidate values, a stray or unbalanced
bracket, an unterminated structure, a candidate embedded mid-sentence, or a
candidate that does not itself parse all keep the loud node failure rather than
guessing, since a misparse is worse than a clean failure. That guarantee is
**structural, not semantic** — a refusal whose shape is one JSON value with
prose around it (a model that answers "I need more information" and prints the
schema on its own line) is still accepted, because telling it from a real answer
needs the prose read, which this deliberately does not do; a declared contract's
validation is the layer that catches it. The reply stays in
`summary` either way — `build_handoff` does render a predecessor's fields, but
only the ones its `contract.output.fields` declares, capped at 500 characters
per value and 20 fields per predecessor, and a contract without `properties`
publishes none at all. A boilerplate summary would leave every one of those
cases with a completed node and no answer for a consumer that reads it as
context rather than binding it.
There is no corrective reprompt — a subprompt has no conversation to reprompt in — so a
parse or validation failure is an ordinary node failure that consumes
`control.retries`, never fabricated output. `control.optional`,
`control.retries`, and `control.timeoutSec` reuse the identical
`raceWithTimeout` / `executionGeneration` / automatic-retry machinery as
`function` and `check`. Each attempt receives an abort signal, so timeout,
flow cancellation, hydration and disconnect stop the disposable driver and its
in-flight tools before late output can affect flow state. Disconnect and the
next restarted drain also wait for owned child cleanup to finish. A
subprompt's output is classified `obtainedBy: "agent"` — model-generated even
though the *runtime* made the call — so a downstream `check` consuming it
always derives `"asserted"` provenance, never `"verified"`.

A first-class gate never enters the agent mutation surface and never calls
`NodeExecutor`. Admission parks it at `awaiting_approval` with `run.prompt` as
the initial summary. Approval completes it directly and unblocks successors;
rejection keeps the decided record and feedback parked, stimulates one revision
conversation, and lets the agent replace it with a fresh `request_approval`.
Autonomous mode does not bypass a first-class gate. The authored `run.schema`
remains part of the strict contract; richer free-text response transport is
tracked separately in [platform #2097](https://github.com/skaile-ai/platform/issues/2097).

An available router is engine-owned deterministic coordination. Its routes are
evaluated in authored order with the same bounded expression evaluator and
resolved context as preconditions and bindings. Decidable routing advances
through frontier waves before other runtime work, with at most one router from
each source ownership component settling in a wave. Each selected target is
retained in the existing node output for later drains and hydration. The first
match completes the router and skips only hard-flow-exclusive losing nodes.
Selected routes, unsettled-router targets, shared convergence, and nodes with
another live hard predecessor remain live; optional and parallel edges do not
participate in branch pruning. An explicit default handles the
no-earlier-match case, including `target: null`. Missing declared runtime output
waits for its producer, and a valid unmatched router does not starve unrelated
work. Its possible targets and hard-flow-exclusive descendants remain off
runtime and agent-facing availability until selection; nested routers settle in
later frontier waves. Cyclic route ownership remains valid because every member
of a source ownership component is ungated together; normal hard-edge and
precondition admission chooses its available members. Each wave settles the
first available, decidable member in definition order, so unresolved peers do
not starve it and a peer made losing in that wave cannot also complete.
Independent source components may settle together. Route evaluation happens
only after admission and never invokes the session agent, model client, or
`NodeExecutor`.

An inline sub-flow is runtime-owned coordination. The host resolves the child
definition and creates a transient child `FlowAdapter` in the materialized
session, reusing the session driver for agent nodes, the existing `NodeExecutor`
for functions, and the same host approval/input operations for gates. Each parent
sub-flow node keeps its own transient child across parked/no-op turns. Agent
operations route only during that child's active turn; parent-scoped and
unrelated parent operations stay parent-owned. Child gate/input interactions are
projected onto the parent node and settled by its parent node ID, so the child
snapshot is never published. Child completion completes the parent node;
required failure fails it and optional failure skips it. `passContext: true`
merges parent run input before explicit node bindings, while the separate origin
map preserves agent-produced provenance. Inline sub-flows run once and own no run
group, loop, alternate executor, or new durable-state field.
Canonical child identities resolve inside host lookup roots; in-memory ancestry
rejects cycles and nesting deeper than 16 flow identities. Live hydrate cancels
transient children first. Cold hydrate returns a running or projected-waiting
sub-flow to admission and restarts it from the durable parent boundary.

The pure engine evaluates `contract.requires` before admission and reports failed
conditions separately from hard-edge blockers. It resolves routers only after
admission, rather than during state computation. Output schemas are enforced only
when another node reads the producer through a precondition, binding, or router.
The first invalid agent completion persists and publishes one correction request;
the second fails. Function output fails directly, with captured diagnostics kept.
Valid JSON-object fields are cloned before storage. Structural field errors enter
the correction lifecycle only for consumed producers; malformed fields on an
unconsumed prose node are omitted. `connect` and `hydrate` clone incoming snapshots
and atomically reject invalid complete output for a consumed producer.

A terminal failure resolves the same way for every kind: a node marked
`control.optional` becomes `skipped`, everything else `failed`. Only `complete` and
`skipped` satisfy a hard `flow` edge, so an optional node left `failed` would stall
the run at the node the author called expendable. A `router` is the one exception —
skipping it satisfies all of its outgoing edges at once and admits every branch
instead of selecting one — and a `skipped` node is never manually retryable, because
the skip already released its dependents. `outputHistory` still records the attempt
as `'failed'`; the skip is a compatibility status, not a rewritten history. A
consumer binding a skipped producer's `output.fields` fails with a
`FlowExpressionError` naming that producer and carrying it as `skippedProducer`.

## Operations

| Operation | Access | Description |
|-----------|--------|-------------|
| `get_available` | read | Agent node IDs ready for an agent turn |
| `get_state` | read | Full flow execution snapshot |
| `get_globals` | read | Run-scoped flow globals (`run_input` etc.) |
| `validate_node` | read | Is this node currently runnable, including failed preconditions |
| `build_handoff` | read | Selected agent instruction, resolved bindings/origins, correction issues, assets, and upstream context |
| `start_node` | write | Mark a node `running` |
| `request_approval` | write | Pause a running agent node, or re-request a rejected first-class gate, for human sign-off |
| `request_input` | write | Pause for typed input; the schema is coerced onto the `InputSchema` contract and refused, not stored, when it cannot be rendered as an answerable control |
| `complete_node` | write | Finalise a node with optional JSON fields; consumed schemas get one agent correction |
| `skip_node` | write | Skip an optional node |
| `fail_node` | write | Mark a node failed (recoverable or not) |
