Neonloops API
Execute AI workflows programmatically. Build intelligent automations with a simple REST API — synchronous or streaming.
Base URL
https://neonloops.com/api/v1Authentication
All API requests require a Bearer token in the Authorization header. API keys start with nl_sk_ and can be created from your Dashboard.
curl -X POST https://neonloops.com/api/v1/run \
-H "Authorization: Bearer nl_sk_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"workflow_id": "wf_abc123",
"input": [{"role": "user", "content": "Hello"}]
}'/api/v1/runExecute a workflow synchronously. The response is returned after the workflow completes.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| workflow_id | string | Required | The ID of the workflow to execute (e.g. wf_abc123) |
| input | RunInput[] | Required | Array of input messages — [{role: "user", content: "..."}] |
| session_id | string | Optional | Session ID for multi-turn conversations. When provided, prior session messages are prepended to the input array before execution. |
| variables | object | Optional | Key-value pairs injected as workflow variables |
| version | number | Optional | Pin execution to a specific published version number. When omitted, the latest published state is used. |
{
"workflow_id": "wf_abc123",
"input": [
{ "role": "user", "content": "Summarize the latest news" }
],
"session_id": "sess_xyz",
"variables": {
"language": "en",
"max_length": 500
}
}{
"id": "run_k7m2n9p4",
"workflow_id": "wf_abc123",
"status": "completed",
"output": "Here is a summary of...",
"error": null,
"metadata": {
"tokens": { "input": 42, "output": 128 },
"durationMs": 3200,
"nodesExecuted": ["start", "llm_1", "end"]
}
}Response Headers
| Header | Description |
|---|---|
| X-Request-Id | Unique request identifier for debugging |
| X-Run-Id | Unique identifier for this execution run |
| X-RateLimit-Limit | Total requests allowed per rate limit window |
| X-RateLimit-Remaining | Requests remaining in current window |
| X-RateLimit-Reset | Unix timestamp when the rate limit resets |
| X-Quota-Limit | Total monthly workflow run quota |
| X-Quota-Remaining | Monthly workflow run quota remaining |
| X-Quota-Reset | ISO 8601 timestamp when the monthly quota resets |
Status Codes
| Status | Description |
|---|---|
| 200 | Workflow completed successfully |
| 202 | Workflow paused — awaiting human approval |
| 400 | Invalid request (missing workflow_id or input) |
| 401 | Invalid or missing API key |
| 403 | Project disabled or insufficient permissions |
| 404 | Workflow not found or not published |
| 429 | Rate limited or quota exceeded |
| 500 | Workflow execution failed |
{
"id": "run_k7m2n9p4",
"workflow_id": "wf_abc123",
"status": "pending_approval",
"approval_prompt": "Deploy to production?",
"paused_at_node_id": "approval-1",
"output": null,
"metadata": {
"durationMs": 1200,
"nodesExecuted": ["start", "llm_1", "approval-1"]
}
}/api/v1/run/streamExecute a workflow with real-time Server-Sent Events (SSE). Receive granular updates as each node executes. Request body is the same as /api/v1/run.
Event Types
| Event | Description |
|---|---|
| run:start | Workflow execution has started |
| node:start | A node has begun processing |
| node:complete | A node has finished with output |
| node:error | A node encountered an error |
| node:text-delta | Real-time LLM text token from an Agent node |
| node:waiting_approval | Paused at a Human Approval node |
| edge:traversed | An edge between nodes was traversed |
| fan-out | Parallel execution branches started |
| fan-in:waiting | Waiting for parallel branches to converge |
| fan-in:ready | All parallel branches have converged |
| run:paused | Workflow paused for human approval |
| run:resumed | Workflow resumed after approval decision |
| run:complete | Workflow execution finished |
| run:result | Final workflow result with output and metadata |
Event Payloads
{ "type": "run:start", "runId": "run_k7m2n", "totalNodes": 5 }
{ "type": "node:start", "runId": "run_k7m2n",
"nodeId": "llm_1", "nodeType": "llm", "nodeLabel": "Summarizer" }
{ "type": "node:complete", "runId": "run_k7m2n",
"nodeId": "llm_1", "nodeType": "llm", "nodeLabel": "Summarizer",
"durationMs": 1200, "outputPreview": "Here is...",
"tokenUsage": { "input": 42, "output": 128 } }
{ "type": "node:error", "runId": "run_k7m2n",
"nodeId": "llm_1", "nodeType": "llm", "error": "Provider timeout" }
{ "type": "node:text-delta", "runId": "run_k7m2n",
"nodeId": "llm_1", "nodeType": "agent", "nodeLabel": "Summarizer",
"delta": "Here" }
{ "type": "node:waiting_approval", "runId": "run_k7m2n",
"nodeId": "approval_1", "nodeType": "approval",
"nodeLabel": "Review", "approvalPrompt": "Deploy to production?" }
{ "type": "edge:traversed", "runId": "run_k7m2n",
"edgeId": "e1-2", "source": "llm_1", "target": "llm_2" }
{ "type": "fan-out", "runId": "run_k7m2n",
"sourceNodeId": "router_1", "targetCount": 3 }
{ "type": "fan-in:waiting", "runId": "run_k7m2n",
"nodeId": "merge_1", "arrived": 2, "expected": 3 }
{ "type": "fan-in:ready", "runId": "run_k7m2n", "nodeId": "merge_1" }
{ "type": "run:paused", "runId": "run_k7m2n",
"pausedAtNodeId": "approval_1", "durationMs": 800 }
{ "type": "run:resumed", "runId": "run_k7m2n" }
{ "type": "run:complete", "runId": "run_k7m2n",
"status": "completed", "durationMs": 3200 }
{ "type": "run:result", "id": "run_k7m2n", "workflow_id": "wf_abc123",
"status": "completed", "output": "Here is a summary...", "error": null,
"metadata": { "tokens": { "input": 42, "output": 128 },
"durationMs": 3200, "nodesExecuted": ["start", "llm_1", "end"] } }event: run:start
data: {"type":"run:start","runId":"run_k7m2n","totalNodes":3}
event: node:start
data: {"type":"node:start","runId":"run_k7m2n","nodeId":"llm_1","nodeType":"agent","nodeLabel":"Summarizer"}
data: {"type":"node:text-delta","runId":"run_k7m2n","nodeId":"llm_1","nodeType":"agent","nodeLabel":"Summarizer","delta":"Here"}
data: {"type":"node:text-delta","runId":"run_k7m2n","nodeId":"llm_1","nodeType":"agent","nodeLabel":"Summarizer","delta":" is"}
data: {"type":"node:text-delta","runId":"run_k7m2n","nodeId":"llm_1","nodeType":"agent","nodeLabel":"Summarizer","delta":" a summary"}
event: node:complete
data: {"type":"node:complete","runId":"run_k7m2n","nodeId":"llm_1","durationMs":1200,"outputPreview":"Here is a summary..."}
event: run:complete
data: {"type":"run:complete","runId":"run_k7m2n","status":"completed","durationMs":3200}
event: run:result
data: {"type":"run:result","id":"run_k7m2n","status":"completed","output":"Here is a summary..."}const res = await fetch("/api/v1/run/stream", {
method: "POST",
headers: {
"Authorization": "Bearer nl_sk_xxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
workflow_id: "wf_abc123",
input: [{ role: "user", content: "Summarize the news" }],
}),
});
const reader = res.body!
.pipeThrough(new TextDecoderStream())
.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value);
}Versioning & Rollback
Every time you publish a workflow, an immutable snapshot is saved. You can pin API runs to a specific version or roll back to a previous one.
Note: Only live (published) workflows can be executed. Calling POST /api/v1/run on a draft workflow returns 404 WORKFLOW_NOT_PUBLISHED.
Once a workflow is published, it becomes live permanently — use versioning and rollback to manage changes. Draft workflows can only be tested via the Preview panel in the builder.
/api/v1/workflows/:id/versions/:versionGet the full snapshot for a specific version, including nodes and edges.
/api/v1/workflows/:id/rollbackRoll back a workflow to a previous version. Rollback does not delete or overwrite existing versions — it copies the target snapshot's nodes/edges and publishes it as a new version, preserving the full version history. Status is set to live.
| Parameter | Type | Required | Description |
|---|---|---|---|
| version | number | Required | The version number to roll back to |
// Pin a run to version 2
const result = await runner.run("wf_abc123", {
input: [{ role: "user", content: "Hello!" }],
version: 2,
});
// Inspect a specific version's definition
const v2 = await runner.workflows.getVersion("wf_abc123", 2);
console.log(v2.nodes, v2.edges);
// Roll back to version 2 (publishes as a new version)
const rollback = await runner.workflows.rollback("wf_abc123", 2);
console.log(rollback.version); // e.g. 5 (new version number)# Pin a run to version 2
result = runner.run_sync(
"wf_abc123",
input=[RunInput(role="user", content="Hello!")],
version=2,
)
# Inspect a specific version's definition
v2 = runner.workflows.get_version_sync("wf_abc123", 2)
print(v2.nodes, v2.edges)
# Roll back to version 2 (publishes as a new version)
rollback = runner.workflows.rollback_sync("wf_abc123", 2)
print(rollback.version) # e.g. 5 (new version number)Approval Workflows
Workflows containing a Human Approval node pause execution and return a 202 response with status pending_approval. Use the approve or reject endpoints to resume execution.
Lifecycle: Run workflow → pending_approval → Approve or Reject → Workflow continues or fails
/api/v1/run/:runId/approveResume the workflow along the approved path. Returns the final result, or another 202 if the workflow hits another approval node.
| Parameter | Type | Required | Description |
|---|---|---|---|
| comment | string | Optional | Optional comment explaining the approval decision |
/api/v1/run/:runId/rejectResume the workflow along the rejected path. If no rejection edge exists, the run fails with "Approval rejected".
| Parameter | Type | Required | Description |
|---|---|---|---|
| comment | string | Optional | Optional comment explaining the rejection reason |
Status Codes
| Status | Description |
|---|---|
| 200 | Workflow completed after approval/rejection |
| 202 | Workflow paused again at another approval node |
| 400 | Invalid run ID format |
| 401 | Invalid or missing API key |
| 403 | Insufficient permissions (project-scoped key mismatch) |
| 404 | Run not found |
| 409 | Run is not in pending_approval status |
import { Runner, createInput } from "@neonloops/sdk";
const runner = new Runner({ apiKey: "nl_sk_xxxxx" });
// Execute workflow
const result = await runner.run("wf_abc123", {
input: [createInput("user", "Deploy v2.1 to production")],
});
// Check if approval is needed
if (result.status === "pending_approval") {
console.log(result.approvalPrompt);
// Approve with optional comment
const final = await runner.approve(result.id, {
comment: "Looks good, ship it!",
});
console.log(final.output);
// Or reject it
// const final = await runner.reject(result.id, {
// comment: "Needs more testing",
// });
}from neonloops import Runner, RunInput
runner = Runner(api_key="nl_sk_xxxxx")
# Execute workflow
result = runner.run_sync(
"wf_abc123",
input=[RunInput(role="user", content="Deploy v2.1")],
)
# Check if approval is needed
if result.status == "pending_approval":
print(result.approval_prompt)
# Approve with optional comment
final = runner.approve_sync(
result.id, comment="Looks good, ship it!"
)
print(final.output)
# Or reject it
# final = runner.reject_sync(
# result.id, comment="Needs more testing"
# )Sessions
Create and manage chat sessions for multi-turn conversations. When a session_id is passed to /api/v1/run, the server automatically loads previous messages from the session.
/api/v1/sessionsCreate a new chat session for a workflow.
| Parameter | Type | Required | Description |
|---|---|---|---|
| workflow_id | string | Required | The workflow ID to create the session for |
| title | string | Optional | Session title (default: "New chat", max 200 chars) |
/api/v1/sessions?workflow_id=wf_xxxList sessions for a workflow, ordered by most recently updated.
| Parameter | Type | Required | Description |
|---|---|---|---|
| workflow_id | string | Required | Filter sessions by workflow (query parameter) |
| limit | number | Optional | Max results per page (default: 50, max: 100) |
| offset | number | Optional | Pagination offset (default: 0) |
/api/v1/sessions/:sessionId/messagesGet messages in a session, in chronological order.
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Max messages per page (default: 100, max: 200) |
| offset | number | Optional | Pagination offset (default: 0) |
{
"id": "sess_abc123",
"workflow_id": "wf_abc123",
"title": "New chat",
"created_at": "2026-01-15T10:30:00.000Z"
}{
"data": [
{
"id": "msg_001",
"session_id": "sess_abc123",
"role": "user",
"content": "What is 2+2?",
"type": "text",
"created_at": "2026-01-15T10:30:00.000Z"
}
],
"pagination": {
"total": 24,
"limit": 100,
"offset": 0,
"has_more": false
}
}Multi-turn Conversation
import { Runner, createInput } from "@neonloops/sdk";
const runner = new Runner({ apiKey: "nl_sk_xxxxx" });
// 1. Create a session
const session = await runner.createSession("wf_abc123");
// 2. First message
const r1 = await runner.run("wf_abc123", {
input: [createInput("user", "My name is Alice")],
sessionId: session.id,
});
// 3. Follow-up — only send the new message
const r2 = await runner.run("wf_abc123", {
input: [createInput("user", "What's my name?")],
sessionId: session.id,
});
console.log(r2.output); // "Your name is Alice"
// List sessions for this workflow
const sessions = await runner.listSessions("wf_abc123");
// Get message history
const messages = await runner.getSessionMessages(session.id);from neonloops import Runner, RunInput
runner = Runner(api_key="nl_sk_xxxxx")
# 1. Create a session
session = runner.create_session_sync("wf_abc123")
# 2. First message
r1 = runner.run_sync(
"wf_abc123",
input=[RunInput(role="user", content="My name is Alice")],
session_id=session.id,
)
# 3. Follow-up — only send the new message
r2 = runner.run_sync(
"wf_abc123",
input=[RunInput(role="user", content="What's my name?")],
session_id=session.id,
)
print(r2.output) # "Your name is Alice"
# List sessions for this workflow
sessions = runner.list_sessions_sync("wf_abc123")
# Get message history
messages = runner.get_session_messages_sync(session.id)Expressions
Neonloops uses CEL (Common Expression Language) for dynamic logic inside workflows. Expressions power Condition branches, Transform field mappings, SetState variable mutations, and WhileLoop conditions.
Neonloops implements the CEL specification (google.github.io/cel-spec). Standard CEL operators, types, and functions are fully supported. The functions listed in the Custom Functions table below are Neonloops-specific extensions — they are not part of the CEL standard and are only available inside Neonloops workflows.
There are two expression modes:
CEL Expression
Plain expression — no markers. Returns any type (boolean, number, string, object, array).
score > 80 && status == "active"Template Interpolation
Uses ${var} or {{var}} markers to build strings.
Hello {{name}}, score=${score}Expression Context
Before any expression is evaluated, the execution context is flattened— all variables and the previous node's output are merged into a single root scope so you can access them directly by name.
// State before evaluation:
// variables: { name: "Alice", age: 30 }
// lastOutput: { score: 95, status: "ok" }
// Flattened context — all of these are directly accessible:
name → "Alice" // from variables
age → 30 // from variables
score → 95 // from lastOutput (spread)
status → "ok" // from lastOutput (spread)
output → { score: 95, status: "ok" } // lastOutput itself
output.score → 95 // dot access on outputName conflicts: When a variable and a lastOutput field share the same name, lastOutput wins — it is spread last during flattening.
Flattening order: raw context → variables → lastOutput fields
variables: { status: "pending" }
lastOutput: { status: "approved" }
status → "approved" // lastOutput overwrites the variable
output.status → "approved" // always safe for lastOutput fieldsUse output.field to safely access lastOutput fields regardless of naming. Avoid naming variables the same as expected output fields.
| Variable | Source | Description |
|---|---|---|
| name, age, ... | Start node variables | Variables defined on the Start node or assigned via SetState |
| score, status, ... | Previous node output | If lastOutput is an object, its fields are spread into root scope |
| output | Previous node output | The raw lastOutput value — always available regardless of type |
| output.field | Dot access | Access nested fields when lastOutput is an object |
lastOutput Types
The previous node's output can be different types depending on the node:
| Type | Example | Access Pattern |
|---|---|---|
| String | "The customer needs a refund." | output.contains("refund"), size(output) |
| JSON Object | { approved: true, score: 92 } | approved, score, or output.approved |
| Nested Object | { user: { profile: { email: "..." } } } | user.profile.email |
| Array in field | { tags: ["urgent", "billing"] } | size(tags), tags[0], "urgent" in tags |
CEL Syntax Reference
Operators
| Category | Operators | Example |
|---|---|---|
| Arithmetic | + - * / % | price * quantity + tax |
| Comparison | == != < > <= >= | score >= 80 |
| Logical | && || ! | age >= 18 && verified |
| Membership | in | "urgent" in tags |
| Ternary | ? : | score > 80 ? "pass" : "fail" |
| String concat | + | first + " " + last |
| List concat | + | [1, 2] + [3, 4] |
String Methods
| Method | Description | Example |
|---|---|---|
| contains(str) | Check if string contains substring | msg.contains("error") |
| startsWith(str) | Check prefix | name.startsWith("Dr.") |
| endsWith(str) | Check suffix | file.endsWith(".pdf") |
| size() | Character count | msg.size() > 100 |
| matches(regex) | Regex match | email.matches("[a-z]+@[a-z]+") |
| split(sep) | Split into list | text.split(", ") |
| indexOf(str) | First position | text.indexOf("hello") |
| lastIndexOf(str) | Last position | text.lastIndexOf("o") |
| charAt(i) | Character at index | text.charAt(0) |
| substring(start, end?) | Extract substring | text.substring(0, 5) |
| lowerAscii() | Lowercase | name.lowerAscii() |
| upperAscii() | Uppercase | code.upperAscii() |
| trim() | Strip whitespace | input.trim() |
| replace(old, new) | Replace all occurrences | text.replace("old", "new") |
List & Map Operations
| Operation | Description | Example |
|---|---|---|
| filter(x, expr) | Filter elements by condition | [1,2,3,4,5].filter(x, x > 2) → [3,4,5] |
| map(x, expr) | Transform each element | [1,2,3].map(x, x * 2) → [2,4,6] |
| join(sep) | Join list into string | ["a","b","c"].join(", ") → "a, b, c" |
| exists(x, expr) | True if any element matches | items.exists(x, x > 10) |
| all(x, expr) | True if all elements match | items.all(x, x > 0) |
| has(obj.field) | True if field exists | has(output.email) |
| size(list|map) | Length of list or map | size(tags) → 3 |
| list[index] | Access element by index | tags[0] → first element |
Type Conversions
| Function | Result |
|---|---|
| int(3.7) | 3 |
| double(42) | 42.0 |
| string(42) | "42" |
| string(true) | "true" |
Custom Functions
Neonloops extensions — not part of the CEL standard.
| Function | Description |
|---|---|
| wordCount(text) | Number of words (whitespace-split) |
| lineCount(text) | Number of lines |
| max(a, b) | Larger of two values |
| min(a, b) | Smaller of two values |
Template Interpolation
Use ${var} or {{var}} markers to substitute values into strings. Both syntaxes are interchangeable.
// Multiple markers → always returns a string
${firstName} ${lastName} → "Ada Lovelace"
{{name}}: score={{score}} → "Alice: score=95"
// Single block → unwrapped to raw value (not stringified)
{{age + 1}} → 29 (number)
{{user}} → { name: "Alice", ... } (object)
// Missing values are replaced with empty string
Hello ${unknown}! → "Hello !"Note: Do not use JavaScript backtick template literals. Use ${} or {{}} markers only. For math and logic, use bare expressions without markers: counter < 5.
Node Usage Examples
Condition Node
Each branch has a CEL expression that must return true or false. The first branch that evaluates to true is taken.
// Branch "Adult"
age >= 18
// Branch "VIP Customer"
order_count > 10 && membership == "gold"
// Branch "Urgent Support"
urgency == "high" && sentiment == "angry"
// Branch "Contains keyword"
output.contains("refund") || output.contains("cancel")Transform Node
Each row maps a key to a CEL expression. The result is a new object with the computed values.
// key: full_name
first_name + " " + last_name
// key: is_adult
age >= 18
// key: greeting
"Hello " + name.upperAscii()
// key: word_count
wordCount(output)SetState Node
Each row assigns the result of a CEL expression to a workflow variable.
// variable: retry_count
retry_count + 1
// variable: status
output.status
// variable: total_score
total_score + score
// variable: last_error
output.contains("error") ? output : last_errorWhileLoop Node
A single CEL expression that controls the loop — the loop body executes as long as the expression returns true.
// Retry until success or max retries
retry_count < max_retries && !success
// Improve until quality threshold
score < 80 && iteration < 5
// Process until list is empty
size(remaining_items) > 0Fan-In Expressions
When a node receives input from multiple parallel branches (fan-in), lastOutput is a special FanInInput object with outputs from each branch keyed by source node ID.
{
"_fanIn": true,
"branches": {
"n8": { "approve": true, "reason": "Well written" },
"n9": { "approve": false, "reason": "Needs sources" },
"n10": { "approve": true, "reason": "Great work" }
}
}Access branch outputs using branches.nodeId.field — thanks to context flattening, branches is directly accessible.
// Vote counting — majority approval (2 of 3 reviewers)
(branches.n8.approve ? 1 : 0) + (branches.n9.approve ? 1 : 0) + (branches.n10.approve ? 1 : 0) >= 2
// Access individual branch values
branches.n8.reason → "Well written"
// Check if specific branches approved
branches.n8.approve && branches.n10.approve
// Missing branch is safe (no error)
branches.n99.field → undefined// Combine reviews into a summary string
Review 1: {{branches.n8.reason}}, Review 2: {{branches.n9.reason}}, Review 3: {{branches.n10.reason}}Name conflict: Fan-in spreads _fanIn and branches into root scope. If you have a variable named branches, the fan-in output will overwrite it. Use output.branches for unambiguous access.
Workflows
Manage workflows programmatically — list, create, update, delete, publish, and inspect run history.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/workflows | List all workflows |
| GET | /api/v1/workflows/:id | Get workflow detail (includes nodes, edges, settings) |
| POST | /api/v1/workflows | Create a new workflow |
| PUT | /api/v1/workflows/:id | Update a workflow |
| DELETE | /api/v1/workflows/:id | Delete a workflow |
| POST | /api/v1/workflows/:id/publish | Publish a new version |
| GET | /api/v1/workflows/:id/versions | List published versions |
| GET | /api/v1/workflows/:id/versions/:version | Get version detail (nodes, edges) |
| POST | /api/v1/workflows/:id/rollback | Roll back to a previous version |
| GET | /api/v1/workflows/:id/runs | List execution runs |
| GET | /api/v1/workflows/:id/runs/:runId | Get run detail (input, metadata, node_trace) |
List Workflows
| Parameter | Type | Required | Description |
|---|---|---|---|
| project_id | string | Optional | Filter workflows by project |
| limit | number | Optional | Max results per page (default: 50, max: 100) |
| offset | number | Optional | Pagination offset (default: 0) |
List Versions
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Max results per page (default: 50, max: 100) |
| offset | number | Optional | Pagination offset (default: 0) |
List Runs
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Max results per page (default: 50, max: 100) |
| offset | number | Optional | Pagination offset (default: 0) |
Create Workflow
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | Workflow name (default: "Untitled workflow", max 200 chars) |
| description | string | Optional | Workflow description (max 2000 chars) |
| projectId | string | Required | Project ID (optional if using a project-scoped API key) |
| nodes | array | string | Optional | Workflow nodes as JSON array or stringified JSON (max 500 nodes) |
| edges | array | string | Optional | Workflow edges as JSON array or stringified JSON (max 1000 edges) |
Update Workflow
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | New name (max 200 chars) |
| description | string | Optional | New description (max 2000 chars) |
| nodes | string | Optional | Stringified JSON nodes (max 2 MB) |
| edges | string | Optional | Stringified JSON edges (max 2 MB) |
| settings | string | Optional | Stringified JSON settings (max 100 KB) |
Publish Workflow
Creates a new immutable version snapshot. Optionally override nodes and edges at publish time.
| Parameter | Type | Required | Description |
|---|---|---|---|
| nodes | string | Optional | Override nodes at publish time (stringified JSON) |
| edges | string | Optional | Override edges at publish time (stringified JSON) |
[
{
"id": "wf_abc123",
"project_id": "proj_xyz",
"name": "Customer Support Bot",
"description": "Handles tier-1 support tickets",
"version": 3,
"status": "live",
"created_at": "2026-01-10T08:00:00.000Z",
"updated_at": "2026-01-15T14:30:00.000Z"
}
]{
"version": 4,
"status": "live"
}{
"id": "run_k7m2n9p4",
"workflow_id": "wf_abc123",
"status": "completed",
"output": "Ticket resolved.",
"error": null,
"workflow_version": 3,
"input": [{ "role": "user", "content": "My order is late" }],
"metadata": {
"tokens": { "input": 85, "output": 210 },
"durationMs": 4500,
"nodesExecuted": ["start", "classify", "llm_1", "end"]
},
"node_trace": [...],
"started_at": "2026-01-15T14:30:00.000Z",
"completed_at": "2026-01-15T14:30:04.500Z",
"created_at": "2026-01-15T14:30:00.000Z"
}SDK Examples
import { Runner } from "@neonloops/sdk";
const runner = new Runner({ apiKey: "nl_sk_xxxxx" });
// List workflows
const workflows = await runner.workflows.list({
projectId: "proj_xyz",
limit: 10,
});
// Get a single workflow (includes nodes, edges, settings)
const detail = await runner.workflows.get("wf_abc123");
// Create a workflow
const wf = await runner.workflows.create({
name: "My Workflow",
projectId: "proj_xyz",
});
// Update it
await runner.workflows.update(wf.id, {
name: "Renamed Workflow",
});
// Publish
const pub = await runner.workflows.publish(wf.id);
console.log("Published version:", pub.version);
// List versions and runs
const versions = await runner.workflows.listVersions(wf.id);
const runs = await runner.workflows.listRuns(wf.id);
const run = await runner.workflows.getRun(wf.id, runs[0].id);
// Delete
await runner.workflows.delete(wf.id);from neonloops import Runner
runner = Runner(api_key="nl_sk_xxxxx")
# List workflows
workflows = runner.workflows.list_sync(
project_id="proj_xyz",
limit=10,
)
# Get a single workflow (includes nodes, edges, settings)
detail = runner.workflows.get_sync("wf_abc123")
# Create a workflow
wf = runner.workflows.create_sync(
name="My Workflow",
project_id="proj_xyz",
)
# Update it
runner.workflows.update_sync(wf.id, name="Renamed Workflow")
# Publish
pub = runner.workflows.publish_sync(wf.id)
print("Published version:", pub.version)
# List versions and runs
versions = runner.workflows.list_versions_sync(wf.id)
runs = runner.workflows.list_runs_sync(wf.id)
run = runner.workflows.get_run_sync(wf.id, runs[0].id)
# Delete
runner.workflows.delete_sync(wf.id)Projects
Projects group workflows and secrets together. API keys can be scoped to a single project for isolation.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/projects | List all projects (or scoped project) |
| POST | /api/v1/projects | Create a new project |
| PUT | /api/v1/projects/:id | Update a project |
| DELETE | /api/v1/projects/:id | Delete a project |
List Projects
| Parameter | Type | Required | Description |
|---|---|---|---|
| limit | number | Optional | Max results per page (default: 50, max: 100) |
| offset | number | Optional | Pagination offset (default: 0) |
Create / Update Project
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Optional | Project name (default: "New project", max 200 chars) |
| enabled | boolean | Optional | Enable or disable the project (update only) |
Note: Project-scoped API keys can only access their own project. Account-scoped keys can access all projects. Only account-scoped keys can create new projects.
Quota: Free plan allows up to 5 projects. Pro plan has unlimited projects.
{
"id": "proj_xyz",
"name": "Production",
"enabled": true,
"created_at": "2026-01-10T08:00:00.000Z",
"updated_at": "2026-01-10T08:00:00.000Z"
}// List projects
const projects = await runner.projects.list();
// Create
const proj = await runner.projects.create({
name: "Staging",
});
// Update
await runner.projects.update(proj.id, {
name: "Staging v2",
enabled: false,
});
// Delete
await runner.projects.delete(proj.id);# List projects
projects = runner.projects.list_sync()
# Create
proj = runner.projects.create_sync(name="Staging")
# Update
runner.projects.update_sync(
proj.id, name="Staging v2", enabled=False
)
# Delete
runner.projects.delete_sync(proj.id)Secrets
Store API keys and sensitive values that workflows can access at runtime. Secrets exist at two levels — project and workflow — with workflow secrets overriding project secrets.
Override hierarchy: At runtime, workflow-level secrets take priority over project-level secrets with the same name. This lets you set default provider keys at the project level and override them per workflow.
Project Secrets
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/projects/:id/secrets | List project secrets (values never returned) |
| POST | /api/v1/projects/:id/secrets | Create a project secret |
| DELETE | /api/v1/projects/:id/secrets/:secretId | Delete a project secret |
Workflow Secrets
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/workflows/:id/secrets | List workflow secrets (values never returned) |
| POST | /api/v1/workflows/:id/secrets | Create a workflow secret |
| DELETE | /api/v1/workflows/:id/secrets/:secretId | Delete a workflow secret |
Create Secret
| Parameter | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Secret name — alphanumeric and underscores, starts with letter or underscore (max 128 chars) |
| value | string | Required | Secret value (max 10 KB, write-only — never returned in API responses) |
Constraints: Max 200 secrets per project or workflow. Values are encrypted at rest with AES-256-GCM. Name must match ^[a-zA-Z_][a-zA-Z0-9_]{0,127}$
[
{
"id": "sec_abc123",
"name": "OPENAI_API_KEY",
"created_at": "2026-01-10T08:00:00.000Z"
}
]{
"id": "sec_abc123",
"name": "OPENAI_API_KEY"
}SDK Examples
// Project secrets
const secrets = await runner.projects.secrets.list("proj_xyz");
await runner.projects.secrets.create("proj_xyz", {
name: "OPENAI_API_KEY",
value: "sk-...",
});
await runner.projects.secrets.delete("proj_xyz", "sec_abc123");
// Workflow secrets (override project secrets)
await runner.workflows.secrets.create("wf_abc123", {
name: "OPENAI_API_KEY",
value: "sk-override-...",
});
const wfSecrets = await runner.workflows.secrets.list("wf_abc123");
await runner.workflows.secrets.delete("wf_abc123", "sec_xyz");# Project secrets
secrets = runner.projects.secrets.list_sync("proj_xyz")
runner.projects.secrets.create_sync(
"proj_xyz",
name="OPENAI_API_KEY",
value="sk-...",
)
runner.projects.secrets.delete_sync("proj_xyz", "sec_abc123")
# Workflow secrets (override project secrets)
runner.workflows.secrets.create_sync(
"wf_abc123",
name="OPENAI_API_KEY",
value="sk-override-...",
)
wf_secrets = runner.workflows.secrets.list_sync("wf_abc123")
runner.workflows.secrets.delete_sync("wf_abc123", "sec_xyz")Error Handling
All errors follow a consistent JSON format with an error code, human-readable message, and a unique request ID for debugging.
{
"error": {
"code": "WORKFLOW_NOT_FOUND",
"message": "The specified workflow does not exist",
"request_id": "req_a1b2c3d4"
}
}| Code | HTTP | Description |
|---|---|---|
| INVALID_API_KEY | 401 | API key is missing or invalid |
| API_KEY_REVOKED | 401 | API key has been revoked |
| UNAUTHORIZED | 401 | Authentication required |
| FORBIDDEN | 403 | Insufficient permissions for this resource |
| PROJECT_DISABLED | 403 | Project is disabled |
| PLAN_LIMIT | 403 | Feature not available on current plan |
| NOT_FOUND | 404 | Resource not found |
| WORKFLOW_NOT_FOUND | 404 | Workflow does not exist |
| WORKFLOW_NOT_PUBLISHED | 404 | Workflow exists but is not published |
| PROJECT_NOT_FOUND | 404 | Project does not exist |
| SESSION_NOT_FOUND | 404 | Session does not exist |
| INVALID_INPUT | 400 | Request validation failed |
| INVALID_JSON | 400 | Malformed JSON request body |
| VALIDATION_ERROR | 400 | Request body failed validation |
| MISSING_FIELD | 400 | A required field is missing |
| INVALID_WORKFLOW | 400 | Workflow has no nodes or is malformed |
| SESSION_WORKFLOW_MISMATCH | 400 | Session belongs to a different workflow |
| CONFLICT | 409 | Resource state conflict (e.g., run not pending approval) |
| RATE_LIMITED | 429 | Too many requests per minute |
| QUOTA_EXCEEDED | 429 | Monthly workflow run quota exhausted |
| SPENDING_CAP_EXCEEDED | 429 | Pro plan spending cap exceeded |
| EXECUTION_FAILED | 500 | Workflow execution failed at runtime |
| INTERNAL_ERROR | 500 | Unexpected server error |
| PROVIDER_ERROR | 502 | Upstream AI provider returned an error |
| SERVICE_UNAVAILABLE | 503 | Service temporarily unavailable |
| TIMEOUT | 504 | Workflow execution exceeded time limit |
Rate Limiting & Quotas
API usage is subject to rate limits and monthly quotas based on your plan.
Free
1,000
runs / month
100 requests / minute
Pro
25,000
runs / month included
Unlimited requests / minute
Response Headers
| Header | Description |
|---|---|
| X-Request-Id | Unique request identifier (matches error request_id) |
| X-RateLimit-Limit | Maximum requests allowed per minute |
| X-RateLimit-Remaining | Remaining requests in current window |
| X-RateLimit-Reset | Unix timestamp when the window resets |
| X-Quota-Limit | Monthly workflow run quota |
| X-Quota-Remaining | Remaining runs in current billing period |
| X-Quota-Reset | ISO date when the quota resets |
| Retry-After | Seconds to wait before retrying (on 429) |
When rate limited, use the Retry-After header with exponential backoff to retry requests gracefully.
Pagination
All list endpoints support pagination via limit and offset query parameters.
| Parameter | Default | Max | Description |
|---|---|---|---|
| limit | 50 | 100 | Number of items to return |
| offset | 0 | — | Number of items to skip |
Session endpoints wrap results in a data array with a pagination object. Use has_more to determine if more pages are available.
{
"data": [...],
"pagination": {
"total": 142,
"limit": 50,
"offset": 0,
"has_more": true
}
}Other list endpoints (workflows, projects, secrets, versions, runs) return a bare JSON array. These endpoints still accept limit and offset query parameters to control pagination.
[
{ "id": "wf_abc123", "name": "My Workflow", ... },
{ "id": "wf_def456", "name": "Another Workflow", ... }
]| Endpoint | Response Format |
|---|---|
| GET /api/v1/sessions | { data, pagination } |
| GET /api/v1/sessions/:id/messages | { data, pagination } |
| GET /api/v1/workflows | Array |
| GET /api/v1/workflows/:id/versions | Array |
| GET /api/v1/workflows/:id/runs | Array |
| GET /api/v1/projects | Array |
| GET /api/v1/projects/:id/secrets | Array |
| GET /api/v1/workflows/:id/secrets | Array |
curl https://neonloops.com/api/v1/sessions?workflow_id=wf_abc123&limit=50&offset=50 \
-H "Authorization: Bearer nl_sk_xxxxx"Type Reference
Core types used across the API and SDKs. TypeScript names are shown first, followed by Python equivalents where different.
Execution Types
| Type | Fields | Description |
|---|---|---|
| RunInput | role, content | Input message — role is "user" or "assistant" |
| RunOptions | input, sessionId?, variables?, version? | Options passed to run() and runStream() |
| RunResult | id, workflowId, status, output, error?, approvalPrompt?, pausedAtNodeId?, metadata | Result of a workflow execution |
| RunMetadata | provider?, model?, tokens?, durationMs, nodesExecuted[] | Execution metadata attached to RunResult |
| TokenUsage | input, output | Token counts for input and output |
| ApprovalOptions | comment? | Optional comment for approve/reject decisions |
Resource Types
| Type | Fields | Description |
|---|---|---|
| Workflow | id, projectId, name, description, version, status, createdAt, updatedAt | Workflow summary (list view) |
| WorkflowDetail | ...Workflow, nodes, edges, settings | Full workflow with node/edge definitions |
| WorkflowVersion | id, workflowId, version, publishedAt | Published version metadata |
| WorkflowVersionDetail | ...WorkflowVersion, nodes, edges | Version snapshot with full definition |
| WorkflowRun | id, workflowId, status, output, error?, startedAt, completedAt? | Run summary (list view) |
| WorkflowRunDetail | ...WorkflowRun, workflowVersion, input, metadata, nodeTrace | Full run with execution details |
| PublishResult | version, status | Result of publishing a workflow |
| RollbackResult | version, status, rolledBackFrom | Result of rolling back a workflow |
| Project | id, name, enabled, createdAt, updatedAt | Project resource |
| Secret | id, name, createdAt? | Secret metadata (values never returned) |
| Session | id, workflowId, title, createdAt, updatedAt? | Chat session for multi-turn conversations |
| SessionMessage | id, sessionId, role, content, type, createdAt | Individual message within a session |
Streaming Event Types
| Type | Event | Key Fields |
|---|---|---|
| RunStartEvent | run:start | runId, totalNodes |
| NodeStartEvent | node:start | runId, nodeId, nodeType, nodeLabel |
| NodeCompleteEvent | node:complete | runId, nodeId, durationMs, outputPreview, tokenUsage? |
| NodeErrorEvent | node:error | runId, nodeId, error |
| NodeTextDeltaEvent | node:text-delta | runId, nodeId, nodeType, nodeLabel, delta |
| NodeWaitingApprovalEvent | node:waiting_approval | runId, nodeId, approvalPrompt |
| EdgeTraversedEvent | edge:traversed | runId, edgeId, source, target |
| FanOutEvent | fan-out | runId, sourceNodeId, targetCount |
| FanInWaitingEvent | fan-in:waiting | runId, nodeId, arrived, expected |
| FanInReadyEvent | fan-in:ready | runId, nodeId |
| RunPausedEvent | run:paused | runId, pausedAtNodeId, durationMs |
| RunResumedEvent | run:resumed | runId |
| RunCompleteEvent | run:complete | runId, status, durationMs, error? |
| RunResultEvent | run:result | id, workflowId, status, output, metadata |
In TypeScript, use the StreamEvent union type. In Python, use StreamEventUnion and the parse_stream_event() helper for type-safe event handling.
Audit Logging
Neonloops automatically logs administrative actions for accountability. Audit logs are attached to the authenticated user and include the action, resource type, and resource ID.
| Action | Resource | Trigger |
|---|---|---|
| workflow.update | Workflow | PUT /api/v1/workflows/:id |
| workflow.delete | Workflow | DELETE /api/v1/workflows/:id |
| workflow.publish | Workflow | POST /api/v1/workflows/:id/publish |
| workflow.rollback | Workflow | POST /api/v1/workflows/:id/rollback |
| project.update | Project | PUT /api/v1/projects/:id |
| project.delete | Project | DELETE /api/v1/projects/:id |
| secret.read | Secret | GET .../secrets (list) |
| secret.create | Secret | POST .../secrets |
| secret.delete | Secret | DELETE .../secrets/:id |
Audit logs are retained for 90 days and are accessible from the Dashboard. Read-only operations (list, get) on workflows and projects are not logged.
Approval and rejection comments submitted via the /approve and /reject endpoints are persisted as part of the run record and are not stored in audit logs.
SDKs
Official client libraries for TypeScript and Python. Both SDKs wrap the REST API with typed interfaces, automatic retries, and streaming support.
Installation
npm install @neonloops/sdkpip install neonloopsConfiguration
Create a Runner instance with your API key and optional settings.
| Option | Python | Default | Description |
|---|---|---|---|
| apiKey | api_key | Required | Your API key (nl_sk_...) |
| baseUrl | base_url | https://neonloops.com | API base URL |
| projectId | project_id | undefined | Scope requests to a project |
| timeoutMs | timeout | 120000 / 120.0 | Request timeout (ms / seconds) |
| maxRetries | max_retries | 2 | Retry count for 429/5xx errors |
import { Runner } from "@neonloops/sdk";
const runner = new Runner({
apiKey: process.env.NEONLOOPS_API_KEY!,
baseUrl: "https://neonloops.com",
timeoutMs: 30_000,
maxRetries: 3,
});import os
from neonloops import Runner
runner = Runner(
api_key=os.environ["NEONLOOPS_API_KEY"],
base_url="https://neonloops.com",
timeout=30.0,
max_retries=3,
)Basic Execution
Use runner.run() to execute a workflow and wait for the final result.
import { Runner, createInput } from "@neonloops/sdk";
const runner = new Runner({
apiKey: "nl_sk_xxxxx",
});
const result = await runner.run("wf_abc123", {
input: [createInput("user", "Hello!")],
variables: { language: "en" },
sessionId: "session_xyz",
});
console.log(result.output);
console.log(result.metadata.tokens);import asyncio
from neonloops import Runner, RunInput
runner = Runner(api_key="nl_sk_xxxxx")
async def main():
result = await runner.run(
"wf_abc123",
input=[RunInput(role="user", content="Hello!")],
variables={"language": "en"},
session_id="session_xyz",
)
print(result.output)
print(result.metadata.tokens)
asyncio.run(main())from neonloops import Runner, RunInput
runner = Runner(api_key="nl_sk_xxxxx")
result = runner.run_sync(
"wf_abc123",
input=[RunInput(role="user", content="Hello!")],
)
print(result.output)Python async vs sync variants
- Use
run(),run_stream(), andlist_sessions()when working in async contexts — FastAPI request handlers, async scripts, or anywhereasynciois already running. - Use
run_sync()andrun_stream_sync()in synchronous scripts, Jupyter notebooks, or any context whereasynciois not already running. - All
_syncmethods are thin wrappers that run the async implementation in a thread pool — they are functionally equivalent to their async counterparts.
Streaming
Stream events as the workflow executes using SSE. Events are emitted for each node start, completion, error, and the final result.
const stream = runner.runStream("wf_abc123", {
input: [createInput("user", "Hello!")],
});
for await (const event of stream) {
switch (event.type) {
case "node:text-delta":
// Real-time LLM tokens — build streaming UI
process.stdout.write(event.delta);
break;
case "node:complete":
console.log(`\nNode ${event.nodeId} done`);
break;
case "run:result":
console.log("Output:", event.output);
break;
}
}async for event in runner.run_stream(
"wf_abc123",
input=[RunInput(role="user", content="Hello!")],
):
if event.type == "node:text-delta":
# Real-time LLM tokens
print(event.delta, end="", flush=True)
elif event.type == "node:complete":
print(f"\nNode {event.node_id} done")
elif event.type == "run:result":
print("Output:", event.output)for event in runner.run_stream_sync(
"wf_abc123",
input=[RunInput(role="user", content="Hello!")],
):
print(event.type, getattr(event, "node_id", ""))| Event Type | Description |
|---|---|
| run:start | Workflow execution started |
| node:start | A node began processing |
| node:text-delta | Real-time LLM token from an Agent node |
| node:complete | A node finished successfully |
| node:error | A node encountered an error |
| node:waiting_approval | Paused at an approval node |
| edge:traversed | Execution moved to the next node |
| fan-out | Parallel branches started |
| fan-in:waiting | Waiting for parallel branches |
| fan-in:ready | All parallel branches converged |
| run:paused | Workflow paused for approval |
| run:resumed | Workflow resumed after approval |
| run:complete | All nodes finished executing |
| run:result | Final workflow result with output |
Error Handling
Both SDKs throw typed errors for API failures and timeouts. Requests to 429 and 5xx endpoints are automatically retried with exponential backoff.
import {
Runner,
NeonloopsApiError,
NeonloopsTimeoutError,
createInput,
} from "@neonloops/sdk";
try {
const result = await runner.run("wf_abc123", {
input: [createInput("user", "Hello!")],
});
} catch (err) {
if (err instanceof NeonloopsApiError) {
console.error(err.status, err.body);
} else if (err instanceof NeonloopsTimeoutError) {
console.error("Request timed out");
}
}from neonloops import (
Runner,
RunInput,
NeonloopsApiError,
NeonloopsTimeoutError,
)
try:
result = runner.run_sync(
"wf_abc123",
input=[RunInput(role="user", content="Hello!")],
)
except NeonloopsApiError as e:
print(e.status, e.body)
except NeonloopsTimeoutError as e:
print(f"Timed out after {e.timeout_s}s")429 (rate limited) and 5xx (server error) responses are retried up to maxRetries times with exponential backoff. 4xx client errors are not retried.