Skip to content

Workflows API

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:

  1. createWorkflow({ name, description }) → returns a WorkflowDto with an id, nodes: [], edges: [].
  2. updateWorkflow({ id, nodes, edges }) → writes the actual graph onto that workflow.
  3. 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.)

Fetches a single Workflow by id, including its nodes/edges JSON, scoped to the caller’s tenant.

Auth: JWT dashboard auth only.

NameTypeRequiredDescription
idInt!YesThe workflow’s id.

WorkflowDto (nullable — null if no workflow with that id exists for the caller’s tenant).

FieldTypeDescription
idInt!Workflow id.
tenantIdInt!Owning tenant.
nameString!Display name.
descriptionStringOptional free-text description.
nodesJSON!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.
edgesJSON!The draft graph’s edges — same caveat as nodes.
isPublishedBoolean!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.
versionInt!Incremented on every publishWorkflow call.
createdAtDateTime!Creation timestamp.
updatedAtDateTime!Last update timestamp.
query GetWorkflow($id: Int!) {
workflow(id: $id) {
id
name
isPublished
version
nodes
edges
}
}
{ "id": 42 }

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer
{
"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" }]
}
}
}

Lists every Workflow belonging to the caller’s tenant.

Auth: JWT dashboard auth only.

None.

[WorkflowDto!]! — same shape as workflow above, one entry per workflow.

query ListWorkflows {
workflows {
id
name
isPublished
version
}
}

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer
{
"data": {
"workflows": [
{ "id": 42, "name": "Support Triage", "isPublished": true, "version": 3 },
{
"id": 51,
"name": "Onboarding Draft",
"isPublished": false,
"version": 0
}
]
}
}

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.

NameTypeRequiredDescription
input.nameString!YesWorkflow name.
input.descriptionStringNoOptional description.

WorkflowDto! — a freshly created workflow with empty nodes/edges, isPublished: false, version: 0.

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: customer
{
"data": {
"createWorkflow": {
"id": 42,
"name": "Support Triage",
"isPublished": false
}
}
}

Updates a Workflow, including its nodes/edges graph.

Auth: JWT dashboard auth only.

NameTypeRequiredDescription
input.idInt!YesWorkflow to update.
input.nameStringNoNew name.
input.descriptionStringNoNew description.
input.nodesJSONNoFull replacement node array, React Flow shape.
input.edgesJSONNoFull replacement edge array.

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:

  1. Any node you don’t include in the array is deleted. If your workflow has 10 nodes and you call updateWorkflow with only the 2 you actually changed, the other 8 are gone. Always send the complete node list — read the current graph first (via the workflow query) if you’re only changing a subset, and re-send everything.
  2. 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 config with an updated value, but forgetting to also re-send its position — the node saves correctly, but its position is now gone, not left as whatever it was before. The same applies to edges: 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: customer
{
"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.

Deletes a Workflow owned by the caller’s tenant.

Auth: JWT dashboard auth only.

NameTypeRequiredDescription
idInt!YesWorkflow to delete.

Boolean!true on success.

mutation DeleteWorkflow($id: Int!) {
deleteWorkflow(id: $id)
}
{ "id": 51 }

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer
{ "data": { "deleteWorkflow": true } }

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.

NameTypeRequiredDescription
idInt!YesWorkflow to publish.

WorkflowDto! — the workflow with isPublished: true and an incremented version.

mutation PublishWorkflow($id: Int!) {
publishWorkflow(id: $id) {
id
isPublished
version
}
}
{ "id": 42 }

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer
{
"data": {
"publishWorkflow": {
"id": 42,
"isPublished": true,
"version": 1
}
}
}

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.

NameTypeRequiredDescription
input.promptString!YesPlain-English description of the desired conversation flow.
input.modelStringNoOptionally override the generation model.

GeneratedWorkflowDto!

FieldTypeDescription
nodesJSON!Valid, ready-to-use nodes in React Flow shape. Safe to pass into updateWorkflow after user review.
edgesJSON!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!]!.
modelString!The model actually used for generation.
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: customer
{
"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.

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!]!
ArgumentTypeRequiredNotes
typeNodeType!yesLLM, 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).

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.

query {
nodeConfigSchemas {
type
schema
}
}
  • The type enum on the wire is UPPERCASE (LLM), but the type value inside your node JSON must be lowercase ("llm"). nodes/edges are a JSON scalar with no enum validation, so "LLM" in a node is not rejected at save time — it fails at execution.
  • Field presence ≠ field compatibility. The llm schema lists both systemPrompt and promptTemplate, but setting systemPrompt makes the executor ignore promptTemplate (see the LLM node page). A form generator should default systemPrompt to hidden/advanced.

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.

All fields live under a single input object.

FieldTypeRequiredDescription
agentIdInt!YesThe 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.
externalConversationIdString!YesYour 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.
variablesJSONNoA 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.
attachmentUrlStringNoAbsolute 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.
attachmentFilenameStringNoOriginal filename (max 255 chars). Used for the mime fallback below, and available to templates.
attachmentMimeStringNoContent 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.
clientTurnIdStringNoOpaque correlation token (max 255 chars), echoed back verbatim on any AiResponseEvent this run publishes. Not persisted, not interpreted — same semantics as SdkSendMessageInput.clientTurnId.

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 and foo.bar is 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-eval throws 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 keyValue
userMessage"" — a task has no user utterance. Empty string, never undefined, so {{userMessage}} renders blank and a condition comparing it is well-defined.
conversationHistory[]
turnCountThis task’s turn number on the session.
attachment{ url, mime, filename }, or null when you sent no attachmentUrl.
attachmentUrl / attachmentMime / attachmentFilenameFlat 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 != 0true 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.

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, and turnCount increments. Every task for that conversation therefore lands on one timeline you can read in a single workflowRuns(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.

RunWorkflowTaskResult!

FieldTypeDescription
sessionIdInt!The session this task ran on. Keep this — it is the only handle for reading the outcome.
workflowIdInt!The published workflow that was resolved from the agent.
agentIdInt!Echo of the agent that ran.
turnCountInt!The session turn number this task consumed.
reusedSessionBoolean!true when an existing ACTIVE session was joined, false when a new one was created.
acceptedBoolean!Always true on a successful return — the run was validated and enqueued. Again: accepted, not succeeded.

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,
"candidateEmail": "[email protected]",
"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: customer
{
"data": {
"runWorkflowTask": {
"sessionId": 305,
"workflowId": 42,
"agentId": 12,
"turnCount": 1,
"reusedSession": false,
"accepted": true
}
}
}
ConditionResult
Missing X-Api-Key header, or a revoked/expired/unknown key401Missing X-Api-Key header / Invalid API key
agentId does not exist or belongs to another tenant (deliberately indistinguishable)404Agent <id> not found
Agent exists but is not ACTIVE400Agent <id> is not ACTIVE — a task cannot be run against it
Agent has no workflow attached400Agent <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) URL400attachmentUrl must be an absolute http(s) URL
Over the rate limit429 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.

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.

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.

NameTypeRequiredDescription
idInt!YesThe workflow run’s id.

WorkflowRunDto (nullable).

FieldTypeDescription
idInt!Run id.
tenantIdInt!Owning tenant.
workflowIdInt!The workflow that was executed.
agentIdIntThe agent that owned the session, if applicable.
sessionIdIntThe session this run belongs to.
statusWorkflowRunStatus!One of PENDING, RUNNING, COMPLETED, FAILED, INTERRUPTED, AWAITING_INPUT.
nodeTraceJSON!Ordered list of nodes visited during this run, with enough detail to see why each transition happened.
contextJSON!The accumulated workflow context/variables at the time this data was captured.
errorMessageStringPopulated when status is FAILED.
resumedFromRunIdIntSet 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.
totalTokensUsedInt!Aggregate LLM token usage across all nodes in this run.
createdAt / updatedAt / completedAtDateTime! / DateTime! / DateTimeTimestamps.

Two statuses are worth calling out specifically:

  • AWAITING_INPUT — the run paused at an await_reply node and is waiting on the user. It is not COMPLETED and it is not stuck; the reply continues it as a separate run whose resumedFromRunId points back here. pendingAwaitReplies is the query for “what is waiting, and until when”.
  • FAILED with a BUDGET_EXCEEDED entry in nodeTrace — the run exhausted its execution budget. See Run limits and BUDGET_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.

query GetWorkflowRun($id: Int!) {
workflowRun(id: $id) {
id
status
nodeTrace
context
errorMessage
totalTokensUsed
}
}
{ "id": 981 }

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer
{
"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
}
}
}

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.

NameTypeRequiredDescription
sessionIdInt!YesThe session to list runs for.

[WorkflowRunDto!]! — same shape as workflowRun above.

query ListWorkflowRuns($sessionId: Int!) {
workflowRuns(sessionId: $sessionId) {
id
status
workflowId
createdAt
completedAt
}
}
{ "sessionId": 305 }

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer
{
"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"
}
]
}
}

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 timeupdateWorkflow (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 fieldTypeDescription
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.
budgetNodeExecutionsnumberNode dispatches completed before the breach was detected.
budgetElapsedMsnumberWall-clock milliseconds elapsed when the breach was detected.
nodeId / nodeTypestringThe node that was not dispatched.
errorstringHuman-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.

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 timemaxVisits 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 fieldTypeDescription
status"VISIT_LIMIT_REACHED"Distinct from "FAILED" — this dispatch never happened; the node had run out of visits.
visitLimitnumberThe node’s effective maxVisits (1 when it never declared one).
visitCountnumberHow many times it had already run in this run.
nodeId / nodeTypestringThe 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 reaches COMPLETED, as above.
  • No escape edge and maxVisits was declared — the run is FAILED, and errorMessage reads Workflow node 'n_submit' (action) exhausted its visit budget of 3 ….
  • No escape edge and maxVisits was 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:

  • visitLimit and visitCount appear only on a VISIT_LIMIT_REACHED entry, so existing parsing is unaffected — but nodeId is no longer unique within a nodeTrace array. Code keyed on “one entry per node” should key on array position instead.
  • At most one VISIT_LIMIT_REACHED entry is written per node per run, no matter how many edges lead back into it.

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.

NameTypeRequiredDescription
sessionIdIntNoRestrict to one session. Omit for every open wait in tenant.

[WorkflowRunWaitDto!]!

FieldTypeDescription
idInt!The wait’s own id.
tenantId / sessionIdInt!Owning tenant and the paused conversation.
workflowIdInt!The workflow that asked.
agentIdIntThe agent that owned the session, if applicable.
workflowRunIdInt!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.
nodeIdString!Graph node id of the await_reply node that is waiting.
saveAsString!Context variable the answer will be written to on resume.
matchModeAwaitReplyMatchMode!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.
statusWorkflowRunWaitStatus!WAITING, RESUMED, EXPIRED or CANCELLED. This query returns open (WAITING) waits.
expiresAtDateTime!After this instant the wait is no longer resumable and the next message starts a fresh run from start.
createdAt / updatedAtDateTime!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.

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: customer
{
"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).

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.

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.

NameTypeRequiredDescription
workflowRunIdInt!YesA completed WorkflowRun id (see workflowRun/workflowRuns above) to capture as the baseline.
nameString!YesDisplay name for the scenario.
descriptionStringNoOptional free-text notes.

WorkflowRegressionScenarioDto!

FieldTypeDescription
idInt!Scenario id.
tenantIdInt!Owning tenant.
workflowIdInt!The workflow this scenario belongs to.
sourceWorkflowRunIdInt!The WorkflowRun this scenario was captured from.
nameString!Display name.
descriptionStringFree-text notes, if given.
inputScriptJSON!The reconstructed turn-by-turn user input, derived from the source session’s messages.
baselineNodeTraceJSON!A snapshot of the source run’s nodeTrace — the baseline any future replay would diff against.
assertionsJSON!Structural assertions derived from the trace (node sequence, router decisions, tool/action calls, terminal node).
isActiveBoolean!Whether the scenario is active.
lastResultStatusRegressionResultStatusOne of PASSED, FAILED, FLAGGEDnull until the scenario has been replayed via runRegressionScenario at least once.
lastRunAtDateTimenull until the scenario has been replayed at least once.
createdAt/updatedAtDateTime!Timestamps.
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: customer
{
"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
}
}
}

Lists every regression scenario captured for a given workflow, scoped to the caller’s tenant, most recently created first.

NameTypeRequiredDescription
workflowIdInt!YesThe workflow to list scenarios for.

[WorkflowRegressionScenarioDto!]! — same shape as captureRegressionScenario above.

query ListRegressionScenarios($workflowId: Int!) {
workflowRegressionScenarios(workflowId: $workflowId) {
id
name
sourceWorkflowRunId
lastResultStatus
lastRunAt
createdAt
}
}
{ "workflowId": 42 }

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer
{
"data": {
"workflowRegressionScenarios": [
{
"id": 7,
"name": "Billing intent — happy path",
"sourceWorkflowRunId": 981,
"lastResultStatus": null,
"lastRunAt": null,
"createdAt": "2026-09-01T09:00:00.000Z"
}
]
}
}

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.

NameTypeRequiredDescription
scenarioIdInt!YesThe scenario to replay.

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.

mutation RunRegressionScenario($scenarioId: Int!) {
runRegressionScenario(scenarioId: $scenarioId) {
id
name
lastResultStatus
lastRunAt
}
}
{ "scenarioId": 7 }

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer

Lists every replay recorded for a given scenario, most recently created first — the full history, not just the denormalized latest status workflowRegressionScenarios exposes.

NameTypeRequiredDescription
scenarioIdInt!YesThe scenario whose replay history to list.

[WorkflowRegressionResultDto!]!

FieldTypeDescription
idInt!Result id.
tenantIdInt!Owning tenant.
scenarioIdInt!The scenario this result belongs to.
workflowRunIdInt!The WorkflowRun this replay produced — inspect its nodeTrace for the full run detail.
statusRegressionResultStatus!PASSED, FAILED, or FLAGGED.
assertionResultsJSON!Per-assertion pass/fail detail — which structural checks passed, which diverged, and why.
createdAt/updatedAtDateTime!Timestamps.
query ListRegressionResults($scenarioId: Int!) {
workflowRegressionResults(scenarioId: $scenarioId) {
id
status
workflowRunId
assertionResults
createdAt
}
}
{ "scenarioId": 7 }

Headers:

Authorization: Bearer <your-jwt>
x-huat-platform: customer
{
"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.

  • It’s fine to skip aiGenerateWorkflow review 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 publishWorkflow was called after your last updateWorkflow — 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/workflowRuns once against a real test session before the demo, not during it — confirm the nodeTrace matches what you expect a live audience to see.
  • Treat any mcp connector or 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.
  • Never publish directly from aiGenerateWorkflow output without a human review pass — inspect rejectedNodes/rejectedEdges and manually verify every branch’s actual behavior, not just that it validated.
  • Version discipline matters: publishWorkflow increments version but keeps prior versions’ history implicit in that counter, not as separately retrievable snapshots today — so keep your own copy of the nodes/edges JSON you send to updateWorkflow (e.g. in your own source control) before each publish, so you can manually reconstruct a rollback if a new version misbehaves.
  • Treat workflowRun/workflowRuns as your primary production debugging tool for “the agent didn’t do what I expected” reports — pull the nodeTrace for 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 — totalTokensUsed on workflowRunDto is 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.