Workflows API
This content is not available in your language yet.
This page documents every GraphQL operation for managing Workflows — the visual conversation graphs that drive an agent’s behavior. For the conceptual model (node types, edges, how a workflow executes at runtime), see Workflows Overview. This page is strictly the API-call reference for creating, editing, publishing, generating, and debugging workflows.
Most operations on this page require JWT dashboard authentication (AuthJwtGuard), with two exceptions: the read-only nodeConfigSchema / nodeConfigSchemas queries also accept an API key, so a headless builder can fetch node schemas without a dashboard login; and runWorkflowTask is API-key only (it is not reachable with a JWT at all). Every request must also include the x-huat-platform: customer header. See API Reference Overview for general request conventions, and Agents API for how a workflow gets attached to an agent via Agent.workflowId.
Before you start: the two-call create pattern
Section titled “Before you start: the two-call create pattern”createWorkflow creates an empty workflow — its input has no nodes/edges fields at all. You always follow it with a second call to updateWorkflow to actually populate the graph:
createWorkflow({ name, description })→ returns aWorkflowDtowith anid,nodes: [],edges: [].updateWorkflow({ id, nodes, edges })→ writes the actual graph onto that workflow.publishWorkflow(id)→ makes it live. A workflow does nothing at runtime until this is called — sessions started against the owning agent run whatever was published most recently, not whatever is currently saved as a draft.
Skipping step 3 is the most common cause of “the agent isn’t responding the way I configured it” — the draft graph is saved correctly, but no session ever executes it.
This applies identically to a workflow that’s already published. updateWorkflow always writes to the draft only — it never affects what a live session runs. If you edit an already-published workflow and want that change live, you must call publishWorkflow(id) again; there is no “edit is live immediately” path for any workflow, published or not. (Before 2026-08-27, editing an already-published workflow’s nodes/edges did take effect immediately, with no republish step — this was an unintended gap, not documented behavior, and has been fixed. If your integration was built around that shortcut — editing a live workflow and expecting the change to apply without a follow-up publishWorkflow call — add that call now.)
workflow
Section titled “workflow”Fetches a single Workflow by id, including its nodes/edges JSON, scoped to the caller’s tenant.
Auth: JWT dashboard auth only.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
id | Int! | Yes | The workflow’s id. |
Returns
Section titled “Returns”WorkflowDto (nullable — null if no workflow with that id exists for the caller’s tenant).
| Field | Type | Description |
|---|---|---|
id | Int! | Workflow id. |
tenantId | Int! | Owning tenant. |
name | String! | Display name. |
description | String | Optional free-text description. |
nodes | JSON! | The draft graph’s nodes, in React Flow shape — always reflects the latest updateWorkflow call, which may be ahead of what’s actually live (see isPublished below). See Workflows Overview for node type reference. |
edges | JSON! | The draft graph’s edges — same caveat as nodes. |
isPublished | Boolean! | Whether this workflow has ever been published. Does not mean nodes/edges above match what’s live — an already-published workflow’s draft can be edited (and diverge from its live version) indefinitely without ever flipping this to false. There is no field on this type exposing the live/published graph directly; it’s whatever nodes/edges looked like at the most recent publishWorkflow call. |
version | Int! | Incremented on every publishWorkflow call. |
createdAt | DateTime! | Creation timestamp. |
updatedAt | DateTime! | Last update timestamp. |
Example request
Section titled “Example request”query GetWorkflow($id: Int!) { workflow(id: $id) { id name isPublished version nodes edges }}{ "id": 42 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "workflow": { "id": 42, "name": "Support Triage", "isPublished": true, "version": 3, "nodes": [ { "id": "n_start", "type": "start", "position": { "x": 0, "y": 0 }, "data": {} } ], "edges": [{ "id": "e1", "source": "n_start", "target": "n_intent" }] } }}workflows
Section titled “workflows”Lists every Workflow belonging to the caller’s tenant.
Auth: JWT dashboard auth only.
Arguments
Section titled “Arguments”None.
Returns
Section titled “Returns”[WorkflowDto!]! — same shape as workflow above, one entry per workflow.
Example request
Section titled “Example request”query ListWorkflows { workflows { id name isPublished version }}Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "workflows": [ { "id": 42, "name": "Support Triage", "isPublished": true, "version": 3 }, { "id": 51, "name": "Onboarding Draft", "isPublished": false, "version": 0 } ] }}createWorkflow
Section titled “createWorkflow”Creates a new, empty Workflow with just a name/description. CreateWorkflowInput has no nodes/edges fields — add the graph itself in a second call to updateWorkflow using the id returned here.
Auth: JWT dashboard auth only.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
input.name | String! | Yes | Workflow name. |
input.description | String | No | Optional description. |
Returns
Section titled “Returns”WorkflowDto! — a freshly created workflow with empty nodes/edges, isPublished: false, version: 0.
Example request
Section titled “Example request”mutation CreateWorkflow($input: CreateWorkflowInput!) { createWorkflow(input: $input) { id name isPublished }}{ "input": { "name": "Support Triage", "description": "Routes incoming questions to the right specialist flow." }}Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "createWorkflow": { "id": 42, "name": "Support Triage", "isPublished": false } }}updateWorkflow
Section titled “updateWorkflow”Updates a Workflow, including its nodes/edges graph.
Auth: JWT dashboard auth only.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
input.id | Int! | Yes | Workflow to update. |
input.name | String | No | New name. |
input.description | String | No | New description. |
input.nodes | JSON | No | Full replacement node array, React Flow shape. |
input.edges | JSON | No | Full replacement edge array. |
Returns
Section titled “Returns”WorkflowDto! — the updated workflow.
nodes/edges are a full replacement, not a merge — at every level, not just the top one
Section titled “nodes/edges are a full replacement, not a merge — at every level, not just the top one”When you pass nodes, the entire stored array is replaced with exactly what you sent — not diffed, not merged field-by-field, not patched. This has two consequences that are easy to miss:
- Any node you don’t include in the array is deleted. If your workflow has 10 nodes and you call
updateWorkflowwith only the 2 you actually changed, the other 8 are gone. Always send the complete node list — read the current graph first (via theworkflowquery) if you’re only changing a subset, and re-send everything. - Any field you omit on a node you DO include is also dropped — the replacement is whole-object, not whole-array-but-per-field-merged. A common way to hit this: re-sending a node’s
configwith an updated value, but forgetting to also re-send itsposition— the node saves correctly, but itspositionis now gone, not left as whatever it was before. The same applies toedges: omit a field on an edge you’re re-sending, and it’s dropped the same way.
Concretely: fetch the current nodes/edges via the workflow query, modify only what you actually want to change on the in-memory objects, and send the complete, modified array back — never construct a thin/partial version of a node or edge from scratch when your intent is to change just one field on it.
nodes/edges are GraphQLJSON — you must use variables
Section titled “nodes/edges are GraphQLJSON — you must use variables”nodes and edges are GraphQLJSON scalars. GraphQL’s query-string syntax does not support raw JSON object literals with quoted field names — attempting to inline the graph directly into the query string fails with a confusing parse error, not a validation error.
Wrong — inline literal in the query string (fails to parse):
# DO NOT DO THIS — fails with "Syntax Error: Expected Name, found String \"id\""mutation { updateWorkflow(input: { id: 42, nodes: [{"id": "n_start"}] }) { id }}Right — pass the graph via a variables block:
mutation UpdateWorkflow($input: UpdateWorkflowInput!) { updateWorkflow(input: $input) { id version nodes edges }}{ "input": { "id": 42, "nodes": [ { "id": "n_start", "type": "start", "position": { "x": 0, "y": 0 }, "data": {} }, { "id": "n_intent", "type": "intent", "position": { "x": 200, "y": 0 }, "data": { "label": "Classify request" } } ], "edges": [{ "id": "e1", "source": "n_start", "target": "n_intent" }] }}Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "updateWorkflow": { "id": 42, "version": 0, "nodes": [ { "id": "n_start", "type": "start", "position": { "x": 0, "y": 0 }, "data": {} }, { "id": "n_intent", "type": "intent", "position": { "x": 200, "y": 0 }, "data": { "label": "Classify request" } } ], "edges": [{ "id": "e1", "source": "n_start", "target": "n_intent" }] } }}Note version is still 0 here — updateWorkflow saves the draft, it does not publish it. See publishWorkflow below.
For the full set of node types and how the graph is validated/executed, see Workflows Overview.
deleteWorkflow
Section titled “deleteWorkflow”Deletes a Workflow owned by the caller’s tenant.
Auth: JWT dashboard auth only.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
id | Int! | Yes | Workflow to delete. |
Returns
Section titled “Returns”Boolean! — true on success.
Example request
Section titled “Example request”mutation DeleteWorkflow($id: Int!) { deleteWorkflow(id: $id)}{ "id": 51 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "deleteWorkflow": true } }publishWorkflow
Section titled “publishWorkflow”Publishes the current draft of a Workflow — increments version and sets isPublished to true. Sessions started against the owning agent use the published version.
A workflow performs no actions at runtime until this mutation has been called at least once — a well-formed, fully-edited draft with correct nodes and edges still results in a session doing nothing (or falling back to a generic error reply) if it’s never been published.
Auth: JWT dashboard auth only.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
id | Int! | Yes | Workflow to publish. |
Returns
Section titled “Returns”WorkflowDto! — the workflow with isPublished: true and an incremented version.
Example request
Section titled “Example request”mutation PublishWorkflow($id: Int!) { publishWorkflow(id: $id) { id isPublished version }}{ "id": 42 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "publishWorkflow": { "id": 42, "isPublished": true, "version": 1 } }}aiGenerateWorkflow
Section titled “aiGenerateWorkflow”Generates a preview workflow graph (nodes/edges) from a plain-English description, via LLM. This does not persist anything — it’s a starting point for a human to review, not a finished, saved workflow. Pass the returned nodes/edges into updateWorkflow (after review) to actually save them, and remember publishWorkflow is still required afterward to make them live.
Auth: JWT dashboard auth only.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
input.prompt | String! | Yes | Plain-English description of the desired conversation flow. |
input.model | String | No | Optionally override the generation model. |
Returns
Section titled “Returns”GeneratedWorkflowDto!
| Field | Type | Description |
|---|---|---|
nodes | JSON! | Valid, ready-to-use nodes in React Flow shape. Safe to pass into updateWorkflow after user review. |
edges | JSON! | Valid edges connecting the generated nodes. |
rejectedNodes | [RejectedNodeDto!]! | Nodes the model attempted to generate that failed validation (unknown type, missing required config field). Show these as “could not fully generate” — don’t silently drop them. Each has node: JSON! (the raw attempted node) and problems: [String!]!. |
rejectedEdges | [RejectedEdgeDto!]! | Same idea for edges: edge: JSON! and problems: [String!]!. |
model | String! | The model actually used for generation. |
Example request
Section titled “Example request”mutation GenerateWorkflow($input: AiGenerateWorkflowInput!) { aiGenerateWorkflow(input: $input) { model nodes edges rejectedNodes { node problems } rejectedEdges { edge problems } }}{ "input": { "prompt": "Greet the caller, ask if they want billing or technical support, then hand off to the matching specialist flow." }}Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "aiGenerateWorkflow": { "model": "gemini-2.5-pro", "nodes": [ { "id": "n_start", "type": "start", "position": { "x": 0, "y": 0 }, "data": {} }, { "id": "n_greet", "type": "response", "position": { "x": 200, "y": 0 }, "data": { "message": "Hi! Are you calling about billing or a technical issue?" } }, { "id": "n_route", "type": "intent", "position": { "x": 400, "y": 0 }, "data": { "label": "Route to specialist" } } ], "edges": [ { "id": "e1", "source": "n_start", "target": "n_greet" }, { "id": "e2", "source": "n_greet", "target": "n_route" } ], "rejectedNodes": [], "rejectedEdges": [] } }}Once you’ve reviewed the graph (and made any manual edits), save it with updateWorkflow and go live with publishWorkflow.
nodeConfigSchema / nodeConfigSchemas
Section titled “nodeConfigSchema / nodeConfigSchemas”Machine-readable versions of the per-node-type reference pages: a real JSON Schema (draft 2020-12) document describing each node type’s config object, generated from the backend’s own validation code — not a Wetel-specific format. Built for anyone rendering their own workflow editor (see Build Your Own Admin Panel) so the UI can be form-generated from the live schema instead of a hand-maintained copy.
Auth: JWT or X-Api-Key — the same dual-auth posture as certifiedOperations, so a headless integration can fetch these without a dashboard login.
nodeConfigSchema(type: NodeType!): JSON!nodeConfigSchemas: [NodeTypeSchemaDto!]!Arguments
Section titled “Arguments”| Argument | Type | Required | Notes |
|---|---|---|---|
type | NodeType! | yes | LLM, CONDITION, ROUTER, RESPONSE, WEBHOOK, END_SESSION, TOOL, ACTION, SUB_AGENT. START has no config and returns an empty object schema. |
nodeConfigSchemas takes no arguments and returns one { type, schema } entry for each of the 9 node types that have a config (START is omitted).
Returns
Section titled “Returns”A JSON Schema object per node type. Every property is optional and additionalProperties is allowed — a node’s config is intentionally an open bag, so the schema documents the known, named fields without rejecting unknown ones. Use it to build forms and validate what you do set; don’t use it to reject configs that carry extra keys.
Example request
Section titled “Example request”query { nodeConfigSchemas { type schema }}Two things the schema won’t tell you
Section titled “Two things the schema won’t tell you”- The
typeenum on the wire is UPPERCASE (LLM), but thetypevalue inside your node JSON must be lowercase ("llm").nodes/edgesare aJSONscalar with no enum validation, so"LLM"in a node is not rejected at save time — it fails at execution. - Field presence ≠ field compatibility. The
llmschema lists bothsystemPromptandpromptTemplate, but settingsystemPromptmakes the executor ignorepromptTemplate(see the LLM node page). A form generator should defaultsystemPromptto hidden/advanced.
runWorkflowTask
Section titled “runWorkflowTask”Runs one of your agents’ published workflows as a task — no chat message, no conversational session to drive, no reply to collect. It exists for the case where an external system already knows exactly what it wants done and has the facts to do it with: an orchestrator’s webhook step, a channel gateway, your own backend, a scheduled job, or a plain cURL.
This is the deterministic alternative to driving the same workflow through sdkStart + sdkSendMessage. Use it when all three of these are true:
- The values your workflow needs are already resolved on your side (an id you looked up, a URL you already hold) — you do not want an LLM node re-deriving them from message text.
- There is no end user waiting on a reply. This entry point produces no conversational response to relay.
- You can authenticate server-side with an API key. There is no browser-safe variant of this call.
If any of those is false, you want the conversational SDK flow instead.
runWorkflowTask(input: RunWorkflowTaskInput!): RunWorkflowTaskResult!Auth: X-Api-Key only — this operation does not accept a dashboard JWT. The tenant is resolved from the key itself, so RunWorkflowTaskInput has no tenant field (and GraphQL will reject one if you add it). The agent is looked up as (agentId, your tenant), which means a key can only ever run its own tenant’s agents.
Never ship this key to a browser. Like every other API-key operation, it is a server-side credential — anyone holding it can run any of your agents’ workflows.
It is asynchronous, and “accepted” is not “succeeded”
Section titled “It is asynchronous, and “accepted” is not “succeeded””A successful return means the run was validated and enqueued — nothing more. The workflow executes on the same queue every chat turn uses, after this mutation has already returned. Do not treat a 200 here as evidence the work happened, and do not infer success from any reply text.
Read the real outcome from the run itself, using the sessionId this mutation returns:
query TaskOutcome($sessionId: Int!) { workflowRuns(sessionId: $sessionId) { id status nodeTrace context }}Caveat worth planning for: workflowRuns / workflowRun are JWT dashboard queries — they are not reachable with the API key you used to start the task. Today that means an API-key-only integration can start tasks but cannot read their node traces with the same credential. Two practical options: check outcomes from the dashboard (or a dashboard-authenticated backoffice job), and/or have the workflow itself report its own result outward — a terminal webhook node posting back to you is the pattern that keeps everything on one credential. sessionsByExternalCustomerId is API-key reachable and will show you the session (pass the same string you sent as externalConversationId), but it does not carry the run’s node trace.
Arguments
Section titled “Arguments”All fields live under a single input object.
| Field | Type | Required | Description |
|---|---|---|---|
agentId | Int! | Yes | The agent whose published workflow runs. The agent must be ACTIVE and must have a workflow attached. You cannot name a workflowId — the agent owns which graph runs, exactly as it does for a chat turn. |
externalConversationId | String! | Yes | Your own conversation/thread identifier (max 255 chars) — an orchestrator conversation id, a chat id, a ticket reference. Becomes the session’s clientExternalId; see Session reuse below. |
variables | JSON | No | A flat map of scalars merged verbatim into the workflow run’s starting context, readable by your nodes as {{jobPostingId}} (Handlebars) or jobPostingId > 0 (a condition expression). Rules below. |
attachmentUrl | String | No | Absolute http(s) URL (max 2048 chars) of a file the workflow should fetch — e.g. an action node’s fetchAsBase64Var reads it from attachmentUrl. Must be reachable by Wetel’s backend. |
attachmentFilename | String | No | Original filename (max 255 chars). Used for the mime fallback below, and available to templates. |
attachmentMime | String | No | Content type (max 255 chars). Optional: when omitted it is derived from attachmentFilename’s extension, then from the URL path’s extension — so a .pdf still satisfies an attachmentMime == "application/pdf" gate. |
clientTurnId | String | No | Opaque correlation token (max 255 chars), echoed back verbatim on any AiResponseEvent this run publishes. Not persisted, not interpreted — same semantics as SdkSendMessageInput.clientTurnId. |
variables rules
Section titled “variables rules”The endpoint is deliberately domain-agnostic — it does not know or care what any of your keys mean, and it does not coerce, trim, or reformat a single value. What it guarantees is that each value reaches the run’s context unchanged, under a name your graph can read. What it enforces:
- At most 32 keys.
- Keys must match
^[A-Za-z][A-Za-z0-9_]{0,63}$— a bare word starting with a letter.{{foo-bar}}is a Handlebars subexpression andfoo.baris a member access, so neither is a usable variable name. - Values must be scalars: string, number, boolean, or
null. Nested objects and arrays are rejected. (This is not fussiness —expr-evalthrows on a member access against a missing intermediate, which fails the whole run instead of taking the false branch. Flatten structure into distinct keys, which also makes each one gateable on its own.) - Strings are capped at 4,096 characters; numbers must be finite.
- Reserved names are rejected with a
400, not silently dropped. These belong to the workflow runner:sessionId,tenantId,agentId,interruptGeneration,clientExternalId,clientTurnId,userMessage,conversationHistory,turnCount,session,attachment,channel,attachmentUrl,attachmentMime,attachmentFilename. Keys may not start with_either.
What the run’s context looks like at start
Section titled “What the run’s context looks like at start”Alongside your variables, the run begins with:
| Context key | Value |
|---|---|
userMessage | "" — a task has no user utterance. Empty string, never undefined, so {{userMessage}} renders blank and a condition comparing it is well-defined. |
conversationHistory | [] |
turnCount | This task’s turn number on the session. |
attachment | { url, mime, filename }, or null when you sent no attachmentUrl. |
attachmentUrl / attachmentMime / attachmentFilename | Flat mirrors of the same values — "" when absent. Use these in condition expressions (attachmentUrl != ""), never attachment.url. |
That is byte-for-byte the same attachment shape a chat channel’s attachment produces, which is the point: one graph can serve both a conversational caller and a task caller without branching on how it was invoked.
Gate every id before it reaches an outbound call
Section titled “Gate every id before it reaches an outbound call”runWorkflowTask validates the shape of your input, never its meaning — it has no idea which of your variables is an id or what “valid” would mean for it. That check belongs in the graph, as a condition node placed before the action/tool node that writes anything outward, so a missing or junk value routes to a safe terminal branch instead of firing a request.
Write the gate as someId > 0, never someId != 0. expr-eval’s equality operators do not coerce types, so the string "0" is != 0 → true and sails straight through the gate; > uses relational semantics and is correct for the number 19, the string "19", "", "0", and prose alike. A variable you never sent at all also evaluates false (see Condition Node), so the same gate covers the absent case too.
Session reuse
Section titled “Session reuse”A task runs on a real Wetel session, so everything you already know about sessions keeps working.
The session is resolved by (your tenant, agentId, externalConversationId), matching the most recent ACTIVE one:
- No live session for that combination → a new one is created,
reusedSession: false,turnCount: 1. - A live session exists → it is reused,
reusedSession: true, andturnCountincrements. Every task for that conversation therefore lands on one timeline you can read in a singleworkflowRuns(sessionId)call.
Use a stable, unique externalConversationId per logical conversation. Reusing one string across unrelated work piles unrelated runs onto one session; generating a fresh one per call gives you a new session every time.
Returns
Section titled “Returns”RunWorkflowTaskResult!
| Field | Type | Description |
|---|---|---|
sessionId | Int! | The session this task ran on. Keep this — it is the only handle for reading the outcome. |
workflowId | Int! | The published workflow that was resolved from the agent. |
agentId | Int! | Echo of the agent that ran. |
turnCount | Int! | The session turn number this task consumed. |
reusedSession | Boolean! | true when an existing ACTIVE session was joined, false when a new one was created. |
accepted | Boolean! | Always true on a successful return — the run was validated and enqueued. Again: accepted, not succeeded. |
Example request
Section titled “Example request”Filing an application against a partner system, where the caller has already resolved the posting id and holds the document URL:
mutation RunWorkflowTask($input: RunWorkflowTaskInput!) { runWorkflowTask(input: $input) { sessionId workflowId agentId turnCount reusedSession accepted }}{ "input": { "agentId": 12, "externalConversationId": "orchestrator:conversation:EXAMPLE-8f3c1a", "variables": { "jobPostingId": 19, "companyId": 20, "sourceChannel": "web" }, "attachmentUrl": "https://files.example.com/uploads/EXAMPLE-8f3c1a/resume.pdf", "attachmentFilename": "applicant-resume.pdf", "attachmentMime": "application/pdf", "clientTurnId": "task-EXAMPLE-0001" }}Headers:
X-Api-Key: <your-api-key>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "runWorkflowTask": { "sessionId": 305, "workflowId": 42, "agentId": 12, "turnCount": 1, "reusedSession": false, "accepted": true } }}Errors
Section titled “Errors”| Condition | Result |
|---|---|
Missing X-Api-Key header, or a revoked/expired/unknown key | 401 — Missing X-Api-Key header / Invalid API key |
agentId does not exist or belongs to another tenant (deliberately indistinguishable) | 404 — Agent <id> not found |
Agent exists but is not ACTIVE | 400 — Agent <id> is not ACTIVE — a task cannot be run against it |
| Agent has no workflow attached | 400 — Agent <id> has no workflow attached — runWorkflowTask requires one |
variables breaks any rule above (too many keys, bad key, reserved key, non-scalar, too long) | 400, naming the offending key |
attachmentUrl is not an absolute http(s) URL | 400 — attachmentUrl must be an absolute http(s) URL |
| Over the rate limit | 429 semantics — see Rate Limiting |
Note the deliberate omission: there is no error for “the workflow failed.” It cannot exist here — the run has not started yet when this mutation returns. A graph that fails, or that routes to a terminal “I can’t do that” branch, still returns a perfectly successful accepted: true from this call. Read workflowRuns(sessionId) for what actually happened.
Rate limit
Section titled “Rate limit”60 requests per 60 seconds, per client IP (not per API key or tenant). Looser than sdkStart’s 30/min because a machine orchestrator legitimately bursts — a queue drain, a retry sweep — where a human-driven embed does not. See Rate Limiting.
workflowRun
Section titled “workflowRun”Debugging aid: fetches a single WorkflowRun by id, showing exactly which nodes a session’s run visited, in order, and why. Useful for diagnosing a workflow that isn’t behaving as expected — e.g. a node that should have fired but didn’t, or a branch that took an unexpected path.
Auth: JWT dashboard auth only — not reachable via API key.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
id | Int! | Yes | The workflow run’s id. |
Returns
Section titled “Returns”WorkflowRunDto (nullable).
| Field | Type | Description |
|---|---|---|
id | Int! | Run id. |
tenantId | Int! | Owning tenant. |
workflowId | Int! | The workflow that was executed. |
agentId | Int | The agent that owned the session, if applicable. |
sessionId | Int | The session this run belongs to. |
status | WorkflowRunStatus! | One of PENDING, RUNNING, COMPLETED, FAILED, INTERRUPTED, AWAITING_INPUT. |
nodeTrace | JSON! | Ordered list of nodes visited during this run, with enough detail to see why each transition happened. |
context | JSON! | The accumulated workflow context/variables at the time this data was captured. |
errorMessage | String | Populated when status is FAILED. |
resumedFromRunId | Int | Set only when this run continued a run that had paused at an await_reply node: the id of that paused (AWAITING_INPUT) run. null for an ordinary turn. |
totalTokensUsed | Int! | Aggregate LLM token usage across all nodes in this run. |
createdAt / updatedAt / completedAt | DateTime! / DateTime! / DateTime | Timestamps. |
Two statuses are worth calling out specifically:
AWAITING_INPUT— the run paused at anawait_replynode and is waiting on the user. It is notCOMPLETEDand it is not stuck; the reply continues it as a separate run whoseresumedFromRunIdpoints back here.pendingAwaitRepliesis the query for “what is waiting, and until when”.FAILEDwith aBUDGET_EXCEEDEDentry innodeTrace— the run exhausted its execution budget. See Run limits andBUDGET_EXCEEDED.
Note also that nodeTrace may now contain more than one entry for the same nodeId, because a node can be configured to run more than once per run. See Loop re-entry and VISIT_LIMIT_REACHED.
Example request
Section titled “Example request”query GetWorkflowRun($id: Int!) { workflowRun(id: $id) { id status nodeTrace context errorMessage totalTokensUsed }}{ "id": 981 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "workflowRun": { "id": 981, "status": "COMPLETED", "nodeTrace": [ { "nodeId": "n_start", "type": "start", "enteredAt": "2026-08-09T10:00:00.000Z" }, { "nodeId": "n_intent", "type": "intent", "enteredAt": "2026-08-09T10:00:01.200Z", "result": "billing" }, { "nodeId": "n_billing_flow", "type": "response", "enteredAt": "2026-08-09T10:00:02.400Z" } ], "context": { "detectedIntent": "billing" }, "errorMessage": null, "totalTokensUsed": 842 } }}workflowRuns
Section titled “workflowRuns”Debugging aid: lists every WorkflowRun for a given session, newest first. This is the way to discover a run’s id when you only have the sessionId — there is no other lookup path. (The alternative is capturing workflowRunId live off a NodeExecutingEvent/NodeFailedEvent subscription payload while that turn is happening.) Each entry’s id can then be passed to workflowRun(id) for the full nodeTrace.
Auth: JWT dashboard auth only — not reachable via API key.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
sessionId | Int! | Yes | The session to list runs for. |
Returns
Section titled “Returns”[WorkflowRunDto!]! — same shape as workflowRun above.
Example request
Section titled “Example request”query ListWorkflowRuns($sessionId: Int!) { workflowRuns(sessionId: $sessionId) { id status workflowId createdAt completedAt }}{ "sessionId": 305 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "workflowRuns": [ { "id": 981, "status": "COMPLETED", "workflowId": 42, "createdAt": "2026-08-09T10:00:00.000Z", "completedAt": "2026-08-09T10:00:03.000Z" }, { "id": 964, "status": "FAILED", "workflowId": 42, "createdAt": "2026-08-08T09:12:00.000Z", "completedAt": "2026-08-08T09:12:01.500Z" } ] }}Run limits and BUDGET_EXCEEDED
Section titled “Run limits and BUDGET_EXCEEDED”Added 2026-09-21. Every workflow run executes under an explicit budget, and every graph is bounded at save time. The full table of limits and their defaults lives in Workflow Overview: Graph size and run limits; this section covers what you actually see over the API.
At save/publish time — updateWorkflow (drafts included) and publishWorkflow reject a graph over 200 nodes or 400 edges:
{ "errors": [ { "message": "Workflow has 214 nodes, exceeding the maximum of 200" } ]}At run time — a run that exceeds 250 node executions or 10 minutes of wall-clock time stops and is persisted as status: "FAILED". There is no distinct run status for it; the detail is in the nodeTrace, in one extra entry attributed to the node the engine refused to dispatch:
nodeTrace field | Type | Description |
|---|---|---|
status | "BUDGET_EXCEEDED" | Distinct from "FAILED" — this node never ran; the run ran out of budget in front of it. |
budgetLimit | "maxNodeExecutions" | "deadline" | Which of the two ceilings was hit. |
budgetNodeExecutions | number | Node dispatches completed before the breach was detected. |
budgetElapsedMs | number | Wall-clock milliseconds elapsed when the breach was detected. |
nodeId / nodeType | string | The node that was not dispatched. |
error | string | Human-readable message naming the limit, the blocked node, and the elapsed time. |
{ "data": { "workflowRun": { "id": 1042, "status": "FAILED", "errorMessage": "Workflow run exceeded its wall-clock budget of 600000ms after 600118ms and 41 node executions (stopped before node 'n_poll_partner'). …", "nodeTrace": [ { "nodeId": "n_start", "nodeType": "start", "status": "COMPLETED" }, "…", { "nodeId": "n_poll_partner", "nodeType": "action", "status": "BUDGET_EXCEEDED", "budgetLimit": "deadline", "budgetNodeExecutions": 41, "budgetElapsedMs": 600118, "startedAt": "2026-09-21T09:24:03.128Z", "finishedAt": "2026-09-21T09:24:03.128Z" } ] } }}The three budget* fields appear only on a BUDGET_EXCEEDED entry, so trace-parsing code written before this shipped is unaffected. Branch on entry.status === "BUDGET_EXCEEDED" rather than parsing errorMessage text. Note the budget is checked between node dispatches, never mid-node — a node already in flight always finishes, so a single hanging call is bounded by that node’s own timeoutMs, not by this.
Loop re-entry and VISIT_LIMIT_REACHED
Section titled “Loop re-entry and VISIT_LIMIT_REACHED”Added 2026-09-22. A node may now run more than once in a single run, bounded by a maxVisits field on its own config. The conceptual model — how the bound behaves, the 'loop_exhausted' escape edge, and a worked bounded-retry graph — is in Workflow Overview: Loops and re-entry; this section covers what it looks like over the API.
At save/publish time — maxVisits is an optional Int on every node type’s config, defaulting to 1. updateWorkflow (drafts included) and publishWorkflow reject a value that is not a positive integer or is above the ceiling of 100:
{ "errors": [ { "message": "Node \"n_submit\" has maxVisits 400, exceeding the maximum of 100" } ]}It is also carried by nodeConfigSchema / nodeConfigSchemas for every type, so a form-generating editor picks it up with no special-casing.
At run time — reaching a node that has used up its budget writes one extra nodeTrace entry:
nodeTrace field | Type | Description |
|---|---|---|
status | "VISIT_LIMIT_REACHED" | Distinct from "FAILED" — this dispatch never happened; the node had run out of visits. |
visitLimit | number | The node’s effective maxVisits (1 when it never declared one). |
visitCount | number | How many times it had already run in this run. |
nodeId / nodeType | string | The node that was not dispatched again. |
{ "data": { "workflowRun": { "id": 1187, "status": "COMPLETED", "nodeTrace": [ { "nodeId": "n_start", "nodeType": "start", "status": "COMPLETED" }, { "nodeId": "n_submit", "nodeType": "action", "status": "FAILED_HANDLED" }, { "nodeId": "n_wait_note", "nodeType": "response", "status": "COMPLETED" }, { "nodeId": "n_submit", "nodeType": "action", "status": "FAILED_HANDLED" }, "…", { "nodeId": "n_submit", "nodeType": "action", "status": "VISIT_LIMIT_REACHED", "visitLimit": 3, "visitCount": 3, "startedAt": "2026-09-22T04:10:51.402Z", "finishedAt": "2026-09-22T04:10:51.402Z" }, { "nodeId": "n_give_up", "nodeType": "response", "status": "COMPLETED" } ] } }}Unlike BUDGET_EXCEEDED, this entry does not on its own mean the run failed. Read the run’s own status to know that:
- The node has a
'loop_exhausted'edge — the run follows it and generally still reachesCOMPLETED, as above. - No escape edge and
maxVisitswas declared — the run isFAILED, anderrorMessagereadsWorkflow node 'n_submit' (action) exhausted its visit budget of 3 …. - No escape edge and
maxVisitswas never declared — that branch stops, the rest of the graph carries on, and the run’s status is whatever the rest of it produces. This is the pre-2026-09-22 behavior, now with a trace entry where there used to be none.
Two notes for anything parsing traces:
visitLimitandvisitCountappear only on aVISIT_LIMIT_REACHEDentry, so existing parsing is unaffected — butnodeIdis no longer unique within anodeTracearray. Code keyed on “one entry per node” should key on array position instead.- At most one
VISIT_LIMIT_REACHEDentry is written per node per run, no matter how many edges lead back into it.
pendingAwaitReplies
Section titled “pendingAwaitReplies”Added 2026-09-22. Lists conversations currently paused at an await_reply node — a run that asked the user a question, ended with status AWAITING_INPUT, and will continue from that node’s successor when the next message on that session arrives. Newest first, scoped to your tenant.
Pass sessionId to check one conversation — this is the answer to “is my test run hung, or is it waiting on me?” Omit it for every open wait in the tenant, which is the operational view of what is waiting and for how long.
Auth: JWT dashboard auth only — not reachable via API key.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
sessionId | Int | No | Restrict to one session. Omit for every open wait in tenant. |
Returns
Section titled “Returns”[WorkflowRunWaitDto!]!
| Field | Type | Description |
|---|---|---|
id | Int! | The wait’s own id. |
tenantId / sessionId | Int! | Owning tenant and the paused conversation. |
workflowId | Int! | The workflow that asked. |
agentId | Int | The agent that owned the session, if applicable. |
workflowRunId | Int! | The run that asked the question — the one persisted as AWAITING_INPUT. Pass it to workflowRun(id) for the node trace up to the pause. |
nodeId | String! | Graph node id of the await_reply node that is waiting. |
saveAs | String! | Context variable the answer will be written to on resume. |
matchMode | AwaitReplyMatchMode! | FREE_TEXT or OPTIONS. |
options | [AwaitReplyOptionDto!]! | The option list fixed at ask time — { slot, id, label } each. Empty in FREE_TEXT mode. Matching a reply reads only this. |
status | WorkflowRunWaitStatus! | WAITING, RESUMED, EXPIRED or CANCELLED. This query returns open (WAITING) waits. |
expiresAt | DateTime! | After this instant the wait is no longer resumable and the next message starts a fresh run from start. |
createdAt / updatedAt | DateTime! | Timestamps — createdAt is when the question was asked. |
The stored workflow context a paused run will resume with is deliberately not exposed on this type, or on any other query.
Example request
Section titled “Example request”query PendingAwaitReplies($sessionId: Int) { pendingAwaitReplies(sessionId: $sessionId) { id sessionId workflowRunId nodeId saveAs matchMode options { slot id label } expiresAt createdAt }}{ "sessionId": 305 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "pendingAwaitReplies": [ { "id": 77, "sessionId": 305, "workflowRunId": 1043, "nodeId": "ask_which_order", "saveAs": "chosenOrder", "matchMode": "OPTIONS", "options": [ { "slot": 1, "id": "SO-4417", "label": "Desk lamp — placed 12 Sep" }, { "slot": 2, "id": "SO-4462", "label": "Office chair — placed 18 Sep" } ], "expiresAt": "2026-09-23T04:11:52.000Z", "createdAt": "2026-09-22T04:11:52.000Z" } ] }}An empty array means nothing is waiting — either the conversation was never paused, or the reply has already arrived and the follow-on run has started (look for a run whose resumedFromRunId points at workflowRunId).
Regression scenarios
Section titled “Regression scenarios”Lets an author freeze a real, already-completed workflow run as a named baseline — so that when the workflow is edited later, there’s a saved record of exactly what a real conversation looked like and which nodes it visited, and a way to automatically re-run it against the current draft. This exists because of a documented, real failure mode: an intent/classification-style LLM node can silently misroute after a model change (a different provider, a prompt tweak, a default-model swap), with no error anywhere — the workflow just quietly starts taking a different branch. A saved scenario is the baseline a replay diffs against.
Pass/fail is structural, not text-based. A replay compares the new run’s node-visitation sequence, router/condition branch taken, tool/action calls, and terminal node against the scenario’s frozen assertions — never the literal wording of an LLM/voice response. LLM responses are non-deterministic by nature; a semantic/content-quality check may be added later as a separate, non-blocking signal, but today’s pass/fail is purely about whether the workflow took the same structural path.
Auth: JWT dashboard auth only — not reachable via API key.
captureRegressionScenario
Section titled “captureRegressionScenario”Saves a new regression scenario captured from an already-completed workflow run. workflowRunId must reference a run that finished with status: COMPLETED and has an associated session — the mutation reconstructs the turn-by-turn input script from that session’s user messages (in order), snapshots the run’s nodeTrace as the baseline, and derives structural assertions (node-visitation sequence, router branch decisions, tool/action calls, terminal node) from that trace.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
workflowRunId | Int! | Yes | A completed WorkflowRun id (see workflowRun/workflowRuns above) to capture as the baseline. |
name | String! | Yes | Display name for the scenario. |
description | String | No | Optional free-text notes. |
Returns
Section titled “Returns”WorkflowRegressionScenarioDto!
| Field | Type | Description |
|---|---|---|
id | Int! | Scenario id. |
tenantId | Int! | Owning tenant. |
workflowId | Int! | The workflow this scenario belongs to. |
sourceWorkflowRunId | Int! | The WorkflowRun this scenario was captured from. |
name | String! | Display name. |
description | String | Free-text notes, if given. |
inputScript | JSON! | The reconstructed turn-by-turn user input, derived from the source session’s messages. |
baselineNodeTrace | JSON! | A snapshot of the source run’s nodeTrace — the baseline any future replay would diff against. |
assertions | JSON! | Structural assertions derived from the trace (node sequence, router decisions, tool/action calls, terminal node). |
isActive | Boolean! | Whether the scenario is active. |
lastResultStatus | RegressionResultStatus | One of PASSED, FAILED, FLAGGED — null until the scenario has been replayed via runRegressionScenario at least once. |
lastRunAt | DateTime | null until the scenario has been replayed at least once. |
createdAt/updatedAt | DateTime! | Timestamps. |
Example request
Section titled “Example request”mutation CaptureRegressionScenario( $workflowRunId: Int! $name: String! $description: String) { captureRegressionScenario( workflowRunId: $workflowRunId name: $name description: $description ) { id name sourceWorkflowRunId inputScript baselineNodeTrace assertions lastResultStatus }}{ "workflowRunId": 981, "name": "Billing intent — happy path", "description": "Baseline captured before swapping the intent node's model."}Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "captureRegressionScenario": { "id": 7, "name": "Billing intent — happy path", "sourceWorkflowRunId": 981, "inputScript": [ { "turn": 1, "userMessage": "I have a question about my bill" } ], "baselineNodeTrace": [ { "nodeId": "n_start", "type": "start", "enteredAt": "2026-08-09T10:00:00.000Z" }, { "nodeId": "n_intent", "type": "intent", "enteredAt": "2026-08-09T10:00:01.200Z", "result": "billing" }, { "nodeId": "n_billing_flow", "type": "response", "enteredAt": "2026-08-09T10:00:02.400Z" } ], "assertions": { "nodeSequence": ["n_start", "n_intent", "n_billing_flow"], "routerDecisions": { "n_intent": "billing" }, "terminalNode": "n_billing_flow" }, "lastResultStatus": null } }}workflowRegressionScenarios
Section titled “workflowRegressionScenarios”Lists every regression scenario captured for a given workflow, scoped to the caller’s tenant, most recently created first.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
workflowId | Int! | Yes | The workflow to list scenarios for. |
Returns
Section titled “Returns”[WorkflowRegressionScenarioDto!]! — same shape as captureRegressionScenario above.
Example request
Section titled “Example request”query ListRegressionScenarios($workflowId: Int!) { workflowRegressionScenarios(workflowId: $workflowId) { id name sourceWorkflowRunId lastResultStatus lastRunAt createdAt }}{ "workflowId": 42 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "workflowRegressionScenarios": [ { "id": 7, "name": "Billing intent — happy path", "sourceWorkflowRunId": 981, "lastResultStatus": null, "lastRunAt": null, "createdAt": "2026-09-01T09:00:00.000Z" } ] }}runRegressionScenario
Section titled “runRegressionScenario”Triggers a replay of a saved scenario against the workflow’s current live draft (not its published snapshot) — the same dry-run mechanism used when testing a workflow directly in the editor. Because a replay involves at least one real LLM turn, this mutation returns immediately, before the replay finishes — it runs on a background queue, the same async pattern sendMessage itself uses for its own reply. The scenario object this mutation returns reflects lastResultStatus/lastRunAt from before this call, not the outcome of the replay you just triggered.
To see the result, poll workflowRegressionScenarios (denormalized latest status) or workflowRegressionResults (below, full history) a few seconds later.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
scenarioId | Int! | Yes | The scenario to replay. |
Returns
Section titled “Returns”WorkflowRegressionScenarioDto! — same shape as captureRegressionScenario above, but reflecting pre-replay state. Fails immediately (before any replay starts) if the scenario doesn’t exist or isActive is false.
Example request
Section titled “Example request”mutation RunRegressionScenario($scenarioId: Int!) { runRegressionScenario(scenarioId: $scenarioId) { id name lastResultStatus lastRunAt }}{ "scenarioId": 7 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerworkflowRegressionResults
Section titled “workflowRegressionResults”Lists every replay recorded for a given scenario, most recently created first — the full history, not just the denormalized latest status workflowRegressionScenarios exposes.
Arguments
Section titled “Arguments”| Name | Type | Required | Description |
|---|---|---|---|
scenarioId | Int! | Yes | The scenario whose replay history to list. |
Returns
Section titled “Returns”[WorkflowRegressionResultDto!]!
| Field | Type | Description |
|---|---|---|
id | Int! | Result id. |
tenantId | Int! | Owning tenant. |
scenarioId | Int! | The scenario this result belongs to. |
workflowRunId | Int! | The WorkflowRun this replay produced — inspect its nodeTrace for the full run detail. |
status | RegressionResultStatus! | PASSED, FAILED, or FLAGGED. |
assertionResults | JSON! | Per-assertion pass/fail detail — which structural checks passed, which diverged, and why. |
createdAt/updatedAt | DateTime! | Timestamps. |
Example request
Section titled “Example request”query ListRegressionResults($scenarioId: Int!) { workflowRegressionResults(scenarioId: $scenarioId) { id status workflowRunId assertionResults createdAt }}{ "scenarioId": 7 }Headers:
Authorization: Bearer <your-jwt>x-huat-platform: customerExample response
Section titled “Example response”{ "data": { "workflowRegressionResults": [ { "id": 3, "status": "FAILED", "workflowRunId": 1042, "assertionResults": [ { "assertion": { "type": "ROUTER_BRANCH", "nodeId": "n_intent" }, "passed": false, "detail": "Expected next node 'n_billing_flow', got 'n_default_fallback'." } ], "createdAt": "2026-09-06T10:00:00.000Z" } ] }}Deployment tips: PoC vs. production workflows
Section titled “Deployment tips: PoC vs. production workflows”Whether you’re wiring up a five-minute demo or a workflow that will handle real customer traffic, the same API primitives apply — but the checklist you should run through before calling it “done” differs quite a bit.
For a proof-of-concept / demo
Section titled “For a proof-of-concept / demo”- It’s fine to skip
aiGenerateWorkflowreview and lightly edit the generated graph by hand before saving — for a one-off demo, “good enough to show the flow” is a reasonable bar. - Always double check
publishWorkflowwas called after your lastupdateWorkflow— this is the single most common reason a demo looks broken five minutes before it starts, even though the graph itself is correct. - Keep the graph small and linear. A PoC audience is evaluating the concept, not edge-case branch coverage — extra branches you haven’t tested live are a bigger demo risk than a missing feature.
- Run through
workflowRun/workflowRunsonce against a real test session before the demo, not during it — confirm thenodeTracematches what you expect a live audience to see. - Treat any
mcp connectoror custom action used in a demo workflow as a single point of failure — if the third-party service it calls is flaky or rate-limited, have a fallback response node ready rather than letting the workflow surface a raw tool error mid-demo.
For production
Section titled “For production”- Never publish directly from
aiGenerateWorkflowoutput without a human review pass — inspectrejectedNodes/rejectedEdgesand manually verify every branch’s actual behavior, not just that it validated. - Version discipline matters:
publishWorkflowincrementsversionbut keeps prior versions’ history implicit in that counter, not as separately retrievable snapshots today — so keep your own copy of thenodes/edgesJSON you send toupdateWorkflow(e.g. in your own source control) before each publish, so you can manually reconstruct a rollback if a new version misbehaves. - Treat
workflowRun/workflowRunsas your primary production debugging tool for “the agent didn’t do what I expected” reports — pull thenodeTracefor the affected session before assuming the LLM or a connected tool is at fault. - Any node in the graph that calls out to an MCP connector or custom action is a network dependency with its own availability and latency characteristics — test failure paths (the third party timing out or erroring) explicitly, not just the happy path.
- Because none of the workflow-management operations on this page are reachable via API key, any automated CI/CD-style deployment of workflow changes (e.g. promoting a graph from a staging tenant to a production tenant) needs to run under a JWT-authenticated service identity, not an API key integration — plan your automation accordingly.
- Load-test workflows with LLM-backed nodes (
intent,response, generation-heavy nodes) separately from purely deterministic nodes —totalTokensUsedonworkflowRunDtois a good place to start estimating per-conversation cost before scaling traffic.
See Agents API for how a published workflow gets attached to and run by an agent, MCP & Actions API for the connector/action operations referenced above, and API Reference Overview for general auth and header conventions.