Node Flowdocs

The REST API

Authentication, scopes, the shape of every surface, the generated clients, and the Conductor compatibility layer.

Everything is at /v1, except the Conductor compatibility layer, which owns its own paths. The full machine-readable contract is served, unauthenticated, at:

GET /v1/openapi.json

OpenAPI 3.1. It is public deliberately — the document describes the shape of the API, not its contents; every endpoint in it still requires credentials and each carries the scopes it needs. Gating the description behind authentication mostly obstructs the person trying to work out how to authenticate.

There is no Swagger UI on the server. Serving one would pull a static-file plugin back into a JSON API. The document is the contract; the dashboard renders a reference from it, and any viewer can point at that URL.

Path shape

PrefixWhat lives there
/v1/ns/{ns}/…Everything namespaced: definitions, executions, queues, tasks, secrets, schedules, human tasks, integrations.
/v1/auth/…Token exchange, whoami, API keys, service accounts.
/v1/namespacesThe tenants themselves. Needs platform:admin.
/v1/health, /v1/health/live, /v1/health/readyUnauthenticated probes.
/v1/metricsPrometheus. Needs metrics:read.
/v1/hooks/{id}Inbound webhook deliveries. Unauthenticated by design.
/v1/ns/{ns}/webhooks/{token}WAIT_FOR_WEBHOOK callbacks. Unauthenticated by design.
/v1/openapi.jsonThe document.
/conductor/api/…Conductor wire compatibility. Not under /v1.

{ns} is a namespace slug. The server checks it against the caller's own namespace, so a credential cannot reach another tenant by editing the path.


Authentication

X-API-Key: nf_... Principal Authorization: Bearer jwt Session cookie + x-csrf-token mTLS client certificate OIDC workload identity token Every authorization check reads ONLY the Principal:type, id, namespaceId, scopes, tagGrants, resourceGrants
Every mechanism resolves to one Principal

That indirection is the whole point: adding a mechanism touches the authenticator chain and nothing else.

API keys

Long-lived, presented directly. Right for a CLI, a CI job or a script, where a token-exchange round trip on every invocation buys nothing.

curl -X POST "$NF_URL/v1/auth/api-keys" \
  -H "x-api-key: $ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"name":"ci","scopes":["executions:start","executions:read"],"expiresAt":"2027-01-01T00:00:00Z"}'

The key is returned once. expiresAt is optional — no expiry is allowed, but the caller has to say so by omitting it.

curl -H "x-api-key: nf_..." "$NF_URL/v1/auth/whoami"

An API key may be sent four ways, because different clients know how to send different headers:

X-API-Key: nf_...
Authorization: Bearer nf_...
X-Authorization: Bearer nf_...     # what Conductor's SDKs send
X-Authorization: nf_...            # Conductor's header carries no scheme

A value matching nf_… is treated as an API key rather than verified as a JWT, wherever it arrives.

Manage them at GET /v1/auth/api-keys and DELETE /v1/auth/api-keys/{id}.

Service accounts

A key and secret exchanged for short-lived tokens. Right for a long-running worker fleet: verification is a signature check with no database round trip, which matters when workers poll continuously, and a captured token is useless within the hour.

# Create one (needs admin)
curl -X POST "$NF_URL/v1/auth/service-accounts" \
  -H "x-api-key: $ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"name":"charge-fleet","scopes":["queues:lease:charge","tasks:report"]}'

# Exchange it
curl -X POST "$NF_URL/v1/auth/token" \
  -H 'content-type: application/json' \
  -d '{"keyId":"…","secret":"…"}'
{ "accessToken": "eyJ…", "tokenType": "Bearer", "expiresIn": 3600, "scopes": ["queues:lease:charge", "tasks:report"] }

POST /v1/auth/token is the only unauthenticated write in the API — it is the authentication step. It uses a body rather than query parameters, because query strings land in access logs, browser history and proxy telemetry.

A failed exchange gives one message for every failure mode. Distinguishing "no such key" from "wrong secret" hands an attacker a key-enumeration oracle for free.

The TypeScript SDK handles the exchange and the refresh:

new NodeFlowClient({
  baseUrl: 'http://localhost:3000',
  namespace: 'default',
  serviceAccount: { keyId: '…', secret: '…' },
});

Human sessions

curl -c jar -X POST "$NF_URL/v1/ns/default/users/login" \
  -H 'content-type: application/json' \
  -d '{"email":"ada@example.com","password":"…"}'

Sets a session cookie and a nf_csrf cookie. Every subsequent request sends both the cookie and an x-csrf-token header carrying the CSRF cookie's value.

GET /v1/auth/me is deliberately namespace-free: a browser holding a session knows neither its namespace slug nor its scopes until it asks, and every other route needs the namespace in the path.

Other session routes: POST /v1/ns/{ns}/users/logout, /logout-all, /change-password.

The human-task inbox requires a user principal. An API key is refused by design: claiming and completing a task are statements about who did the work, and a service account belongs to a fleet rather than to anyone. A script cannot approve its own refunds.

SSO is at /v1/auth/sso/... (OIDC) and /v1/auth/saml/... (SAML 2.0), both configured through environment variables — see Configuration.

Machine identity without a stored secret

  • mTLSNODE_FLOW_MTLS_ENABLED, when this process terminates TLS itself with a CA bundle. A SPIFFE URI in the certificate's SANs is honoured.
  • OIDC workload identityNODE_FLOW_OIDC_ISSUERS, for GitHub Actions, Kubernetes projected tokens and similar. The issuer is matched exactly; nothing looser is offered.

Bindings are managed at /v1/ns/{ns}/workload-identities.


Scopes

resource:action, optionally ending in :*.

ScopeGrants
workflows:readRead definitions.
workflows:writeRegister and delete definitions, schedules and event handlers.
executions:readRead and search runs, queue depth, worker activity.
executions:startStart runs.
executions:writePause, resume, retry, rerun, terminate, skip, cancel a task, re-run tasks, signal.
queues:lease:<queue>Lease from one queue.
tasks:reportHeartbeat, log and report a task the caller holds.
human-tasks:read / human-tasks:writeThe inbox, and claiming/completing.
metrics:readScrape /v1/metrics.
adminNamespace, service-account, API-key, secret and quota administration. Satisfies every scope below.
platform:adminCreate and list namespaces.

Matching rules

Exact, or by trailing wildcard, and nothing else. No prefix matching, no hierarchy inference: executions:read must never accidentally satisfy executions:readwrite, and a scope language rich enough to be surprising is one that will eventually grant something nobody intended.

queues:lease:charge        matches  queues:lease:charge
queues:lease:charge:*      matches  queues:lease:charge:eu-west
queues:lease:*             matches  every queue
admin                      matches  everything EXCEPT platform:admin
platform:admin             matches  only itself

platform:admin is the one scope admin does not satisfy, and the reason is the whole point of multi-tenancy: a namespace administrator runs their tenant; deciding that another tenant should exist, and seeing the list of who the tenants are, is a different job with a different blast radius. Folding it into admin would mean every tenant's administrator could enumerate every other tenant by name.

An insufficient scope is a 403 naming what is missing, which is safe — the caller already knows who they are — and turns an opaque refusal into something self-service:

{ "error": "insufficient_scope", "message": "missing scope(s): workflows:write", "required": ["workflows:write"] }

Refusals on audited routes are recorded in the audit log, because "who tried to read the production secrets and was refused" is the question an incident starts with.

Tags and resource grants

Beyond scopes, two narrower mechanisms.

Tags restrict; they never grant. An untagged resource is governed by scopes alone. A resource carrying team:payments additionally requires a group whose tagGrants match at least one of its tags. So adding a tag can only narrow access — the mistake it invites is locking yourself out, which is loud. The opposite model fails quietly, because a resource nobody has tagged yet would be reachable by everyone.

Resource grants go the other way: access to one workflow (or a name prefix, or everything carrying a tag, or *) for someone who does not hold the namespace-wide scope.

curl -X PUT "$NF_URL/v1/ns/default/permissions" \
  -H "x-api-key: $ADMIN_KEY" -H 'content-type: application/json' \
  -d '{
    "subjectType": "GROUP",
    "subjectId": "0193c0f1-…",
    "resourceType": "WORKFLOW",
    "resource": "refund_*",
    "access": ["READ", "EXECUTE"]
  }'

resource is an exact name, a prefix*, tag:key:value, or *. access is any of READ, EXECUTE, UPDATE, DELETE — and any access implies reading, since you cannot run or change what you may not see.

The two combine as an OR: the principal holds the scope and can reach the resource's tags, or a grant names the resource. A grant naming a resource reaches it even when it is tagged — naming the one workflow a contractor may run is a more specific statement than a tag rule.


Errors

Every failure has the same envelope, and every error value is a stable code. Branch on the code, not on the status or the message: messages are for humans and will change.

{
  "error": "LIMIT_EXCEEDED",
  "message": "workflow \"nightly\" is at its maxConcurrentExecutions limit",
  "details": { "control": "maxConcurrentExecutions", "running": 1 }
}
CodeStatusMeaning
INVALID_DEFINITION400A definition failed validation or referential integrity.
COMPILATION_FAILED400Structurally valid but could not compile to a blueprint.
EXPRESSION_FAILED400An expression could not be evaluated.
UNKNOWN_TASK_TYPE400
UNKNOWN_TASK_REFERENCE400
INVALID_ARGUMENT400Malformed request — a cursor that does not decode, a bad search term.
NOT_FOUND404
CONFLICT409
TERMINAL_STATE409The execution has finished and takes no more changes.
LEASE_EXPIRED409A worker's write was refused by the fencing check. Its view is stale; nothing is wrong with its authorization.
LIMIT_EXCEEDED429A quota, cap, rate limit or retry budget tripped. Carries Retry-After where one can be computed.
INTERNAL500A bug. Nothing revealing is returned.
unauthenticated401No usable credential.
insufficient_scope403

A 429 always carries Retry-After where a wait can be computed — a client that is told to come back later and not told when comes back immediately.


Definitions

# Register (versions are immutable; re-registering the same version is a conflict)
POST   /v1/ns/{ns}/metadata/workflows

# Compile-check without registering
POST   /v1/ns/{ns}/metadata/workflows/validate

# Convert a BPMN 2.0 process into a draft definition. A DRAFT, never a
# registration: an imported diagram is a starting point someone reviews.
POST   /v1/ns/{ns}/metadata/workflows/import-bpmn   { "xml": "<?xml …" }

GET    /v1/ns/{ns}/metadata/workflows
GET    /v1/ns/{ns}/metadata/workflows/{name}[?version=N]
PUT    /v1/ns/{ns}/metadata/workflows/{name}/tags
DELETE /v1/ns/{ns}/metadata/workflows/{name}?version=N

POST   /v1/ns/{ns}/metadata/task-definitions
GET    /v1/ns/{ns}/metadata/task-definitions
GET    /v1/ns/{ns}/metadata/task-definitions/{name}
DELETE /v1/ns/{ns}/metadata/task-definitions/{name}

# Bundles
POST   /v1/ns/{ns}/metadata/export    { "workflows": ["a","b"] }
POST   /v1/ns/{ns}/metadata/import    { "bundle": {...}, "dryRun": true }

Executions

Starting

POST /v1/ns/{ns}/executions/{name}
{
  "input": { "orderId": "A-1", "amount": 99 },
  "version": 3,
  "correlationId": "order-A-1",
  "idempotencyKey": "order-A-1",
  "idempotencyStrategy": "RETURN_EXISTING",
  "priority": 50,
  "variables": { "stage": "new" },
  "taskToDomain": { "charge": "eu-west", "*": "canary" }
}
{
  "workflowId": "0193c0f1-…",
  "status": "RUNNING",
  "defName": "checkout",
  "defVersion": 3,
  "startedAt": "2026-09-19T14:22:03.112Z"
}
FieldNotes
inputValidated against inputSchema when the definition declares one.
versionOmitted means the latest at start; the run then pins it.
idempotencyStrategyRETURN_EXISTING (default), FAIL (409), FAIL_ON_RUNNING.
priority0–99.
taskToDomainKeys are task definition names or *; at most 100 routes.

Admission control runs before the row is created: a definition at its maxConcurrentExecutions is refused rather than admitted and stalled, because the latter looks identical to a broken worker pool from outside.

Starting and waiting

POST /v1/ns/{ns}/executions/{name}/execute

Same body plus waitForSeconds (default 10, max 60) and an optional waitUntilTaskRef.

{ "workflowId": "0193c0f1-…", "reached": true, "status": "COMPLETED", "output": { "receipt": "txn_abc" } }

On timeout it answers reached: false with the state so far, and the execution continues — a caller that stopped waiting has not cancelled anything.

Reading

GET /v1/ns/{ns}/executions/{id}          # full: tasks, resolved payloads, masking applied
GET /v1/ns/{ns}/executions/{id}/status   # lightweight: no payloads resolved
GET /v1/ns/{ns}/executions/{id}/history  # the ordered event log
GET /v1/ns/{ns}/executions/{id}/tasks/{taskId}/logs
GET /v1/ns/{ns}/executions/overview?hours=24
GET /v1/ns/{ns}/executions/by-correlation/{correlationId}

Searching executions

POST /v1/ns/{ns}/executions/search

POST for a read, deliberately: the filter set is a structured object, and encoding it into a query string means inventing an escaping scheme every client then has to reimplement. A body also keeps correlation ids and workflow names out of access logs.

{
  "status": ["FAILED", "TIMED_OUT"],
  "defName": "checkout",
  "defVersion": 3,
  "correlationId": "order-A-1",
  "idempotencyKey": "order-A-1",
  "workflowId": "0193c0f1-…",
  "excludeSubWorkflows": true,
  "startedAfter": "2026-09-19T00:00:00Z",
  "startedBefore": "2026-09-20T00:00:00Z",
  "finished": true,
  "limit": 50,
  "cursor": "…",
  "q": "status:FAILED workflow:checkout_* input.customer.tier:gold card declined"
}

Pagination is keyset: pass the previous page's nextCursor as cursor. Offset paging would skip and duplicate rows, because this table receives continuous inserts.

The q language

The dashboard's search box, parsed into the same typed filters — so there is still no string DSL reaching SQL, nothing for a client to escape, and a UI search and an API call give identical results.

TermMatches
status:FAILED,TIMED_OUTAny of these statuses.
workflow:checkout / wf:check*A name, or a prefix ending in *.
version:3A definition version.
id:<uuid>One execution.
correlation:inv-7An exact correlation id.
key:order-42An exact idempotency key.
reason:"card declined"Text in the failure reason.
input.customer.tier:goldA value inside the input (JSON containment).
output.approved:true… or the output. Numbers, true, false and null are typed.
is:sub / is:topOnly sub-workflows, or only top-level runs.
is:running / is:finished
anything elseFree text across id, workflow, correlation id, reason, input and output.

Values with spaces go in double quotes. Every term must match. At most 20 terms; a mistyped key is a 400 naming the keys there are, not an empty list that looks like "no such executions".

Where q and a structured field set the same filter, the field wins.

Operating

POST /v1/ns/{ns}/executions/{id}/pause
POST /v1/ns/{ns}/executions/{id}/resume
POST /v1/ns/{ns}/executions/{id}/terminate   { "reason": "…" }
POST /v1/ns/{ns}/executions/{id}/retry       # re-run the failed tasks, same execution
POST /v1/ns/{ns}/executions/{id}/rerun       { "fromTaskRef": "charge" }
POST /v1/ns/{ns}/executions/{id}/run-again   # a NEW execution with the same input
POST /v1/ns/{ns}/executions/{id}/skip-task   { "taskRef": "charge" }
POST /v1/ns/{ns}/executions/{id}/tasks/cancel  { "taskRef": "charge" }
POST /v1/ns/{ns}/executions/{id}/rerun-tasks   { "taskRefs": ["charge"], "cascade": false }
POST /v1/ns/{ns}/executions/{id}/decide      # force an evaluation
POST /v1/ns/{ns}/executions/{id}/signal      { "taskRef": "hold", "status": "COMPLETED", "output": {...} }
POST /v1/ns/{ns}/executions/{id}/replay      { "version": 4 }
POST /v1/ns/{ns}/executions/{id}/messages    { "payload": {...} }
GET  /v1/ns/{ns}/executions/{id}/messages

POST /v1/ns/{ns}/executions/bulk/pause|resume|retry|terminate
     { "workflowIds": ["…"], "reason": "incident 412" }     # up to 1000

retry resumes the same execution from the failed task. run-again starts a different one with the same input, leaving the original readable exactly as it was.

Stopping one task

tasks/cancel marks a running task CANCELED, releases its permits, queue entry and timers, and pauses the workflow in the same transaction. The pause is the point: without it the decider carries on with whatever else was runnable while you are still working out what went wrong.

It is deliberately not a failure. A failure is something your definition handles — it spends a retry, may start the failureWorkflow, may unwind compensation — and an operator stepping in is none of those.

It does not stop your worker. Nothing server-side can reach into the process holding the lease. The lease is released, so the result is refused when it eventually reports, but the work continues until it finishes on its own — and whatever it touches on the way is still touched. Stopping it needs the worker to cooperate, which node-flow does not do for you.

Running specific tasks again

rerun-tasks takes the references and a cascade flag, default false.

{ "taskRefs": ["transcribe"], "cascade": false }
// 202
{
  "rerun": ["transcribe"],
  "staleDownstream": ["transcreate", "speech_generate"],
  "cascade": false
}

cascade: true re-runs the named tasks and everything that depended on them — by data and by control flow — so the run stays consistent. false runs only the named tasks and leaves everything else as it is, which means the downstream results were computed from outputs that no longer exist.

That is sometimes exactly what you want, and sometimes a quiet corruption, so the tasks it leaves behind come back in staleDownstream rather than being left for you to notice.

The dependency set is computed from the compiled blueprint, not from timing. A parallel branch that merely started later is not swept up; a task that reads another's output across a fork is, even with no control-flow edge between them.

Both refuse on a RUNNING workflow — 409, "pause it before re-running tasks". Doing either live races the decider for the task frontier and a worker for a row about to be deleted, and "re-run this" has no agreed meaning while its downstream is still executing. Pause it, then change it.

After a re-run the execution returns to RUNNING, so a second one needs another pause.

Live stream

curl -N -H "x-api-key: $NF_API_KEY" -H 'accept: text/event-stream' \
  "$NF_URL/v1/ns/default/executions/$ID/stream"

Server-sent events, closing when the execution reaches a terminal state. It is resumable: a browser's EventSource replays Last-Event-ID automatically after a dropped connection and the server answers from that sequence number. For anything that is not an EventSource, pass ?lastEventId=N; the header wins when both are present.


Workers

Covered in full in Workers.

POST /v1/ns/{ns}/queues/{queue}/lease     { "workerId","count","waitSeconds","leaseSeconds" }
POST /v1/ns/{ns}/tasks/{taskId}/heartbeat { "queueName","leaseToken","leaseSeconds" }
POST /v1/ns/{ns}/tasks/{taskId}/logs      { "workflowId","leaseToken","logs":[{"message","level"}] }
POST /v1/ns/{ns}/tasks/{taskId}/report    { "queueName","workflowId","leaseToken","status","output","reason" }

GET  /v1/ns/{ns}/queues                   # everything backed up
GET  /v1/ns/{ns}/queues/workers           # who has polled in the last 24 hours
GET  /v1/ns/{ns}/queues/{queue}/depth     # the autoscaling signal

The rest of the surface

AreaPaths
NamespacesGET/POST /v1/namespaces, GET /v1/namespaces/{slug}platform:admin
Users/v1/ns/{ns}/users — create, list, disable, login, logout, change-password
Groups/v1/ns/{ns}/groups — scopes, tag grants, members
Permissions/v1/ns/{ns}/permissions — resource grants
TagsGET /v1/ns/{ns}/tags — what is in use and which groups can reach it
SecretsPUT/GET/DELETE /v1/ns/{ns}/secrets[/{name}], POST .../rotateadmin
Environment variables/v1/ns/{ns}/environment — readable, unlike secrets
Schemas/v1/ns/{ns}/schemas, POST .../{name}/validate
QuotasGET/PUT /v1/ns/{ns}/quotas
SemaphoresGET/PUT/DELETE /v1/ns/{ns}/semaphores[/{name}]
Schedules/v1/ns/{ns}/schedules, POST .../preview, /pause, /resume, /runs
Event handlers/v1/ns/{ns}/event-handlers, /sources, /activity, /executions, POST .../{name}/test
Inbound webhooks/v1/ns/{ns}/incoming-webhooks, delivered at /v1/hooks/{id}
Status listeners/v1/ns/{ns}/status-listeners
Human tasks/v1/ns/{ns}/human-tasks, /search, and per task /claim, /release, /reassign, /skip, /complete
Forms/v1/ns/{ns}/forms
AuditGET /v1/ns/{ns}/audit
Saved views/v1/ns/{ns}/saved-views
AI/v1/ns/{ns}/integrations, /prompts, /vector-indexes, /assistant
Workload identity/v1/ns/{ns}/workload-identities
Simulation/v1/ns/{ns}/metadata/… — server-side dry runs
REST gatewayPOST /v1/ns/{ns}/api/{workflow}
MCP gatewayPOST /v1/ns/{ns}/mcp

Schedules

curl -X POST "$NF_URL/v1/ns/default/schedules" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{
    "name": "nightly_reconcile",
    "cron": "0 2 * * *",
    "timezone": "Europe/London",
    "workflow": { "name": "reconcile", "version": 2 },
    "input": { "mode": "full" },
    "overlapPolicy": "SKIP",
    "catchupPolicy": "FIRE_ONE",
    "startAt": "2026-10-01T00:00:00Z",
    "endAt": "2027-10-01T00:00:00Z",
    "priority": 10
  }'
FieldValues
overlapPolicyALLOW — fire regardless; SKIP — do not fire while the previous run is live.
catchupPolicySKIP — drop missed firings; FIRE_ONE — one catch-up; FIRE_ALL — every missed firing.

POST /v1/ns/{ns}/schedules/preview answers with the next occurrences of a cron expression in a timezone, without creating anything. Scopes are workflows:* — a schedule is a statement about when a definition runs, so anyone who may change the definition may change its schedule.

Event handlers

curl -X POST "$NF_URL/v1/ns/default/event-handlers" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{
    "name": "on_order_placed",
    "source": "kafka:default",
    "topic": "orders.v1",
    "condition": "$.value.state == \"placed\"",
    "action": "START_WORKFLOW",
    "workflow": { "name": "fulfil_order" },
    "inputTemplate": { "orderId": "${value.orderId}" },
    "correlationId": "${value.orderId}",
    "enabled": true
  }'
FieldMeaning
sourcekafka:<name>, nats:<name>, amqp:<name>, sqs:<name>, redis:<name>, webhook, or internal.
actionSTART_WORKFLOW, COMPLETE_TASK, FAIL_TASK.
workflowIdExpr, taskRefExprFor the task actions: which run and which task.

A handler on a source the install has not configured is accepted and never fires, so read GET /v1/ns/{ns}/event-handlers/sources and offer those rather than a free-text box. POST /v1/ns/{ns}/event-handlers/{name}/test sends a synthetic message.

The event monitor is GET /v1/ns/{ns}/event-handlers/executions, filterable by handler and outcome (ACTED, SKIPPED, FAILED).

Inbound webhooks

curl -X POST "$NF_URL/v1/ns/default/incoming-webhooks" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{"name":"stripe","verifier":"STRIPE","secretName":"STRIPE_WEBHOOK_SECRET","enabled":true}'

The response carries the path to publish — use exactly that rather than assembling one. A webhook only verifies and forwards; what a request does is an event handler on source webhook with the webhook's name as the topic, so conditions, input templates, deduplication and the event monitor are the ones every other event gets.

The REST gateway

POST /v1/ns/{ns}/api/{workflow} runs a workflow and answers with its output, nothing else. The request body is the input; query parameters are merged in, so a link can carry arguments.

curl -X POST "$NF_URL/v1/ns/default/api/quote" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{"sku":"ABC","quantity":3}'
# { "price": 42.00, "currency": "GBP" }
  • Opt-in by tag. Only a definition carrying api:route is exposed. An untagged workflow is a 404, not a 403 — the gateway does not confirm that a workflow exists to someone who cannot reach it.
  • The same door, a different shape. The caller still needs a credential with executions:start and whatever tag or grant covers that workflow.
  • The workflow may choose its response. An output with a _response object — { status, headers, body } — is used as the HTTP response; anything else is returned as a JSON body with 200.
  • A slow run is a 202, with the execution id, not an error.

Browser access needs NODE_FLOW_GATEWAY_CORS_ORIGINS, which is empty by default.

The MCP gateway

POST /v1/ns/{ns}/mcp speaks JSON-RPC (tools/list, tools/call). A definition tagged mcp:tool becomes a tool; its description and input schema become the tool's. Streamable HTTP requires both content types:

curl -X POST "$NF_URL/v1/ns/default/mcp" \
  -H "x-api-key: $NF_API_KEY" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Generated clients

clients/ in the repository holds clients generated from openapi.json with OpenAPI Generator 7.16.

LanguageDirectoryPackage
Pythonpython/node_flow_client
Gogo/github.com/node-flow/node-flow-go/nodeflow
Javajava/dev.nodeflow:node-flow-client
TypeScripttypescript/@node-flow-dev/api-client (fetch)

Operations are grouped by area (ExecutionsApi, MetadataApi, AiApi, …) and named after the server's own handlers, so POST /v1/ns/{ns}/executions/{name}/execute is execution_execute (Python), ExecutionExecute (Go) and executionExecute (Java, TypeScript).

import node_flow_client as nf

config = nf.Configuration(host="http://localhost:3000")
config.api_key["apiKey"] = "nf_..."

with nf.ApiClient(config) as client:
    run = nf.ExecutionsApi(client).execution_execute(
        "default",
        "checkout",
        nf.ExecutionExecuteRequest(input={"orderId": "A-1"}, wait_for_seconds=10),
    )
    print(run["status"], run["output"])

Regenerate them against a running server:

./clients/generate.sh http://localhost:3000            # all four
./clients/generate.sh http://localhost:3000 python go  # some

These are the API, not a worker SDK. Worker fleets in TypeScript should use @node-flow-dev/sdk, which adds leasing, heartbeats, log batching, draining and retries on top.


Conductor compatibility

node-flow translates supported Conductor REST requests into its existing metadata, execution, and queue services. Existing clients can reuse that wire protocol after configuring the base URL and node-flow credentials, subject to the compatibility limits below.

Start with the dedicated Conductor compatibility guide for credential setup, a complete register/start/poll/report walkthrough, JavaScript SDK examples, migration boundaries, and troubleshooting.

Where to point an SDK

Mounted at /conductor/api, not /api, because Conductor's SDKs build the path themselves in different ways:

SDKBase URL
JavaScript (strips a trailing /api, appends /api/...)https://host/conductor
Python, Java (expect a base already ending in /api)https://host/conductor/api

Keeping it under /conductor serves both while keeping the compatibility layer clearly separate from the /v1 API this project designs against. It is a translation, not the contract.

What is translated

POST   /conductor/api/token
POST   /conductor/api/metadata/workflow
PUT    /conductor/api/metadata/workflow          (bulk)
GET    /conductor/api/metadata/workflow
GET    /conductor/api/metadata/workflow/{name}
DELETE /conductor/api/metadata/workflow/{name}/{version}
POST   /conductor/api/metadata/taskdefs
PUT    /conductor/api/metadata/taskdefs
GET    /conductor/api/metadata/taskdefs
GET    /conductor/api/metadata/taskdefs/{name}

POST   /conductor/api/workflow
POST   /conductor/api/workflow/{name}
POST   /conductor/api/workflow/execute/{name}[/{version}]
GET    /conductor/api/workflow/{workflowId}
GET    /conductor/api/workflow/{name}/correlated/{correlationId}
GET    /conductor/api/workflow/search
PUT    /conductor/api/workflow/{workflowId}/pause
PUT    /conductor/api/workflow/{workflowId}/resume
POST   /conductor/api/workflow/{workflowId}/retry
POST   /conductor/api/workflow/{workflowId}/restart
POST   /conductor/api/workflow/{workflowId}/rerun
DELETE /conductor/api/workflow/{workflowId}

GET    /conductor/api/tasks/poll/{taskType}?workerid=…
GET    /conductor/api/tasks/poll/batch/{taskType}
POST   /conductor/api/tasks
POST   /conductor/api/tasks/{workflowId}/{taskRefName}/{status}
GET    /conductor/api/tasks/{taskId}
POST   /conductor/api/tasks/{taskId}/log
GET    /conductor/api/tasks/queue/sizes

Requests go through the same controllers the /v1 API uses, so scopes, resource grants, input schemas, quotas, admission control and masking apply unchanged. The namespace comes from the credential, because Conductor has no concept of one and its paths carry none.

Conductor returns bare ids and task ids as text, not JSON strings, because its SDKs read them directly. That is preserved.

Two differences, documented rather than papered over

Fencing is weaker. A Conductor TaskResult carries no lease token — Conductor has no equivalent — so the token is read from the task row and the worker is checked by workerId instead. A worker that kept its id and lost its lease to a timeout could still report. Workers on the /v1 API keep the stronger guarantee.

Search is partial. Conductor's query language is an Elasticsearch dialect. The common equality forms are translated; anything else is ignored rather than silently mistranslated into a different result set.

Migrating

# 1. Point the SDK at the compatibility layer, exchange credentials as usual.
curl -X POST https://flows.example.com/conductor/api/token \
  -H 'content-type: application/json' \
  -d '{"keyId":"…","keySecret":"…"}'

# 2. Register definitions after checking the supported task/expression subset.
curl -X POST https://flows.example.com/conductor/api/metadata/workflow \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  --data @existing-conductor-workflow.json

# 3. Workers keep polling and acking the Conductor way.
curl "https://flows.example.com/conductor/api/tasks/poll/charge?workerid=w1" \
  -H "authorization: Bearer $TOKEN"

Things worth knowing when porting definitions:

  • http_request nesting on HTTP tasks is accepted.
  • value-param SWITCH expressions work; javascript ones are refused at registration rather than silently taking the default case.
  • Conductor-style loop conditions ($.loop['iteration'] < 3) are refused at registration rather than silently running the loop once.
  • ${workflow.variables.x} and ${workflow.env.X} both resolve.
  • Duration strings ("10m 30s", "1 day 4 hours") parse the same way.

Next

On this page