Technical docs
Workflow Engine (Backend)
Public reference generated from tech docs/workflow-engine.md.
Overview
Support-facing summary of the workflow execution engine as it exists in current backend code.
Workflow types
- Stored workflow types are `agent`, `structured`, and `employee`. - Regular org workflows and skills are `agent` or `structured`. - `employee` workflows are the default workflows attached to an AI teammate and are used by internal chat, reminders, and teammate delegation.
What makes a workflow runnable
- A workflow must have a published `flow` map and `starting_step_id`. - Published runtime metadata also includes: - `payload_format` - `state_definition` - `mode` - `debounce_seconds` - `debounce_key` - `mode` is `parallel` or `restart`.
Runnable node set
- Agent workflows: - `starting_step` - `agent` - Structured workflows: - `starting_step` - `llm` - `tool` - `run_workflow` - `run_employee` - `run_code` - `condition` - `user_input` - `send_message` - `finish` - Backend type definitions and validators still include `mapping` and `wait_callback`, but `WorkflowProcessor` currently throws `Mapping processing is not implemented` and `Wait callback processing is not implemented`. Treat those as non-runnable.
Entry points
- Public trigger: - `POST /api/public/trigger/{public_api_key}/{workflow_id}` - Private trigger: - `POST /api/private/trigger/{workflow_id}` - Manual org-scoped run: - `POST /api/organizations/{orgId}/workflows/{workflow_id}/runs/`
Trigger behavior
### Public trigger
- Resolves the organization by `public_api_key`. - Requires the workflow to exist and have published `flow` plus `starting_step_id`. - Uses the raw request body as payload. - If the workflow has a `debounce_key`, the route extracts it from the payload using dot-path lookup with flat-key fallback. - If both `debounce_seconds` and an extracted debounce key are present: - the route upserts a row in `workflow_debouncer` - the response returns `{ success: true, data: { debounced: true } }` - no run is created immediately - `workflowDebouncerDaemon` later creates the run and enqueues it with `isAsync: true` - If there is a debounce key and `mode === restart`, but the request is not going through the debouncer path: - backend looks for an active run on the same workflow and raw debounce key - matching active run is marked `aborted` - a new run is created immediately - Immediate public-trigger runs store `debounce_key` on the run. Debounced runs store the composite key written by the debouncer row.
### Private trigger
- Uses `req.organization` context instead of a public API key. - Requires published `flow` plus `starting_step_id`. - Creates and enqueues the run immediately.
### Manual org-scoped run
- Requires an authenticated org user. - Validates `payload` against `payload_format`. - Validates optional `state` against `state_definition`. - Stores the optional manual state under `runtimeContext.state` before queueing the run. - Creates the run with no conversation attached and queues execution from the published `starting_step_id`.
Run snapshot and queue pipeline
- Every execution is persisted as a `WorkflowRun` snapshot. - A run stores: - `flow` - `state` - `parent_workflow` - `conversation_id` - `model` - `input_tokens` - `output_tokens` - `channel` - `ai_employee_id` - `trigger_id` - `trigger_url` - `debounce_key` - `workflowRunDaemon` consumes Redis queue messages. - `WorkflowRunQueue.processMessage()` enriches `runtimeContext` with: - `isDebug` - `isAsync` - `organizationId` - `employeeId` - `workflowId` - The queue layer also copies conversation channel and workflow employee onto top-level run columns when available. - `WorkflowProcessor` atomically claims the run before executing it, so terminal runs are not reprocessed.
Run statuses
- `created` - `in_progress` - `suspended` - `completed` - `failed` - `aborted`
Step behavior
- `agent`: runs Alloy AI with tools/streaming behavior. - `llm`: single prompt/model step without tool execution. - `tool`: executes one backend tool after parameter substitution. - `run_workflow`: creates a child workflow run, waits for completion, and resumes the parent with the child result. - `run_employee`: executes another employee workflow, supports `attachments_json`, and can shape plain-text child output into a declared object schema. - `run_code`: executes Python or JavaScript code. - `condition`: evaluates branches and continues to the matching next step or default branch. - `user_input`: suspends the run and waits for resume payload. - `send_message`: sends a message and continues to the next step. - `finish`: finalizes the run.
Variables and schema rules
- `payload_format` and `state_definition` currently validate `string`, `number`, and `boolean` field types. - Declared runtime state is exposed to templates through `{{state.key}}`. - `saveToState` writes values into `runtimeContext.state`. - `{{input.*}}` resolves only from direct predecessor output. - If a node has multiple direct predecessors, the referenced input path must exist in every direct predecessor schema. - Starting-step payload keys can use dotted field names.
Run APIs
- Org-wide runs: - `GET /api/organizations/{orgId}/workflows/runs/` - Workflow-scoped runs: - `GET /api/organizations/{orgId}/workflows/{workflow_id}/runs/` - Run detail: - `GET /api/organizations/{orgId}/workflows/{workflow_id}/runs/{run_id}/` - Exact-match filters supported today: - `status` - `run_id` - `workflow_id` - `conversation_id` - `channel` - `ai_employee_id` - `model` - `trigger_id` - Range filters supported today: - `started` - `ended` - `duration` - `tokens` - Express query parsing is `extended`, so range filters use bracket syntax such as `duration[gte]=5`.
Filter implementation details
- `started` maps to `created_at`. - `ended` maps to `completed_at` only. Failed or suspended timestamps are not part of the `ended` filter. - `duration` is computed as `completed_at - created_at`, so non-completed runs do not match duration filters meaningfully. - `tokens` is `COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)`.
Lightweight list payloads
- Query validation allows `exclude=flow`, `exclude=state`, or `exclude=flow,state`. - In `GetWorkflowRunsAction`, any truthy `exclude` value switches the serializer to `listData()`. - In that mode the DB query excludes only the `flow` column. - `state` is still returned, but in compressed form: - `currentStepId` - top-level `error` - `runtimeContext.userId` - `runtimeContext.isAsync` - trimmed step summaries with reduced input/output payloads - Top-level list fields still include: - `conversation_channel` - `conversation_title` - `model` - `input_tokens` - `output_tokens` - `channel` - `ai_employee_id` - `trigger_id` - `trigger_url` - `debounce_key`