Skip to content
Pilotoo
v0.1

Running workflows

A run is one execution of a workflow with an input. It ends finished (with an output) or failed, or it pauses until someone answers a question. This page shows how to start a run, follow it and answer it, first through the server’s REST API and then with the runtime directly.

Terminal window
curl -X POST $API/workflows/$ID/runs -H 'content-type: application/json' \
-d '{"input":{"orderId":"A-1001"}}'

The answer is 201 with the run as running. Read it back until it settles:

Terminal window
curl $API/workflows/$ID/runs/$RUN_ID
{ "id": "run_...", "workflowId": "wf_...", "status": "finished", "output": { "result": "Paged on-call" }, "error": null, "interrupts": [] }

From a React app, createWorkflowsClient from @pilotoo/chat-core wraps the same routes:

import { createWorkflowsClient } from '@pilotoo/chat-core';
const workflows = createWorkflowsClient(apiUrl, fetch, { credentials });
const run = await workflows.start(workflowId, { orderId: 'A-1001' });
const settled = await workflows.run(workflowId, run.id);
const events = await workflows.events(workflowId, run.id);

Failures throw a WorkflowError with a kind: invalid, forbidden, not-found, conflict, unavailable or http.

Status Meaning
running In progress
paused Waiting on one or more interrupts; interrupts is non-empty
finished Done; output holds the named values from output nodes
failed A node failed after its retries, or the run hit maxSteps; error holds the message
cancelled The server stopped it (a workflow delete, a shutdown)

Every run keeps its events. Read them in order:

Terminal window
curl $API/workflows/$ID/runs/$RUN_ID/events
Event Emitted
RUN_STARTED When a run starts, and again on each resume
NODE_STARTED Before a node executes
NODE_FINISHED After it succeeds
NODE_FAILED After each failed attempt
RUN_PAUSED When the run waits on interrupts
RUN_FINISHED On success
RUN_FAILED On failure

The server stores each as a run event with the WORKFLOW_ prefix (WORKFLOW_NODE_FINISHED, and so on), stamped with at.

To follow a run live, join its socket room and listen for workflow:event. The demo web app does this in a small event source:

socket.on('connect', () => socket.emit('workflow:watch', { workflowId, runId }, () => undefined));
socket.on('workflow:event', ({ run, event }) => render(run, event));

run is the run as it is after that event. Details are in Server integration.

Two kinds of node pause a run: approve, which asks a person, and agent, when the agent asks something.

An approval is a node with a message:

{ "id": "ok", "type": "approve", "config": { "message": "Refund {{ $input.amount }} to the customer?", "data": { "orderId": "{{ $input.orderId }}" } } }

The run stops at it. Its status is paused and interrupts lists the pending question:

{ "status": "paused", "interrupts": [{ "interruptId": "int_...", "nodeId": "ok", "payload": { "message": "Refund 40 to the customer?", "data": { "orderId": "A-1001" } } }] }

Answer it by id:

Terminal window
curl -X POST $API/workflows/$ID/runs/$RUN_ID/resume -H 'content-type: application/json' \
-d '{"interruptId":"int_...","answer":{"approved":true}}'

The answer is 200 with the run as running. The run leaves the approve node through approved for true or { approved: true, ... }, and through rejected for anything else. The whole answer becomes the node’s output.

Node Pause payload Resume answer
approve { message, data } from its config true or { approved: true, ... } leaves through approved; anything else leaves through rejected
agent { kind: 'agent', agentId, interrupts }, the agent’s own pending decisions { interruptId, answer } naming one of the agent’s pending interrupts

When several branches pause at once, each has its own interruptId. A resume answers one interrupt by id. The others stay pending, and the run stays paused until none are left.

A run acts as whoever started or resumed it. The request’s credentials are lent to that run’s nodes, so an agent or tool node sees the same identity it would in a chat turn. Runs started by a webhook carry no credentials.

A run has a step budget, maxSteps, which defaults to 1000. A run that exceeds it fails.

Hosts using @pilotoo/server do not do this; the server does. Use the runtime when you embed workflows without the server, for example in a script or a test.

import { MemorySaver } from '@langchain/langgraph';
import { coreNodeKinds, createWorkflowRuntime } from '@pilotoo/workflow';
const runtime = createWorkflowRuntime({
kinds: [...coreNodeKinds, ...hostKinds],
checkpointer: new MemorySaver(),
ports: { fetch, sandbox },
onEvent: (event) => console.log(event),
maxSteps: 1000,
});
const outcome = await runtime.start({ workflow, input, threadId, runId, signal });
if (outcome.status === 'paused') {
await runtime.resume({
workflow,
threadId,
runId: nextRunId,
interruptId: outcome.interrupts[0].interruptId,
answer: { approved: true },
});
}
await runtime.pendingInterrupts(threadId);

start returns { status: 'finished', output } or { status: 'paused', interrupts: [{ interruptId, nodeId, payload }] }. A failed run throws WorkflowRunError (runId, nodeId, the node’s message, cause).

Option Meaning
kinds Node kinds. coreNodeKinds ships the built-ins
checkpointer A LangGraph BaseCheckpointSaver, the same one the agents use. MemorySaver in tests
ports fetch, sandbox, and per-run ports passed to start
onEvent Receives every engine event
maxSteps Supersteps a run may take before it fails as runaway. Default 1000
  • A node with a missing port (fetch for http, sandbox for code) fails the run with a message naming the port.
  • A run has a thread of its own. It is not a conversation and never appears in the conversation list.
  • Deleting a workflow stops its running runs and removes its runs, their events and their checkpoints.