One API
Workflows plane
The workflows plane runs a CNL workflow — one or many agent tasks orchestrated by the engine —
and hands back a pointer to the result. Reach it with an ozk_ key
(Authentication) at POST /v1/workflows/execute (inline) or
POST /v1/workflows/:id/execute (saved). Base URL is always https://api.plungeai.com.
Source: orchestration/api-gateway/routes/workflows.ts, engine orchestration/cnl-engine/.
Where the Agents plane runs one task, the workflows plane runs a whole DAG:
parallel fan-out, sequential stages, data flow between tasks, and bounded autonomous
type: harness missions.
10.1 CNL in one page
CNL (the YAML workflow language) has a workflow with a name and a tasks[] array. Each task
is either a leaf task or a compound block; a top-level input: (sibling of workflow:) seeds the
run. Field names are from orchestration/cnl-engine/context.ts, schema-types.ts and the real
files in workflows/*.yaml.
| Field | On | Meaning |
|---|---|---|
name | workflow | workflow name |
tasks[] | workflow | ordered list of tasks/blocks |
type | task | task | parallel | sequential | harness | dynamic | batch | debate (the TaskType enum also has the legacy agent_loop and internal output / validate) |
id | task | task id — the key its output is stored under |
agent | leaf task | the agent id to run (must be status:active, or the run is fenced 404/403) |
prompt | leaf task | the task input; search agents take the literal query |
operation | leaf task | a named operation for a structured tool-agent (e.g. serpapi-agent operation: amazon) |
| (agent params) | leaf task | any extra card parameters as task fields (e.g. num: 15, searchType: shopping) |
subtasks[] | compound block | children of a parallel / sequential / … block |
Data flow. There is no depends_on — a reference is the dependency:
{input}injects the workflow-level input.{data:<task_id>}injects a prior task's stored output into an LLM prompt. Asequentialblock runs itssubtasksin order; aparallelblock runs them at once (Promise.all, no width cap — see the engine README "Parallelism"). Reference an upstream task and the engine orders around it.
A real 3-task workflow — a parallel search fan-out feeding one LLM synthesis (adapted from
workflows/amazon-price-comparison.yaml):
workflow:
name: price-check
tasks:
- type: parallel
id: search_phase
subtasks:
- type: task
id: amazon_search
agent: serpapi-agent
operation: amazon
prompt: "{input}"
num: 15
- type: task
id: shopping_search
agent: serper-agent
searchType: shopping
prompt: "{input}"
- type: task
id: recommendation
agent: one-agent
prompt: |
For the search "{input}", pick the top 3 products.
Amazon results: {data:amazon_search}
Shopping results: {data:shopping_search}
Use only links that appear verbatim in the data.
input: "wireless headphones"10.2 Inline execute
POST /v1/workflows/execute runs one body in three sizes (routes/workflows.ts): a bare agent call,
a workflow: document, or the JSON twin of either. Content-Type decides YAML or JSON; both produce
the same object.
A bare agent call — agent: plus the agent's own fields, exactly as its README documents them.
The door lifts it to a one-task workflow ({ workflow: { name: <agent>, tasks: [{ id: "call", type: "task", agent, …fields }] } };
a pre-built agent becomes a harness task with the prompt as its goal). Top-level format:, input:
and inputs: stay the wire's, as in every other body; stream:, sync:, dry_run: and metadata: are
wire keys too and never reach the agent.
curl -s -X POST "https://api.plungeai.com/v1/workflows/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: text/yaml' --data-binary @- <<'EOF'
agent: calculator-tool-agent
expression: "7*6"
format: markdown
EOFThe JSON twin is {"agent":"calculator-tool-agent","expression":"7*6"} → the JSON envelope below with
the result in content. Two refusals guard the lift: agent: next to workflow:/tasks: →
400 ambiguous_body (send one); a field named like an engine task key (id, type, agent,
depends_on, mission_ref) → 400 reserved_field naming the fields. An unknown agent id →
404 unknown_agent here (on POST /v1/execute, 16.9, the same body is
404 agent_not_found). Every execute door refuses a body it cannot read, on the negotiated wire:
malformed JSON → 400 invalid_json (with the parser's position), malformed YAML → 400 invalid_yaml,
a scalar or list root → 400 invalid_body; an empty body is missing (missing_workflow,
missing_prompt, invalid_params), not malformed.
A params: wrapper. On the one door, POST /v1/execute (16.9), the fields may also
come wrapped in params: {…}, the shape the tools plane uses: the door unwraps them into the agent's fields, and a
key inside params wins over a bare field of the same name (routes/execute.ts). The wrapper carries agent fields
only. params naming agent, workflow, tasks, on_behalf_of or a wire key (format, input, inputs,
user_context, stream, sync, dry_run, metadata) is 400 invalid_body, because those keys stay at the root.
A params that is not an object stays an ordinary field. Live-verified 2026-10-14:
curl -s -X POST "https://api.plungeai.com/v1/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: text/yaml' --data-binary @- <<'EOF'
agent: calculator-tool-agent
params:
expression: "7*6"
format: markdown
EOF
# → # Calculator: 7*6 … ## 2. Result 42A workflow — the full workflow: document as a text/yaml body, or JSON
{ "workflow": <object | YAML string>, "input"?: any, "inputs"?: any }; a JSON body with top-level
name/tasks auto-nests under workflow. A body with none of workflow, agent or tasks →
400 missing_workflow.
Every execute body — inline, saved (10.4) and both execute-stream variants — also takes:
| Field | Type | Meaning |
|---|---|---|
user_context | object · optional · default: derived from your request's network location (time zone, approximate place) | the caller's time zone, locale and location ({time_zone, locale, place, latitude, longitude, accuracy_m, source}, the UserContext schema); the agent uses it for local time and as the default schedule time zone. A wire key — it never reaches a bare agent call's fields. A raw YAML workflow body always takes the network default |
Success carries the pointer and, once the sync run has stored it, the final task's content: { success: true, status, workflow_id, final_task_id, request_id, content? }. status is "completed", or "incomplete" when the final task stopped at its turn budget before task_complete — the stored result is then partial (the results route returns it with a [run stopped at the N-turn budget …] note), and the execution row carries outcome: incomplete.
The result route (10.5) reads the same content back later, on any wire.
curl -s "https://api.plungeai.com/v1/workflows/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
-d '{"workflow":{"name":"doc-demo","tasks":[{"type":"task","id":"t1","agent":"one-agent","prompt":"Reply with only the word: pong"}]},"input":"go"}'The same run as a raw YAML body (the response mirrors the request Content-Type → YAML, see 10.6):
printf 'workflow:\n name: doc-demo-yaml\n tasks:\n - type: task\n id: t1\n agent: one-agent\n prompt: "Reply with only the word: pong"\n' \
| curl -s "https://api.plungeai.com/v1/workflows/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: text/yaml' --data-binary @-10.3 Streaming — execute-stream
POST /v1/workflows/execute-stream (same body shapes) forces the engine to stream and relays every
engine SSE frame verbatim, plus a relay-injected event: token per provider delta
(lib/stream-relay.ts workflowsEncoder). This is the named-event stream (distinct from the
OpenAI-shaped agent stream in Streaming). Event vocabulary (context.ts
CNLEventType):
| Event | When | Payload (data:) |
|---|---|---|
request_received | run accepted | request metadata |
workflow_loaded | workflow parsed | workflow name/id |
workflow_started | execution begins | workflow id |
task_dispatched | a task is sent to its agent | {task_id, agent, streaming?} |
streaming_started | a task's provider stream opened | {task_id, stream_url} |
token | a provider delta (relay-injected) | {task_id, delta} |
streaming_completed | a task's provider stream closed | {task_id} |
task_completed | a task finished | {task_id, …} |
task_error | a task failed | {task_id, error} |
output_delivered | a task output was stored | {task_id} |
parallel_start / parallel_complete | a parallel block boundary | block id |
sequential_start / sequential_complete | a sequential block boundary | block id |
batch_* / debate_* / dynamic_* | block-specific progress | block payload |
condition_evaluated | a task condition was resolved | {result} |
heartbeat | idle keepalive | — |
workflow_completed | outcome (success) | {final_task_id, execution_summary} |
workflow_error | outcome (failure) | {error} |
workflow_result | last frame, after the outcome | {final_task_id, …} |
The outcome frame is workflow_completed (or workflow_error); workflow_result is always the last
frame, on success and on failure — a client that closes on the outcome frame misses it. Live-verified
2026-10-04 the stream carried: request_received, workflow_loaded, workflow_started,
task_dispatched, task_completed, workflow_completed, workflow_result.
printf 'workflow:\n name: stream-demo\n tasks:\n - type: task\n id: t1\n agent: one-agent\n prompt: "Reply with only the word: pong"\n' \
| curl -sN "https://api.plungeai.com/v1/workflows/execute-stream" \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: text/yaml' --data-binary @-10.4 Saved workflows
A saved workflow has an id in the engine's KV (workflow:{user}:{id}). Create one in Studio or
over the MCP plane (plungeai_build_workflow from a goal, or plungeai_workflow
action: create). Then run it by id:
# not run here — requires a saved workflow id you own
curl -s "https://api.plungeai.com/v1/workflows/<workflow_id>/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
-d '{"input":"quarterly numbers"}'Body: { "input"?: any, "inputs"?: any }. An unknown id → 404 workflow_not_found. A streaming
variant POST /v1/workflows/:id/execute-stream exists with the same body and the 10.3 event
vocabulary.
10.5 Results route
GET /v1/workflows/results/:workflowId/:taskId → { content, content_type, workflow_id, task_id },
with the same reconciliation as the agents plane: 404 not_ready (in flight), 409 execution_failed, 422 empty_completion.
RESP=$(curl -s "https://api.plungeai.com/v1/workflows/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
-d '{"workflow":{"name":"res-demo","tasks":[{"type":"task","id":"t1","agent":"one-agent","prompt":"Reply with only the word: pong"}]}}')
WF=$(printf '%s' "$RESP" | sed -n 's/.*"workflow_id":"\([^"]*\)".*/\1/p')
FT=$(printf '%s' "$RESP" | sed -n 's/.*"final_task_id":"\([^"]*\)".*/\1/p')
for i in 1 2 3 4 5 6 7 8; do
R=$(curl -s "https://api.plungeai.com/v1/workflows/results/$WF/$FT" \
-H "Authorization: Bearer $PLUNGE_API_KEY")
case "$R" in *'"content"'*) echo "$R"; break;; *) sleep 2;; esac
done10.6 Response formats
One negotiated payload, four wires (lib/format.ts): json (default), yaml, markdown, text.
Precedence: body.format / ?format= → Accept header → the request Content-Type mirror
(text/yaml → yaml, else json). An explicit unknown format value → 400 invalid_format; an
unknown Accept type falls through to the mirror. markdown renders through the same renderer the
MCP plane uses; text is the bare result content.
On the YAML wire the envelope fields travel inside the document: top-level format:, input: and
inputs: next to workflow: in a text/yaml body (inline execute and execute-stream; the saved-workflow
execute takes the same three in a YAML or JSON body) mean exactly what the JSON frame's fields mean —
format has body precedence, input is substituted for {input}, inputs is the named map — and are
stripped before the engine sees the workflow (routes/_util.ts splitRequestEnvelope).
Every wire carries the final task's content once the sync execute routes have stored it: markdown
and text answer with it as the body (the pointer keys still render when it is not stored yet); json
and yaml add it as content next to the unchanged pointer fields.
10.7 The type: harness task
A type: harness task with agent: one-agent binds an inline mission onto one-agent's loop mode — a
bounded ReAct agent with a tool fence, iteration cap, long-term memory, and capability injection. All fields and
a full example are in Skills, plugins & capability injection;
in short:
workflow:
name: research-brief
tasks:
- type: harness
id: brief
agent: one-agent
goal: "One-paragraph brief on the latest Anthropic model; every claim a source URL."
mission: "You are a research analyst. Produce sourced, factual briefs."
allowed_tools: [web_search, web_fetch, task_complete]
success_criteria:
- "Every factual claim is followed by a source URL."
max_iterations: 8
skills: [research]10.8 Legacy aliases, limits & headers
Deprecated aliases (same auth, same workflow-plane handlers; prefer /v1/workflows/*):
POST /v1/cnl/execute, POST /v1/cnl/execute-stream (api-gateway.ts).
POST /v1/execute is not an alias. It is the Agent Call door, level 3 of the API
(The one door). It still takes every inline body of 10.2 (a workflow:, root
tasks:, a bare building-block agent) and adds agent: <saved id>[@n], dry_run and stream. An
unknown agent: there is 404 agent_not_found; a task inside a workflow: body that names an unknown
agent is still 404 unknown_agent (below). The level 2 routes of this chapter are unchanged.
Limits (see Errors, limits & headers for the full table):
- Body cap:
MAX_REQUEST_SIZE, default 1 MiB →413 payload_too_large. - Replayed
x-trace-idUUID →409 duplicate_execution_id(send a fresh UUID or omit the header). parallelfan-out has no engine-side width cap (Promise.all); throughput is bounded by the downstream agent service, not the engine.400 invalid_workflowfor a malformed CNL document;404 unknown_agent/403 agent_not_activewhen a task names an unknown or non-active agent (fenced before the run opens).- Every response carries
x-request-id(= the trace id); async/YAML/markdown responses also carryX-Execution-Id. See Traces & observability.
10.9 Cancelling a run
POST /v1/workflows/executions/:id/cancel stops a running execution. Cancellation is
cooperative (core/agent-core/cancel.ts, routes/workflows.ts): it writes a marker in
SharedMemory that the engine reads between tasks and the harness loop reads at the start of each
turn and before each tool batch, then stamps the row cancelled. An in-flight model or tool call
is not interrupted — the run stops at the next boundary, so cancellation may lag one turn.
Because POST /v1/workflows/execute is synchronous (it returns when the run is done), cancel is for
runs you can reach from another request. Pass your own x-trace-id (a UUID) on the execute call —
it becomes the execution id — and use that id here from a second client.
| Response | Meaning |
|---|---|
200 { success: true, cancelled: <id>, markers: [<id>, …] } | Marker written under each run id (row id + the engine's cln id when it differs); row stamped cancelled. |
404 not_found | No such execution for this key. A run owned by another key answers 404 too — the API never confirms a foreign run exists. |
409 not_running | The execution already finished, failed, or was cancelled. |
This example mutates state (it cancels a run), so it is not auto-verified:
# Start a run with a trace id you choose, then cancel it from elsewhere with that same id.
TRACE=$(uuidgen)
curl -s "https://api.plungeai.com/v1/workflows/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
-H "x-trace-id: $TRACE" \
-d '{"workflow":{"name":"cancel-demo","tasks":[{"type":"task","id":"t1","agent":"one-agent","prompt":"Count slowly to a thousand."}]}}' &
curl -s -X POST "https://api.plungeai.com/v1/workflows/executions/$TRACE/cancel" \
-H "Authorization: Bearer $PLUNGE_API_KEY"
# → {"success":true,"cancelled":"<TRACE>","markers":["<TRACE>"]}Over MCP the same action is plungeai_executions with action: "cancel" and the execution_id
(orchestration/mcp-gateway/tools.ts): a foreign or unknown id answers Execution not found., a
finished run answers that it is not running, and a running one is stopped at its next boundary.