Neonloops API

Execute AI workflows programmatically. Build intelligent automations with a simple REST API — synchronous or streaming.

Base URL

https://neonloops.com/api/v1

Authentication

All API requests require a Bearer token in the Authorization header. API keys start with nl_sk_ and can be created from your Dashboard.

Example request
bash
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"}]
  }'
POST/api/v1/run

Execute a workflow synchronously. The response is returned after the workflow completes.

Request Body

ParameterTypeRequiredDescription
workflow_idstringRequiredThe ID of the workflow to execute (e.g. wf_abc123)
inputRunInput[]RequiredArray of input messages — [{role: "user", content: "..."}]
session_idstringOptionalSession ID for multi-turn conversations. When provided, prior session messages are prepended to the input array before execution.
variablesobjectOptionalKey-value pairs injected as workflow variables
versionnumberOptionalPin execution to a specific published version number. When omitted, the latest published state is used.
Request
json
{
  "workflow_id": "wf_abc123",
  "input": [
    { "role": "user", "content": "Summarize the latest news" }
  ],
  "session_id": "sess_xyz",
  "variables": {
    "language": "en",
    "max_length": 500
  }
}
Response (200)
json
{
  "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

HeaderDescription
X-Request-IdUnique request identifier for debugging
X-Run-IdUnique identifier for this execution run
X-RateLimit-LimitTotal requests allowed per rate limit window
X-RateLimit-RemainingRequests remaining in current window
X-RateLimit-ResetUnix timestamp when the rate limit resets
X-Quota-LimitTotal monthly workflow run quota
X-Quota-RemainingMonthly workflow run quota remaining
X-Quota-ResetISO 8601 timestamp when the monthly quota resets

Status Codes

StatusDescription
200Workflow completed successfully
202Workflow paused — awaiting human approval
400Invalid request (missing workflow_id or input)
401Invalid or missing API key
403Project disabled or insufficient permissions
404Workflow not found or not published
429Rate limited or quota exceeded
500Workflow execution failed
Pending approval response (202)
json
{
  "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"]
  }
}
POST/api/v1/run/stream

Execute 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

EventDescription
run:startWorkflow execution has started
node:startA node has begun processing
node:completeA node has finished with output
node:errorA node encountered an error
node:text-deltaReal-time LLM text token from an Agent node
node:waiting_approvalPaused at a Human Approval node
edge:traversedAn edge between nodes was traversed
fan-outParallel execution branches started
fan-in:waitingWaiting for parallel branches to converge
fan-in:readyAll parallel branches have converged
run:pausedWorkflow paused for human approval
run:resumedWorkflow resumed after approval decision
run:completeWorkflow execution finished
run:resultFinal workflow result with output and metadata

Event Payloads

Event payload shapes
json
{ "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"] } }
SSE output
text
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..."}
JavaScript (fetch API)
typescript
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.

GET/api/v1/workflows/:id/versions/:version

Get the full snapshot for a specific version, including nodes and edges.

POST/api/v1/workflows/:id/rollback

Roll 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.

ParameterTypeRequiredDescription
versionnumberRequiredThe version number to roll back to
TypeScript
typescript
// 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)
Python
python
# 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 workflowpending_approvalApprove or RejectWorkflow continues or fails

POST/api/v1/run/:runId/approve

Resume the workflow along the approved path. Returns the final result, or another 202 if the workflow hits another approval node.

ParameterTypeRequiredDescription
commentstringOptionalOptional comment explaining the approval decision
POST/api/v1/run/:runId/reject

Resume the workflow along the rejected path. If no rejection edge exists, the run fails with "Approval rejected".

ParameterTypeRequiredDescription
commentstringOptionalOptional comment explaining the rejection reason

Status Codes

StatusDescription
200Workflow completed after approval/rejection
202Workflow paused again at another approval node
400Invalid run ID format
401Invalid or missing API key
403Insufficient permissions (project-scoped key mismatch)
404Run not found
409Run is not in pending_approval status
TypeScript
typescript
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",
  // });
}
Python
python
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.

POST/api/v1/sessions

Create a new chat session for a workflow.

ParameterTypeRequiredDescription
workflow_idstringRequiredThe workflow ID to create the session for
titlestringOptionalSession title (default: "New chat", max 200 chars)
GET/api/v1/sessions?workflow_id=wf_xxx

List sessions for a workflow, ordered by most recently updated.

ParameterTypeRequiredDescription
workflow_idstringRequiredFilter sessions by workflow (query parameter)
limitnumberOptionalMax results per page (default: 50, max: 100)
offsetnumberOptionalPagination offset (default: 0)
GET/api/v1/sessions/:sessionId/messages

Get messages in a session, in chronological order.

ParameterTypeRequiredDescription
limitnumberOptionalMax messages per page (default: 100, max: 200)
offsetnumberOptionalPagination offset (default: 0)
Session response (201)
json
{
  "id": "sess_abc123",
  "workflow_id": "wf_abc123",
  "title": "New chat",
  "created_at": "2026-01-15T10:30:00.000Z"
}
Messages response (paginated)
json
{
  "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

TypeScript
typescript
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);
Python
python
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.

Context flattening example
json
// 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 output

Name 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

Name conflict example
text
variables:  { status: "pending" }
lastOutput: { status: "approved" }

status        → "approved"   // lastOutput overwrites the variable
output.status → "approved"   // always safe for lastOutput fields

Use output.field to safely access lastOutput fields regardless of naming. Avoid naming variables the same as expected output fields.

VariableSourceDescription
name, age, ...Start node variablesVariables defined on the Start node or assigned via SetState
score, status, ...Previous node outputIf lastOutput is an object, its fields are spread into root scope
outputPrevious node outputThe raw lastOutput value — always available regardless of type
output.fieldDot accessAccess nested fields when lastOutput is an object

lastOutput Types

The previous node's output can be different types depending on the node:

TypeExampleAccess 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

CategoryOperatorsExample
Arithmetic+ - * / %price * quantity + tax
Comparison== != < > <= >=score >= 80
Logical&& || !age >= 18 && verified
Membershipin"urgent" in tags
Ternary? :score > 80 ? "pass" : "fail"
String concat+first + " " + last
List concat+[1, 2] + [3, 4]

String Methods

MethodDescriptionExample
contains(str)Check if string contains substringmsg.contains("error")
startsWith(str)Check prefixname.startsWith("Dr.")
endsWith(str)Check suffixfile.endsWith(".pdf")
size()Character countmsg.size() > 100
matches(regex)Regex matchemail.matches("[a-z]+@[a-z]+")
split(sep)Split into listtext.split(", ")
indexOf(str)First positiontext.indexOf("hello")
lastIndexOf(str)Last positiontext.lastIndexOf("o")
charAt(i)Character at indextext.charAt(0)
substring(start, end?)Extract substringtext.substring(0, 5)
lowerAscii()Lowercasename.lowerAscii()
upperAscii()Uppercasecode.upperAscii()
trim()Strip whitespaceinput.trim()
replace(old, new)Replace all occurrencestext.replace("old", "new")

List & Map Operations

OperationDescriptionExample
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 matchesitems.exists(x, x > 10)
all(x, expr)True if all elements matchitems.all(x, x > 0)
has(obj.field)True if field existshas(output.email)
size(list|map)Length of list or mapsize(tags) → 3
list[index]Access element by indextags[0] → first element

Type Conversions

FunctionResult
int(3.7)3
double(42)42.0
string(42)"42"
string(true)"true"

Custom Functions

Neonloops extensions — not part of the CEL standard.

FunctionDescription
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.

Template interpolation examples
text
// 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.

Condition branch expressions
text
// 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.

Transform field mappings
text
// 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.

SetState variable assignments
text
// variable: retry_count
retry_count + 1

// variable: status
output.status

// variable: total_score
total_score + score

// variable: last_error
output.contains("error") ? output : last_error

WhileLoop Node

A single CEL expression that controls the loop — the loop body executes as long as the expression returns true.

WhileLoop conditions
text
// 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) > 0

Fan-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.

FanInInput structure
json
{
  "_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.

Fan-in expression examples
text
// 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
Fan-in with template interpolation
text
// 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.

MethodPathDescription
GET/api/v1/workflowsList all workflows
GET/api/v1/workflows/:idGet workflow detail (includes nodes, edges, settings)
POST/api/v1/workflowsCreate a new workflow
PUT/api/v1/workflows/:idUpdate a workflow
DELETE/api/v1/workflows/:idDelete a workflow
POST/api/v1/workflows/:id/publishPublish a new version
GET/api/v1/workflows/:id/versionsList published versions
GET/api/v1/workflows/:id/versions/:versionGet version detail (nodes, edges)
POST/api/v1/workflows/:id/rollbackRoll back to a previous version
GET/api/v1/workflows/:id/runsList execution runs
GET/api/v1/workflows/:id/runs/:runIdGet run detail (input, metadata, node_trace)

List Workflows

ParameterTypeRequiredDescription
project_idstringOptionalFilter workflows by project
limitnumberOptionalMax results per page (default: 50, max: 100)
offsetnumberOptionalPagination offset (default: 0)

List Versions

ParameterTypeRequiredDescription
limitnumberOptionalMax results per page (default: 50, max: 100)
offsetnumberOptionalPagination offset (default: 0)

List Runs

ParameterTypeRequiredDescription
limitnumberOptionalMax results per page (default: 50, max: 100)
offsetnumberOptionalPagination offset (default: 0)

Create Workflow

ParameterTypeRequiredDescription
namestringOptionalWorkflow name (default: "Untitled workflow", max 200 chars)
descriptionstringOptionalWorkflow description (max 2000 chars)
projectIdstringRequiredProject ID (optional if using a project-scoped API key)
nodesarray | stringOptionalWorkflow nodes as JSON array or stringified JSON (max 500 nodes)
edgesarray | stringOptionalWorkflow edges as JSON array or stringified JSON (max 1000 edges)

Update Workflow

ParameterTypeRequiredDescription
namestringOptionalNew name (max 200 chars)
descriptionstringOptionalNew description (max 2000 chars)
nodesstringOptionalStringified JSON nodes (max 2 MB)
edgesstringOptionalStringified JSON edges (max 2 MB)
settingsstringOptionalStringified JSON settings (max 100 KB)

Publish Workflow

Creates a new immutable version snapshot. Optionally override nodes and edges at publish time.

ParameterTypeRequiredDescription
nodesstringOptionalOverride nodes at publish time (stringified JSON)
edgesstringOptionalOverride edges at publish time (stringified JSON)
List response
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"
  }
]
Publish response
json
{
  "version": 4,
  "status": "live"
}
Run detail response
json
{
  "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

TypeScript
typescript
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);
Python
python
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.

MethodPathDescription
GET/api/v1/projectsList all projects (or scoped project)
POST/api/v1/projectsCreate a new project
PUT/api/v1/projects/:idUpdate a project
DELETE/api/v1/projects/:idDelete a project

List Projects

ParameterTypeRequiredDescription
limitnumberOptionalMax results per page (default: 50, max: 100)
offsetnumberOptionalPagination offset (default: 0)

Create / Update Project

ParameterTypeRequiredDescription
namestringOptionalProject name (default: "New project", max 200 chars)
enabledbooleanOptionalEnable 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.

Project response
json
{
  "id": "proj_xyz",
  "name": "Production",
  "enabled": true,
  "created_at": "2026-01-10T08:00:00.000Z",
  "updated_at": "2026-01-10T08:00:00.000Z"
}
TypeScript
typescript
// 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);
Python
python
# 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

MethodPathDescription
GET/api/v1/projects/:id/secretsList project secrets (values never returned)
POST/api/v1/projects/:id/secretsCreate a project secret
DELETE/api/v1/projects/:id/secrets/:secretIdDelete a project secret

Workflow Secrets

MethodPathDescription
GET/api/v1/workflows/:id/secretsList workflow secrets (values never returned)
POST/api/v1/workflows/:id/secretsCreate a workflow secret
DELETE/api/v1/workflows/:id/secrets/:secretIdDelete a workflow secret

Create Secret

ParameterTypeRequiredDescription
namestringRequiredSecret name — alphanumeric and underscores, starts with letter or underscore (max 128 chars)
valuestringRequiredSecret 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}$

List response
json
[
  {
    "id": "sec_abc123",
    "name": "OPENAI_API_KEY",
    "created_at": "2026-01-10T08:00:00.000Z"
  }
]
Create response (201)
json
{
  "id": "sec_abc123",
  "name": "OPENAI_API_KEY"
}

SDK Examples

TypeScript
typescript
// 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");
Python
python
# 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 response format
json
{
  "error": {
    "code": "WORKFLOW_NOT_FOUND",
    "message": "The specified workflow does not exist",
    "request_id": "req_a1b2c3d4"
  }
}
CodeHTTPDescription
INVALID_API_KEY401API key is missing or invalid
API_KEY_REVOKED401API key has been revoked
UNAUTHORIZED401Authentication required
FORBIDDEN403Insufficient permissions for this resource
PROJECT_DISABLED403Project is disabled
PLAN_LIMIT403Feature not available on current plan
NOT_FOUND404Resource not found
WORKFLOW_NOT_FOUND404Workflow does not exist
WORKFLOW_NOT_PUBLISHED404Workflow exists but is not published
PROJECT_NOT_FOUND404Project does not exist
SESSION_NOT_FOUND404Session does not exist
INVALID_INPUT400Request validation failed
INVALID_JSON400Malformed JSON request body
VALIDATION_ERROR400Request body failed validation
MISSING_FIELD400A required field is missing
INVALID_WORKFLOW400Workflow has no nodes or is malformed
SESSION_WORKFLOW_MISMATCH400Session belongs to a different workflow
CONFLICT409Resource state conflict (e.g., run not pending approval)
RATE_LIMITED429Too many requests per minute
QUOTA_EXCEEDED429Monthly workflow run quota exhausted
SPENDING_CAP_EXCEEDED429Pro plan spending cap exceeded
EXECUTION_FAILED500Workflow execution failed at runtime
INTERNAL_ERROR500Unexpected server error
PROVIDER_ERROR502Upstream AI provider returned an error
SERVICE_UNAVAILABLE503Service temporarily unavailable
TIMEOUT504Workflow 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

HeaderDescription
X-Request-IdUnique request identifier (matches error request_id)
X-RateLimit-LimitMaximum requests allowed per minute
X-RateLimit-RemainingRemaining requests in current window
X-RateLimit-ResetUnix timestamp when the window resets
X-Quota-LimitMonthly workflow run quota
X-Quota-RemainingRemaining runs in current billing period
X-Quota-ResetISO date when the quota resets
Retry-AfterSeconds 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.

ParameterDefaultMaxDescription
limit50100Number of items to return
offset0Number 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.

Paginated response shape (sessions)
json
{
  "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.

Array response shape (workflows, projects, etc.)
json
[
  { "id": "wf_abc123", "name": "My Workflow", ... },
  { "id": "wf_def456", "name": "Another Workflow", ... }
]
EndpointResponse Format
GET /api/v1/sessions{ data, pagination }
GET /api/v1/sessions/:id/messages{ data, pagination }
GET /api/v1/workflowsArray
GET /api/v1/workflows/:id/versionsArray
GET /api/v1/workflows/:id/runsArray
GET /api/v1/projectsArray
GET /api/v1/projects/:id/secretsArray
GET /api/v1/workflows/:id/secretsArray
Fetching page 2
bash
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

TypeFieldsDescription
RunInputrole, contentInput message — role is "user" or "assistant"
RunOptionsinput, sessionId?, variables?, version?Options passed to run() and runStream()
RunResultid, workflowId, status, output, error?, approvalPrompt?, pausedAtNodeId?, metadataResult of a workflow execution
RunMetadataprovider?, model?, tokens?, durationMs, nodesExecuted[]Execution metadata attached to RunResult
TokenUsageinput, outputToken counts for input and output
ApprovalOptionscomment?Optional comment for approve/reject decisions

Resource Types

TypeFieldsDescription
Workflowid, projectId, name, description, version, status, createdAt, updatedAtWorkflow summary (list view)
WorkflowDetail...Workflow, nodes, edges, settingsFull workflow with node/edge definitions
WorkflowVersionid, workflowId, version, publishedAtPublished version metadata
WorkflowVersionDetail...WorkflowVersion, nodes, edgesVersion snapshot with full definition
WorkflowRunid, workflowId, status, output, error?, startedAt, completedAt?Run summary (list view)
WorkflowRunDetail...WorkflowRun, workflowVersion, input, metadata, nodeTraceFull run with execution details
PublishResultversion, statusResult of publishing a workflow
RollbackResultversion, status, rolledBackFromResult of rolling back a workflow
Projectid, name, enabled, createdAt, updatedAtProject resource
Secretid, name, createdAt?Secret metadata (values never returned)
Sessionid, workflowId, title, createdAt, updatedAt?Chat session for multi-turn conversations
SessionMessageid, sessionId, role, content, type, createdAtIndividual message within a session

Streaming Event Types

TypeEventKey Fields
RunStartEventrun:startrunId, totalNodes
NodeStartEventnode:startrunId, nodeId, nodeType, nodeLabel
NodeCompleteEventnode:completerunId, nodeId, durationMs, outputPreview, tokenUsage?
NodeErrorEventnode:errorrunId, nodeId, error
NodeTextDeltaEventnode:text-deltarunId, nodeId, nodeType, nodeLabel, delta
NodeWaitingApprovalEventnode:waiting_approvalrunId, nodeId, approvalPrompt
EdgeTraversedEventedge:traversedrunId, edgeId, source, target
FanOutEventfan-outrunId, sourceNodeId, targetCount
FanInWaitingEventfan-in:waitingrunId, nodeId, arrived, expected
FanInReadyEventfan-in:readyrunId, nodeId
RunPausedEventrun:pausedrunId, pausedAtNodeId, durationMs
RunResumedEventrun:resumedrunId
RunCompleteEventrun:completerunId, status, durationMs, error?
RunResultEventrun:resultid, 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.

ActionResourceTrigger
workflow.updateWorkflowPUT /api/v1/workflows/:id
workflow.deleteWorkflowDELETE /api/v1/workflows/:id
workflow.publishWorkflowPOST /api/v1/workflows/:id/publish
workflow.rollbackWorkflowPOST /api/v1/workflows/:id/rollback
project.updateProjectPUT /api/v1/projects/:id
project.deleteProjectDELETE /api/v1/projects/:id
secret.readSecretGET .../secrets (list)
secret.createSecretPOST .../secrets
secret.deleteSecretDELETE .../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

TypeScript
bash
npm install @neonloops/sdk
Python
bash
pip install neonloops

Configuration

Create a Runner instance with your API key and optional settings.

OptionPythonDefaultDescription
apiKeyapi_keyRequiredYour API key (nl_sk_...)
baseUrlbase_urlhttps://neonloops.comAPI base URL
projectIdproject_idundefinedScope requests to a project
timeoutMstimeout120000 / 120.0Request timeout (ms / seconds)
maxRetriesmax_retries2Retry count for 429/5xx errors
TypeScript
typescript
import { Runner } from "@neonloops/sdk";

const runner = new Runner({
  apiKey: process.env.NEONLOOPS_API_KEY!,
  baseUrl: "https://neonloops.com",
  timeoutMs: 30_000,
  maxRetries: 3,
});
Python
python
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.

TypeScript (async/await)
typescript
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);
Python (async)
python
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())
Python (sync)
python
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(), and list_sessions() when working in async contexts — FastAPI request handlers, async scripts, or anywhere asyncio is already running.
  • Use run_sync() and run_stream_sync() in synchronous scripts, Jupyter notebooks, or any context where asyncio is not already running.
  • All _sync methods 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.

TypeScript
typescript
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;
  }
}
Python (async)
python
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)
Python (sync)
python
for event in runner.run_stream_sync(
    "wf_abc123",
    input=[RunInput(role="user", content="Hello!")],
):
    print(event.type, getattr(event, "node_id", ""))
Event TypeDescription
run:startWorkflow execution started
node:startA node began processing
node:text-deltaReal-time LLM token from an Agent node
node:completeA node finished successfully
node:errorA node encountered an error
node:waiting_approvalPaused at an approval node
edge:traversedExecution moved to the next node
fan-outParallel branches started
fan-in:waitingWaiting for parallel branches
fan-in:readyAll parallel branches converged
run:pausedWorkflow paused for approval
run:resumedWorkflow resumed after approval
run:completeAll nodes finished executing
run:resultFinal 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.

TypeScript
typescript
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");
  }
}
Python
python
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.