跳转到内容

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 and workflowTaskOutcome are API-key only (they are 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!Row version: starts at 1 and goes up on every save of the workflow (each updateWorkflow and each publishWorkflow). A change counter, not a count of publishes, and not a version history number.
currentVersionIdIntId of the published version that new session turns run now (an entry of workflowVersions, its id, not its versionNumber). Moves on every publish or rollback that changes the published graph. null for a workflow never published, and for one whose last publish happened before version history existed.
settingsWorkflowSettings!Workflow-level settings: failureResponse and notifyOnFailure. Never null: a workflow with no settings returns failureResponse: null and notifyOnFailure: NONE. Saved by updateWorkflow and effective immediately, not on publish.
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": 1
}
]
}
}

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: 1.

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.
input.settingsWorkflowSettingsInputNoWorkflow-level settings to change, merged into the stored ones (an omitted field inside keeps its value; omitting settings, or sending null, changes nothing). Effective on save, not on publish. See Workflow settings.

WorkflowDto! — the updated workflow.

When a run of a workflow fails (a node fails and no failure edge handles it), the conversation gets a standard reply: “Sorry, I ran into a problem processing that — could you try again? If it keeps happening, let the team know.” settings.failureResponse replaces that text with your own, for this workflow.

mutation SetFailureText($input: UpdateWorkflowInput!) {
updateWorkflow(input: $input) {
id
settings {
failureResponse
}
}
}
{
"input": {
"id": 42,
"settings": {
"failureResponse": "Sorry, something went wrong on our side. A colleague will pick up your ticket shortly."
}
}
}
  • Plain text, 1 to 500 characters after trimming. It is stored trimmed and sent exactly as written. Refused with BAD_USER_INPUT when it is empty after trimming, longer than 500 characters, or contains a {{ }} template, an HTML tag, a control character (tabs and line breaks are fine) or anything shaped like a credential or signed URL. The text is shown to end users, so a pasted token would be sent to every conversation that hits a failure.
  • null clears it, back to the standard reply. Omitting failureResponse (or settings) keeps the stored value.
  • Effective on save, not versioned. It applies to the next failure after updateWorkflow returns, without a publish, and it is not part of any version: a publish, rollback or restore leaves it unchanged.
  • Where it is sent. In place of the standard reply for every failure the platform reports for this workflow: an ordinary turn, a conversation resumed after an await_reply or approval pause, a draft test, and a headless task started with runWorkflowTask (where it appears in workflowTaskOutcome’s replies).
  • Where it is not sent. A failure taken by a failure edge is not a run failure: the run continues on that branch and nothing extra is sent. A turn interrupted, or superseded by a newer message, sends no failure reply at all, custom or standard. If the workflow itself cannot be loaded, the standard reply is sent.
  • WorkflowDto.settings returns { failureResponse: null, notifyOnFailure: NONE } when nothing is set.

settings.notifyOnFailure decides who is told when a run of this workflow fails. Live in production since 2026-10-16.

ValueEffect
NONENobody is notified. The default, including for a workflow hired from a template.
OWNERSEvery active owner of the workspace gets an in-app notice, and an email when email delivery is on for the platform, at most one per workflow per 60 minutes.
mutation NotifyOwners($input: UpdateWorkflowInput!) {
updateWorkflow(input: $input) {
id
settings {
notifyOnFailure
}
}
}
{ "input": { "id": 42, "settings": { "notifyOnFailure": "OWNERS" } } }
  • Saved like failureResponse. NONE or null turns it off; omitting it keeps the stored value. Effective on save, no publish needed, and not part of any version.
  • What counts as a failure. A run that ends FAILED: a node fails and no failure edge handles it, or the run hits its step or time limit or a node’s visit limit.
  • What never notifies. A failure handled by a failure edge (the run completes), a dashboard draft test or a regression replay, a turn interrupted or superseded by a newer message, and a failure before the run existed (for example the workflow could not be loaded).
  • One notice per workflow per 60 minutes. Further failures inside that window are held back and counted; the next notice says how many (for example “2 more failures since the last notice.”). The window is shared by all servers.
  • Best effort. The notice is sent in the background after the failure reply, and never delays or changes the reply. If the setting was switched off, or the workflow deleted, before the notice is sent, nothing is sent. A workspace with no active owner gets no notice.

The email (in production since 2026-10-29). Each active owner with an address also gets a short email, queued after the in-app notice. It carries the workflow name, the run id, the time and the relative path /workflows/<workflowId>/runs/<runId> (a path, not a full URL). It never contains error text, node details, end-user text or a token. If email delivery is off, nothing is sent and the in-app notice is unaffected.

What the notice contains. It is an ordinary in-app notification, read with getNotifications by each owner’s own dashboard JWT. title is Workflow failed, type is WARNING, and message names the workflow and, at most, the failing node’s type, for example “Order lookup: a run failed at a tool node.” It never contains the error text, the user’s message, the reply or any run variable. meta carries ids and fixed codes only:

meta keyValue
kindworkflow_failure
workflowIdThe workflow.
runIdThe failed run (read it with workflowRun). Also in subjectId.
traceIdThe run’s trace id, or null.
nodeIdThe id of the node that ended the run, or null.
nodeTypeThat node’s type, or null.
failureCodenode_failed, budget_exceeded, visit_limit_reached or run_failed.
suppressedCountFailures held back since the previous notice.
linkA path in the dashboard app, /workflows/<workflowId>/runs/<runId>: relative, never a full URL.

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": 2,
"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" }]
}
}
}

version went up because every save increments it; that does not mean anything was published. updateWorkflow saves the draft only. 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 } }

Validates the current draft of a Workflow and, when it passes, snapshots it as the published graph: sets isPublished to true (and version goes up, as on any save). Every session turn of an agent attached to this workflow runs the published snapshot from then on; later draft edits change nothing until the next publish. A conversation paused at an await_reply or approval node keeps running the version it paused on; its next fresh turn uses the new one.

Each publish that changes the published graph is also kept as a numbered entry in the version history and moves currentVersionId to it; republishing an unchanged graph adds no entry. See Version History and Rollback.

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.
noteStringNoFree text, at most 500 characters after trimming (longer is BAD_USER_INPUT), stored on the version this publish records, for example what changed. Ignored when the graph is unchanged (no new version). Blank counts as no note.

WorkflowDto! — the workflow with isPublished: true, an incremented version and, when the graph changed, a new currentVersionId.

Refused with BAD_REQUEST naming the problem when the graph shape is invalid (for example no start node) or an llm node names a model your workspace does not allow, and with BAD_USER_INPUT when a node config is invalid or a node type is written in capitals. See What publishing checks.

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
}
}
}

Every publish, rollback or template hire that changes a workflow’s published graph is kept as a numbered version. The guide, with worked examples, is Version History and Rollback. All five operations take a dashboard JWT (any user of the workspace) and answer NOT_FOUND for a workflow, or a version, that does not exist in your workspace (a pruned version included).

OperationKindArgumentsReturnsWhat it does
workflowVersionsqueryworkflowId: Int!, first: Int (default 20, max 100), after: StringWorkflowVersionSummaryConnection!The history, newest first, summaries only. after is a previous page’s pageInfo.endCursor and walks to older versions; an unknown cursor is BAD_REQUEST.
workflowVersionqueryworkflowId: Int!, versionNumber: Int!WorkflowVersionDto!One version with its full graph (nodes, edges, exactly as published, configs included) and its summary.
workflowVersionDiffqueryworkflowId: Int!, fromVersion: Int!, toVersion: IntWorkflowVersionDiffDto!Ids added, removed and changed between two versions, and the key names that differ on each changed node; never values. Omit toVersion to compare against the current draft.
rollbackWorkflowmutationworkflowId: Int!, versionNumber: Int!, note: StringWorkflowDto!Publishes that version’s graph again as a new version (source: ROLLBACK). The draft is not changed. Re-validated against today’s rules and refused with the same BAD_REQUEST / BAD_USER_INPUT errors as publishWorkflow; refused with BAD_USER_INPUT when that graph is already the published one.
restoreWorkflowVersionToDraftmutationworkflowId: Int!, versionNumber: Int!WorkflowDto!Copies that version’s graph into the draft, replacing it (unpublished edits included). Nothing is published and no version is created. Checked like an updateWorkflow save.

versionNumber (1, 2, 3 … per workflow, never reused) is what these operations take. WorkflowDto.currentVersionId and WorkflowRunDto.workflowVersionId hold a version’s id instead. note on rollbackWorkflow is at most 500 characters after trimming.

WorkflowVersionSummaryDto (each edges[].node of workflowVersions, and WorkflowVersionDto.summary):

FieldTypeDescription
idInt!Server-generated id of the version, the value currentVersionId and workflowVersionId refer to.
versionNumberInt!1, 2, 3 … per workflow, never reused. Gaps appear once old versions are pruned (the newest 100 are kept, plus the current one and any a still-waiting conversation runs on).
isCurrentBoolean!true for the version new session turns run now.
sourceWorkflowVersionSource!PUBLISH, ROLLBACK, HIRE (an agent template hire), SEED (a platform seeding script) or BACKFILL (recorded once for a workflow that was already published when version history was introduced).
restoredFromVersionNumberIntFor a ROLLBACK version, the version number it republished. null otherwise.
noteStringThe note given to publishWorkflow or rollbackWorkflow; null when none.
publishedByUserIdIntId of the user who published it; null for hires, seeds, the backfill and internal callers.
nodeCount / edgeCountInt!Size of the graph.
graphHashString!sha256 (hex) of the graph, key order ignored. Two versions with the same hash have identical graphs.
createdAtDateTime!When the version was recorded.

WorkflowVersionSummaryConnection is { edges: [{ cursor, node }], pageInfo: { hasNextPage, endCursor } }; there is no totalCount.

WorkflowVersionDiffDto: addedNodeIds, removedNodeIds, addedEdgeIds, removedEdgeIds, changedEdgeIds (all [String!]!; an edge is changed when its source, target or label differs) and changedNodes: [{ id, type, changedKeys }], where changedKeys names top-level keys such as label, position or type and config keys as config.<key> (for example config.promptTemplate). Every list is sorted.

Non-blocking checks (“lints”) for one workflow graph: shapes that will probably not behave the way you meant at run time, each naming the node and what to do. Read-only. It never blocks saving or publishing; publishWorkflow has its own blocking errors. The full list of codes, with what each means and what to do, is on Workflow Diagnostics.

Auth: JWT dashboard auth only.

NameTypeRequiredDescription
idInt!YesThe workflow’s id.
publishedBoolean!NoDefaults to false: check the draft graph (what updateWorkflow wrote). true: check the published snapshot real sessions run.

[WorkflowDiagnosticDto!]!, an empty list when nothing was found. Sorted with WARNING first, then by code, then by nodeId.

FieldTypeDescription
severityWorkflowDiagnosticSeverity!WARNING (probably wrong at run time) or INFO (a hint, or a repeat of a publish rule). Neither blocks saving or publishing.
codeString!Stable identifier, e.g. ROUTER_NO_DEFAULT. Switch on this, not on message. New codes may be added; treat an unknown one as a generic finding.
nodeIdStringThe node the finding is about, when it is about one node.
edgeIdStringThe edge the finding is about, when it is about one edge that has an id.
messageString!Plain-language explanation naming the node (id and label) and what to do. Wording may change.
  • NOT_FOUND: no workflow with that id in your workspace (another workspace’s id answers the same way), or published: true on a workflow that was never published.
query WorkflowDiagnostics($id: Int!) {
workflowDiagnostics(id: $id) {
severity
code
nodeId
edgeId
message
}
}
{
"data": {
"workflowDiagnostics": [
{
"severity": "WARNING",
"code": "ROUTER_NO_DEFAULT",
"nodeId": "n_dispatch",
"edgeId": null,
"message": "Router \"n_dispatch\" (\"Dispatch\") has no defaultTarget. If its value matches none of its edge labels, the run fails and the user gets the generic error reply. Set defaultTarget to a fallback node."
}
]
}
}

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.

The co-pilot counterpart of aiGenerateWorkflow: instead of drafting a graph from scratch, it proposes an incremental change to an existing graph from a plain-English instruction. Like aiGenerateWorkflow it persists nothing; you apply the returned diff to your own copy of the graph and save with updateWorkflow.

Auth: JWT dashboard auth only. Rate limit: 20 per minute per client IP (see Rate limiting).

It takes the same AiGenerateWorkflowInput as aiGenerateWorkflow:

FieldTypeRequiredNotes
promptString!yesThe change to make, e.g. “add a confirmation step before the refund”.
currentGraphJSONyes, for a diffThe { nodes, edges } graph to change, in the shape updateWorkflow accepts. Send it in variables.
history[ChatTurnInput!]noEarlier turns of this co-pilot session, oldest first, for multi-turn context.
providerLlmProviderTypenoBEDROCK_MANTLE, WEBBYXAI_HAIKU or GEMINI_DIRECT; omit for the platform default.

GeneratedWorkflowDiffDto!:

FieldMeaning
addedNodes, addedEdgesNew nodes and edges to add.
modifiedNodesFull replacement node objects for existing nodes whose config, label or position changed. Apply them by id; do not append them as new nodes.
removedNodeIds, removedEdgeIdsIds to delete (a node’s edges go with it).
rejectedAddedNodes, rejectedModifiedNodes, rejectedAddedEdgesEntries the model attempted that failed validation (unknown node type, missing required config, an edge that does not resolve). They are reported, never silently dropped.
graphShapeErrorSet when applying the diff would leave an invalid graph (a single start node, unique ids, everything reachable). The whole diff is then rejected and every array above is empty: nothing should be applied.
modelThe provider and model that actually served the call (it can differ from the one requested if a fallback happened).

Always check graphShapeError first, show rejected* entries to the user as “could not fully apply”, and validate the merged graph before saving, exactly as you would a hand-edited one.

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, AWAIT_REPLY, APPROVAL, SET_VARIABLE, SWITCH (once deployed). START has no config, so asking for it is rejected with a BadRequest error.

nodeConfigSchemas takes no arguments and returns one { type, schema } entry for every node type except START, which has no config (13 entries once switch is deployed; 12 before).

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, but since 2026-10-07 an uppercase node type is refused at save and publish with BAD_USER_INPUT and a message such as node n1: type "START" must be lowercase: "start".
  • 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. (If all you need is “run this on a timer,” Scheduled Workflows does that for you without your own scheduler, and the run context is the same.)

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 with workflowTaskOutcome, using the sessionId this mutation returns and the same API key:

query TaskOutcome($sessionId: Int!) {
workflowTaskOutcome(sessionId: $sessionId) {
status
runs {
id
status
errorMessage
createdAt
completedAt
}
replies {
text
createdAt
}
}
}

The dashboard queries workflowRuns / workflowRun return the full node trace and context, but they are JWT dashboard queries and are not reachable with an API key.

Every runWorkflowTask call, and every scheduled workflow fire (which goes through the same path), leaves one invocation record so that support can answer “what did this integration fire, and what happened?”. A record holds:

  • The agent, the workflow, and the session it ran in (when one was created).
  • Where it came from: an API call (with the id of the API key used, when it was a named key) or a schedule.
  • Your externalConversationId, which is your own correlation id (it is also the session’s clientExternalId).
  • The names of the variables you sent, and whether an attachment was supplied. Never the values, and never the attachment URL, filename or content type.
  • Whether the call was accepted or rejected. A rejection carries a short fixed code (SPEND_CAP, TENANT_BUSY, AGENT_NOT_ACTIVE, NO_WORKFLOW, INVALID_VARIABLES, INVALID_ATTACHMENT, AGENT_NOT_FOUND for schedules, or INTERNAL_ERROR), never the error text.
  • A link to the resulting workflow run, so the run’s final status can be shown next to the call.

Not recorded: variable values, the attachment, clientTurnId, error messages, and calls that cannot be tied to a tenant and agent (an invalid API key, or an agentId that does not exist in your tenant). Records are kept indefinitely. They are visible to Wetel support staff; there is no customer-facing query for them yet.

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.
attachments[MessageAttachmentInput!]NoSince 2026-10-08. A list of files (up to 10; each { url, mime, filename, kind }, see Sending files and context) instead of the single attachmentUrl fields. The single fields are shorthand for a one-entry list. Send one or the other: both in one call answers 400 Send either attachmentUrl (single file) or attachments (list), not both. The run sees the same context keys as a chat turn: attachments, attachmentImageUrls, and the flat singles from the first entry.
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.
attachments / attachmentImageUrlsThe list you sent ([] when none) and the URLs of its IMAGE entries, usable directly as an llm node’s imageUrlsVar. turn.context is {} for a task.

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.

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 key401 — 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 ACTIVE400 — Agent <id> is not ACTIVE — a task cannot be run against it
Agent has no workflow attached400 — 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) URL400 — attachmentUrl must be an absolute http(s) URL
An attachment fails validation (relative URL, user:password@ in the URL, over 10 entries, malformed attachmentMime)400 — for example attachmentMime is not a valid MIME type; the message never repeats the URL
Over the rate limit429 semantics — see Rate Limiting
The workspace already has its limit of tasks waiting or running (default 10)429 — This workspace already has <n> tasks running or waiting (limit <m>). Try again when one finishes.; recorded as TENANT_BUSY

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.

Reads back the outcome of a task you started with runWorkflowTask, with the same X-Api-Key (no dashboard login). Read-only.

workflowTaskOutcome(sessionId: Int!): WorkflowTaskOutcomeDto!

Auth: X-Api-Key only. Rate-limited to 60 requests per minute per client IP, like runWorkflowTask; poll with a back-off rather than in a tight loop.

  • status is the status of the newest run: COMPLETED means the graph reached its end; FAILED, INTERRUPTED and AWAITING_INPUT are the other end states; PENDING and RUNNING mean keep polling. It is null while the accepted task is still queued and has not started a run.
  • runs lists the session’s runs, newest first, at most 20: id, status, createdAt, completedAt, and for a failed run an errorMessage with anything that looks like a credential masked, cut to 300 characters. A pause and resume at an await_reply node is two runs.
  • replies is what the agent said in the session (the workflow’s response nodes), oldest first, at most the latest 10. When a run fails, the failure reply sent to the session is one of them: the standard text, or the workflow’s own failureResponse when one is set.
  • It never returns the run context or the node trace. Use the dashboard for those.
  • Scope. Only a sessionId that a runWorkflowTask call was accepted for can be read. Any other session id, or one that belongs to another tenant, returns the same not-found error.

Request

query TaskOutcome($sessionId: Int!) {
workflowTaskOutcome(sessionId: $sessionId) {
sessionId
status
runs {
id
status
errorMessage
}
replies {
text
}
}
}

Response

{
"data": {
"workflowTaskOutcome": {
"sessionId": 501,
"status": "COMPLETED",
"runs": [{ "id": 8812, "status": "COMPLETED", "errorMessage": null }],
"replies": [{ "text": "Posting 19 has been updated." }]
}
}
}

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.
continuedByRunIdIntThe inverse link, on a paused (AWAITING_INPUT) run: the id of the run that continued it (an await_reply reply or an approval decision). null while the run is genuinely still waiting, and for every run that never paused. Filled by workflowRun and workflowRuns only. A paused run that has one is no longer waiting: its status stays AWAITING_INPUT as a record of where it stopped.
traceIdStringTrace id (a UUID) shared by one logical execution: a new id for an ordinary turn, the paused run’s id when a run continues an await_reply or approval pause, and the parent run’s id for a sub_agent child run. The same value is sent as the x-wetel-trace-id request header on the run’s outbound webhook, action and tool calls, so your server’s request log can be matched to the run. null for runs created before trace ids existed.
workflowVersionIdIntId of the published version this run executed (WorkflowVersionSummaryDto.id). A run that continued a pause carries the version the paused run recorded. null for draft tests, for runs from before version history existed, and when the published graph could not be matched to a recorded version.
mockedBoolean!true for a mock-mode test run (live in production since 2026-10-24): a draft test or regression replay in mock mode, its continuation after an await_reply pause, or a sub_agent child run inside one. Its tool, action, webhook and pdf_export nodes returned mock results instead of calling out, and each such nodeTrace row carries "mocked": true. Always false for a live run. See Testing a Workflow Safely.
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, and this run’s continuedByRunId then names that run (still null while it waits). 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 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 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 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 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).

The approvals inbox: requests that an approval node raised for a workspace owner to approve or reject before the run continues. Newest first, scoped to your workspace. Step by step, with decideApproval and cancelApproval: Human approval: a walkthrough.

Auth: JWT dashboard auth, workspace owner only. A member’s token answers FORBIDDEN.

NameTypeRequiredDescription
statusApprovalStatusNoOnly requests in this state: PENDING, APPROVED, REJECTED, EXPIRED or CANCELLED (and CHANGES_REQUESTED once request changes is released: staging, not production yet). Cannot be combined with statuses.
statuses[ApprovalStatus!]NoOnly requests in any of these states, in one newest-first list (for example every decided request). Duplicates are ignored. Cannot be combined with status; an empty list is refused. Not deployed yet, see below.
workflowIdIntNoOnly requests raised by this workflow.
agentIdIntNoOnly requests raised for this agent.
firstIntNoPage size, default 20, capped at 100.
afterStringNoA previous page’s pageInfo.endCursor; walks to older requests.

With neither status nor statuses, only PENDING requests are listed (the work waiting for you). That default has not changed: a call that sends neither argument behaves exactly as before.

ApprovalConnection!: edges { cursor node } and pageInfo { hasNextPage endCursor }. Each node is an approval request (id, title, summary, payload, editableFields, status, expiresAt, workflowRunId, the decision fields once decided, and so on). The payload is the thing being decided and may contain customer data. A request past expiresAt that the background sweeper has not closed yet still reads PENDING but can no longer be decided. revision: Int! says how many times the request has been sent back with REQUEST_CHANGES (0 for a first draft, at most 3); it, and the CHANGES_REQUESTED status, exist only on a backend that has released request changes (staging, not production yet), and a query selecting revision fails as a whole on one that has not.

Paging is the same whichever filter you use: the cursor is the request’s position in the newest-first order, so after works unchanged with statuses.

  • BAD_REQUEST Pass either `status` or `statuses`, not both: both arguments were sent.
  • BAD_REQUEST `statuses` must list at least one status; omit it to list PENDING requests: statuses: []. An empty list would match nothing, so it is refused rather than answered with an empty page.
  • BAD_REQUEST: a malformed after cursor.
  • FORBIDDEN: the token is not a workspace owner’s, or has no workspace.
query DecidedApprovals($after: String) {
approvals(
statuses: [APPROVED, REJECTED, EXPIRED, CANCELLED]
first: 20
after: $after
) {
edges {
node {
id
title
status
decidedAt
workflowRunId
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
{
"data": {
"approvals": {
"edges": [
{
"node": {
"id": "42",
"title": "Refund of 120.00",
"status": "REJECTED",
"decidedAt": "2026-10-11T09:12:44.000Z",
"workflowRunId": 9811
}
},
{
"node": {
"id": "39",
"title": "Refund of 35.50",
"status": "APPROVED",
"decidedAt": "2026-10-10T16:02:10.000Z",
"workflowRunId": 9790
}
}
],
"pageInfo": { "hasNextPage": false, "endCursor": "Mzk" }
}
}
}

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. Since 2026-10-08 a user message that carried structured attachments or a turnContext keeps them on its turn in the input script (every attachment’s url, mime, filename, kind, and the turnContext), and a replay sends them structurally, exactly like the original turn, with the recorded text unchanged and no marker in it. A plain message is still just its text, and a scenario captured before this replays unchanged.

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, FLAGGED — null 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.

Mock mode (live in production since 2026-10-24). A replay runs in mock mode by default: each tool, action, webhook, pdf_export and sub_agent node returns the output (or the handled failure) recorded in the scenario’s own baseline run instead of calling out, and LLM nodes run for real so the prompts are tested. Pass sideEffects: LIVE to call the real systems, for example to detect a change in another system’s API; only the workspace owner may, anyone else gets FORBIDDEN. Limits of mock mode: one mock per node (a loop whose output changed between visits gets the last value on every visit, so replay it LIVE), images and base64 values replay as placeholders, an approval node fails the replay in both modes, and at most 20 real LLM calls per turn. See Testing a Workflow Safely. In production today a replay runs every node for real.

NameTypeRequiredDescription
scenarioIdInt!YesThe scenario to replay.
sideEffectsDraftSideEffectModeNoMOCK (default) or LIVE (workspace owner only).

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.
sideEffectModeDraftSideEffectMode!MOCK or LIVE: the mode this replay ran in. Replays recorded before the field existed are LIVE.
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.