NOUKAI

Step-Through Execution

Drive a published flow one step at a time — inspect, edit, and continue. The pattern, the loop, and full client code.

Some flows you want to run end-to-end (/execute). Others — debugging sessions, human-in-the-loop UIs, "let me edit step 2's output and re-run step 3" — need to pause between steps. That is what POST /seq/{org}/{project}/{flow}/step does.

This guide walks through the pattern, the client loop, and the two override planes. For the protocol-level spec see Step-Through Execution API.

Mental Model

   ┌────────────── client (holds cursor + outputs) ──────────────┐
   │                                                             │
   │  state = { executionId: null, cursor: 0, accOuts: {} }      │
   │                                                             │
   │  POST /seq/.../step                                         │
   │  { stepIndex: 0, message }                                  │
   │                       ▼                                     │
   │  ┌──── server (stateless per call) ────┐                    │
   │  │ resolve slug → tree → steps[]       │                    │
   │  │ run steps[0]  ──► SSE stream        │                    │
   │  │ emit step_paused(nextStepIndex=1)   │                    │
   │  └─────────────────────────────────────┘                    │
   │                       ▼                                     │
   │  client merges outputs, bumps cursor:                       │
   │    accOuts[A] = output                                      │
   │    cursor = 1                                               │
   │    executionId = X                                          │
   │                                                             │
   │  POST /seq/.../step                                         │
   │  { executionId: X, stepIndex: 1, accOuts: {A: …} }          │
   │  ... repeat until run_completed ...                         │
   └─────────────────────────────────────────────────────────────┘

Three things to internalize:

  1. The server is stateless between calls. It resolves the flow tree, executes one step, streams the SSE events, then drops everything. Your client is the source of truth for where you are in the flow.
  2. You always send everything you've collected so far. Each call POSTs the full accumulatedOutputs map. The server uses it to derive the next step's pipeline_input.
  3. The cursor is the request, not a server-side handle. The executionId only ties the run to a FlowRun row for tracking and billing. It is not a session you can lose.

When to Use This Endpoint

You want to…Use
Get a final result, end-to-end/execute
Long-running, polled/jobs
Show each step's output in a UI before continuing/step
Edit a block's prompt mid-run and re-execute downstream/step with blockOverrides
Replay a flow with one step's output replaced/step with inputOverrides
Run from step N to the end on demand/step with runRemaining: true

The Two Override Planes

These are independent and frequently confused.

inputOverrides — replace a prior step's output

Use when you want to lie to a downstream step about what an earlier step produced.

Steps: A → B → C → D

After step 2 the user dislikes B's output. They edit it client-side
and put the edit in inputOverrides on the next request:

         normal flow                    overridden flow
A.out:   { txt: "hi" }                  { txt: "hi" }
B.out:   { tag: "casual" }   ─edit→     { tag: "formal" }   ← inputOverrides[B]
C.in:    pipeline = B.out               pipeline = "formal"-version

blockOverrides — replace a block's configuration

Use when you want to change how a block runs — its prompt, model, or schemas — before executing it.

{
  "blockOverrides": {
    "<step-B-id>": {
      "prompt": "Use a more formal tone.\n{{message}}",
      "model": "anthropic/claude-opus-4-7"
    }
  }
}

You can use both in the same call. They apply at different layers and do not interact.

A Complete Client Loop

The client always does the same thing each turn: POST, parse SSE events, merge outputs, decide whether to continue.

type StepEvent =
  | { event: "run_started";    data: { executionId: string; totalSteps: number; steps: PlanStep[] } }
  | { event: "block_started";  data: { stepId: string } }
  | { event: "block_completed"; data: { stepId: string; output: unknown; durationMs: number } }
  | { event: "step_paused";    data: { executionId: string; completedStepIndex: number; nextStepIndex: number } }
  | { event: "step_progress";  data: { executionId: string; completedStepIndex: number; nextStepIndex: number } }
  | { event: "run_completed";  data: { runId: string; status: "completed" | "failed"; error?: string } };
 
interface PlanStep {
  index: number;
  blocks: { stepId: string; blockName: string; processorType: string }[];
  isParallel: boolean;
  isLoop: boolean;
}
 
interface SessionState {
  executionId: string | null;
  cursor: number;
  accumulatedOutputs: Record<string, unknown>;
  plan: PlanStep[] | null;
}
 
async function* stepOnce(
  flowPath: string,
  state: SessionState,
  body: Record<string, unknown>,
): AsyncGenerator<StepEvent> {
  const res = await fetch(
    `${process.env.NOUKAI_API_URL}/api/v1/seq/${flowPath}/step`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.NOUKAI_API_KEY}`,
        "Content-Type": "application/json",
        Accept: "text/event-stream",
      },
      body: JSON.stringify({
        executionId: state.executionId,
        stepIndex: state.cursor,
        accumulatedOutputs: state.accumulatedOutputs,
        ...body,
      }),
    },
  );
 
  if (!res.ok) {
    const detail = await res.json().catch(() => ({}));
    throw new Error(`step ${res.status}: ${JSON.stringify(detail)}`);
  }
 
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
 
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
 
    let sep: number;
    while ((sep = buffer.indexOf("\n\n")) !== -1) {
      const raw = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);
 
      let event = "message";
      let data = "";
      for (const line of raw.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += line.slice(5).trim();
      }
      if (data) yield { event, data: JSON.parse(data) } as StepEvent;
    }
  }
}
 
function applyEvent(state: SessionState, ev: StepEvent): SessionState {
  switch (ev.event) {
    case "run_started":
      return { ...state, executionId: ev.data.executionId, plan: ev.data.steps };
    case "block_completed":
      return {
        ...state,
        accumulatedOutputs: { ...state.accumulatedOutputs, [ev.data.stepId]: ev.data.output },
      };
    case "step_paused":
    case "step_progress":
      return { ...state, cursor: ev.data.nextStepIndex };
    default:
      return state;
  }
}
 
// Example: run the flow step-by-step, logging each step's output.
async function runStepByStep(flowPath: string, message: string) {
  let state: SessionState = {
    executionId: null,
    cursor: 0,
    accumulatedOutputs: {},
    plan: null,
  };
 
  // First call carries `message`; later calls don't.
  let body: Record<string, unknown> = { message };
 
  while (true) {
    let done = false;
    for await (const ev of stepOnce(flowPath, state, body)) {
      state = applyEvent(state, ev);
      if (ev.event === "block_completed") {
        console.log(`step ${ev.data.stepId} →`, ev.data.output);
      }
      if (ev.event === "run_completed") {
        if (ev.data.status === "failed") throw new Error(ev.data.error);
        done = true;
      }
    }
    if (done) break;
    body = {}; // subsequent calls send only cursor + outputs
  }
 
  return state.accumulatedOutputs;
}

Editing an Earlier Output Mid-Run

If your UI lets the user edit step A's output before continuing, send the edit on the next call via inputOverrides. The server merges it on top of accumulatedOutputs before deriving the next step's input.

// After step A, user edits its output. Now stepping into step B:
{
  "executionId": "11111111-…",
  "stepIndex": 1,
  "accumulatedOutputs": { "step-A-id": { "intent": "password_reset" } },
  "inputOverrides":     { "step-A-id": { "intent": "billing_question" } }
}

You do not need to mutate accumulatedOutputs — keeping the original output there and overriding it on the next call leaves you with a clean "original vs. edited" trail in client state.

Editing a Block Before It Runs

If your UI lets the user tweak block B's prompt before it executes, send blockOverrides on the call that runs B:

{
  "executionId": "11111111-…",
  "stepIndex": 1,
  "accumulatedOutputs": { "step-A-id": { "intent": "password_reset" } },
  "blockOverrides": {
    "step-B-id": {
      "prompt": "Respond in a more formal tone.\n{{intent}}"
    }
  }
}

blockOverrides is re-applied every call — it is not persisted on the server. If you want the override to last across the rest of the session, your client must keep sending it.

Run-To-End From Any Step

Once the user is happy through step N, finish the rest in one shot without further pauses:

{
  "executionId": "11111111-…",
  "stepIndex": 2,
  "accumulatedOutputs": { "step-A-id": {}, "step-B-id": {} },
  "runRemaining": true
}

You'll get step_progress events between steps and a final run_completed.

Loops Are Atomic

A loop step runs all iterations in one call. You cannot pause inside a loop. The cursor pauses between a loop step and whatever comes after it, never partway through the iterations. Plan UX accordingly.

Handling STALE_TREE

If someone re-publishes the flow while a session is mid-run, the step ids in your client-side accumulatedOutputs may not exist in the new tree. The server returns 400 STALE_TREE with the current plan in detail.steps. Recover by:

  1. Show the user that the flow changed.
  2. Reset client state (drop executionId, cursor, accumulatedOutputs).
  3. Start a new session from stepIndex: 0.

To avoid this entirely, pin to a version:

POST /seq/acme/support/triage/v3/step

A pinned version's tree is immutable.

Why does the server re-resolve the tree on every call? It's a small DB read and keeps the server stateless. For human-paced step-through this overhead is negligible. If you ever drive /step programmatically at high QPS, prefer runRemaining: true so one HTTP call covers many steps.

Common Mistakes

  • Forgetting to send accumulatedOutputs on later calls. The server has no memory of prior calls. Without it, step N can't derive its pipeline_input.
  • Sending message on every call. It's required at stepIndex: 0 and ignored after. Sending it later is harmless but a code smell — it suggests the client thinks the server keeps state.
  • Using the unversioned URL for long sessions. A re-publish triggers STALE_TREE. Prefer /v{n}/step for any session that may outlive a deploy.
  • Trying to use v0 (draft). Rejected with INVALID_VERSION. Step- through requires a stable tree.
  • Treating executionId as a session token. It's a FlowRun identifier for billing/tracking. Loss of executionId is recoverable by starting a new run with the same accumulatedOutputs (you'd just lose the original FlowRun's tracking — the data is intact).

See Step-Through Execution API for the full request/response reference.