Skip to content

Workflows

Workflows are the heart of tuvl — they define how data flows through your business logic.

Workflow Structure

A workflow is defined in YAML with four main sections:

kind: "Workflow"
version: "v1"

metadata:
  name: "my_workflow"
  description: "What this workflow does"

spec:
  trigger:
    path: "/api/my-endpoint"
    method: "POST"
    input_schema: "context"
    response_schema: "context"

  context: "MyModel"

  steps:
    - id: "step_1"
      kind: "Functional"
      runner: "my_node"

Metadata

Every workflow needs a unique name and optional description:

metadata:
  name: "order_processing"
  description: "Process incoming orders with fraud detection"
  schema_version: "v1"       # optional — defaults to "v1" when omitted

The name is used for:

  • Logging and debugging
  • API route generation
  • Workflow identification in the UI

schema_version

Tag a workflow definition with a version string. Multiple versions of the same workflow (same metadata.name) can coexist in one file using --- document separators or in separate files:

workflows/onboard.yaml
kind: "Workflow"
metadata:
  name: "onboard"
  schema_version: "v1"
steps: [...]

---

kind: "Workflow"
metadata:
  name: "onboard"
  schema_version: "v2"
enabled: false          # staged — activate via admin API
steps: [...]            # revised step set

schema_version defaults to "v1" when omitted.

enabled

Value Behaviour
true (default) Workflow is mounted and accepts requests
false Tracked in WORKFLOW_VERSION_REGISTRY for admin purposes but not mounted; requests return 400

Disabled versions are visible in the admin panel and can be toggled without a server restart.

Trigger Configuration

The trigger defines how the workflow is exposed as an HTTP endpoint:

trigger:
  path: "/api/orders"           # URL path
  method: "POST"                # HTTP method
  input_schema: "context"       # Request body validation
  response_schema: "context"    # Response body validation

Schema Options

Value Description
"context" Use the workflow's context model (Create/Read variants)
"ModelName.variant" Specific model and variant (e.g., "Order.create")
Inline array Define fields inline

Inline schema example:

trigger:
  path: "/api/webhook"
  method: "POST"
  input_schema:
    - name: "event_type"
      type: "string"
      required: true
    - name: "payload"
      type: "string"

Context Model

The context field links the workflow to one or more data models.

Simple form — single model

context: "Contact"

List form — multiple models

context:
  - "Candidate"
  - "Job"

Dict form — explicit model version pinning

Use the dict form to target a specific schema_version of a model at execution time. This is the recommended form when using model versioning:

context:
  models:
    - name: "Candidate"
      version: "v2"     # use Candidate v2 for this workflow
    - name: "Job"       # no version — uses the active version

The version key is optional per entry. When omitted, the currently enabled version in MODEL_REGISTRY is used.

All forms enable:

  • Auto-generated schemas for input/output validation
  • Type hints in the UI workflow builder
  • Repository access within nodes

Steps

Steps are executed sequentially (with routing for branching):

steps:
  - id: "validate"
    kind: "Functional"
    runner: "validate_input"
    routes:
      valid: "process"
      invalid: "reject"

  - id: "process"
    kind: "Agent"
    mode: "completion"
    agent:
      model: "ollama/llama3"
      prompt: "..."

  - id: "reject"
    kind: "Functional"
    runner: "send_rejection"

Step Properties

Property Required Description
id Yes Unique identifier within the workflow
kind Yes Step type: Functional, Agent, Router, APICall, MCP, ModelOp, Response, HumanInTheLoop
runner For functional Node name from NODE_REGISTRY
mode For agent completion (one retried call) or autonomous (bounded tool loop) — required on every Agent step, no default
agent For agent LLM configuration
routes No Signal-to-step mapping

Step Kinds

Functional Steps

Execute a registered Python node:

- id: "save_order"
  kind: "Functional"
  runner: "db_save"        # Must exist in NODE_REGISTRY

The node function receives and returns the context:

@node("db_save")
async def db_save(ctx: dict[str, Any]) -> dict[str, Any]:
    session = ctx["_session"]
    repo = get_repository("Order", session)
    order = await repo.add(ctx)
    ctx["id"] = str(order.id)
    return ctx

Agent Steps

One agent kind, two execution modes, one shared contract. Every Agent step must declare mode: at the step level — completion (a single retried LLM call, shown here) or autonomous (a bounded tool-calling loop, below). There is no default mode.

- id: "classify"
  kind: "Agent"
  mode: "completion"
  agent:
    model: "default"                # preset name from llms/default.yaml
    # or use a LiteLLM model string directly:
    # model: "ollama/llama3"
    # model: "gpt-4o-mini"
    system: |
      You are a customer support classifier.
    prompt: |
      Message: {{ message }}

      Classify the message.
    outcome:
      format: json
      enum: ["urgent", "normal", "spam"]   # closed signal set — the returned "outcome" field routes
      map:
        reason: triage_reason              # LLM key → context key rename
    retry:
      attempts: 3
      on: [parse_error, timeout]
    timeout: 30
  routes:
    urgent: "escalate"
    normal: "queue"
    spam: "discard"
    error: "handle_error"
    parse_error: "handle_error"

With enum declared, the model returns an "outcome" field validated against the closed set — an arbitrary LLM string can never become a routing signal, and every enum value must be mapped in routes:.

Agent Configuration

Property Default Description
model Required Preset name from llms/<name>.yaml (no /) or a LiteLLM model string (e.g. ollama/llama3, gpt-4o)
system "" System prompt — inline text or artifact://<prompt artifact>
prompt Required User prompt with Jinja2 templating — inline text or an artifact ref
outcome.format "json" Output format: json, text
outcome.enum none Optional closed set of route signals; the model's "outcome" field is validated against it and every value must be mapped in routes:
outcome.map {} Rename table for parsed JSON fields (LLM key → context key; json format only)
outcome.write <id>_result Context key that additionally captures the full result payload when declared
retry.attempts 1 Number of retry attempts
retry.on [] Error types to retry on
retry.backoff 1 Backoff multiplier between retries
timeout 60 Timeout in seconds
context_injection [] List of context keys whose values are appended as a delimited untrusted-content message (RAG / search-result grounding)
guardrails none Artifact-backed I/O validation gates (input / output / tools) — see Guardrails

Autonomous Agent Steps

Where mode: completion is a single LLM call, an Agent step with mode: autonomous runs a bounded tool-calling loop: the model is given its steering and a set of declared tools, autonomously chooses which to call, observes the results, and re-decides until it emits one of a declared outcome.enum. Autonomy stays inside the contract — the tools are a closed author-declared set, the exits are a closed set, and the loop is capped.

- id: "triage"
  kind: "Agent"
  mode: "autonomous"
  agent:
    model: "default"
    steering: "Resolve the support ticket using the available tools."
    max_iterations: 8                  # hard cap (default 8)
    token_budget: 50000                # optional cap on cumulative tokens
    skills:                            # when-relevant capabilities — inline text or artifact refs
      - "artifact://support-policy"
    tools:                             # REQUIRED in this mode
      - ref: "lookup_order"            # names ANOTHER step in this workflow;
                                       # description comes from that step's own description:
        parameters:
          type: object
          properties: { order_id: { type: string } }
          required: [order_id]
      - ref: "issue_refund"
    outcome:
      enum: ["resolved", "escalate", "needs_human"]   # the closed set of exits
      write: "agent_result"                           # single data output
  routes:
    resolved: "format_reply"
    escalate: "notify_manager"
    needs_human: "hitl_review"
    max_iterations: "fallback_summary"   # reserved abnormal exits
    error: "alert_ops"
    budget_exceeded: "fallback_summary"
    aborted: "alert_ops"
Property Default Description
model Required Preset name or LiteLLM model string (same as mode: completion)
steering Required The agent's persistent instruction, always injected — inline text or artifact://<steering artifact>
skills [] Optional when-relevant capabilities — inline text or artifact://<skill artifact> refs
tools Required Tools the agent may call; each ref names another step (APICall / MCP / ModelOp / Functional) with JSON-Schema parameters. The tool description is sourced from the referenced step's own description: (required); a description here is only a fallback
tools[].writes_context false When true, the tool's public output is merged back into the shared context (default: result returns to the agent only)
outcome.enum [] Closed set of exit signals; every value must be mapped in routes:
outcome.write <id>_result Context key that receives the agent's final result payload
max_iterations 8 Hard cap on loop turns
token_budget null Optional hard cap on cumulative tokens
guardrails none input / output / tools gates backed by type: guardrail artifacts

The reserved abnormal exits max_iterations, budget_exceeded, error, aborted (supervisor/operator abort), and guardrail_violation (when guardrails are attached) should also be mapped in routes:. system / prompt are completion-only fields and are rejected in this mode. For data-driven branching after an outcome, route into a deterministic router with match: — never push that logic into the agent.

Artifacts, Guardrails, and Hooks

Prompts, steering, skills, guardrails, hooks, and MCP server configs can all live as artifacts — named, versioned, typed assets in the project's artifacts/ directory, referenced from workflow YAML via artifact://name[@version]. Guardrails (type: guardrail) gate agent input/output/tool observations with a closed set of checks; hooks (type: hook) are observe-only lifecycle subscribers attached per step (hooks:) or workflow-wide (spec.hooks:). See the manual: Artifacts, Guardrails, and Hooks.

Router Steps

Evaluate a condition on the context and branch to different steps:

- id: "check_amount"
  kind: "Router"
  condition:
    field: "amount"          # context key (dot-path supported: "order.amount")
    operator: "gte"          # eq | neq | gt | gte | lt | lte | in | contains | is_empty | is_not_empty
    value: 10000
  routes:
    "true": "manual_review"
    "false": "auto_approve"

The router emits "true" or "false" as the route signal.

For multi-way value branching (e.g. by country or tier), use the match: switch instead of condition:. It emits the stringified value of a field as the route signal, falling back to default when the value isn't mapped:

- id: "route_by_country"
  kind: "Router"
  match:
    field: "user.country"     # dot-path supported
  routes:
    US: "resolve_us"
    DE: "resolve_eu"
    FR: "resolve_eu"
    default: "resolve_other"  # any unmapped value lands here

This is the idiomatic way to add data-driven branching after an autonomous Agent outcome — keep the deterministic logic in the router, not in the model.

Chain multiple routers for more complex conditional branching:

steps:
  - id: "check_high"
    kind: "Router"
    condition:
      field: "amount"
      operator: "gte"
      value: 10000
    routes:
      "true": "manual_review"
      "false": "check_medium"

  - id: "check_medium"
    kind: "Router"
    condition:
      field: "amount"
      operator: "gte"
      value: 1000
    routes:
      "true": "auto_approve"
      "false": "instant_approve"

Supported Operators

Operator Description
eq Equal to value
neq Not equal to value
gt Greater than value
gte Greater than or equal to value
lt Less than value
lte Less than or equal to value
in Value is in a list
contains String contains value
is_empty Field is None, "", or []
is_not_empty Field is not empty

API Call Steps

Make HTTP requests to external services:

- id: "fetch_weather"
  kind: "APICall"
  http:
    url: "https://api.weather.com/v1/current"
    method: "GET"
    headers:
      Authorization: "Bearer {{ api_key }}"
    timeout: 30               # seconds (default: 30)
  response:
    output_key: "weather_raw" # context key for the full response body
    extract:
      - path: "current.temp_c"
        as: "temperature"
      - path: "current.condition.text"
        as: "weather_conditions"
  routes:
    default: "next_step"
    error: "fallback"

On HTTP errors the engine sets _last_error and _api_status_code in context and emits the "error" signal.

MCP Steps

Call tools from MCP (Model Context Protocol) servers. The connection — SSE (default) or stdio transport — lives only in a type: mcp artifact; inline transport config on the step is rejected. One server, one definition, any number of steps.

The artifact declares the connection:

artifacts/github_mcp.yaml
kind: "Artifact"
metadata: { name: "github-mcp", version: 1 }
spec:
  type: "mcp"
  transport: "stdio"                # sse | stdio (default sse)
  command: "npx"
  args: ["@modelcontextprotocol/server-github"]
  env:
    GITHUB_TOKEN: "${GITHUB_TOKEN}"

The step carries only the call:

- id: "list_issues"
  kind: "MCP"
  mcp:
    server: "artifact://github-mcp"   # REQUIRED — ref to a type: mcp artifact
    tool: "list_issues"
    timeout: 30
    arguments:
      owner: "{{ repo_owner }}"
      repo: "{{ repo_name }}"
  response:
    output_key: "issues"              # full response stored here
    extract:
      - path: "0.title"
        as: "first_issue_title"

Model-Op Steps

Perform a CRUD operation on a registered model without writing a Python node. The model must be declared in the workflow's context: field.

- id: "create_candidate"
  kind: "ModelOp"
  model: "Candidate"            # PascalCase model name from MODEL_REGISTRY
  operation: "create"           # create | read | list | update | delete
  payload: "{{ candidate }}"    # dict or {{template}} — used by create / update
  output: "new_candidate"       # context key to store the result
  routes:
    default: "next_step"
    error: "handle_error"

Read with relations:

- id: "fetch_candidate"
  kind: "ModelOp"
  model: "Candidate"
  operation: "read"
  record_id: "{{ candidate_id }}"
  include: "education,experience"   # comma-separated relation names
  output: "candidate"

List with filters:

- id: "list_pending"
  kind: "ModelOp"
  model: "Application"
  operation: "list"
  filters:
    status: "pending"
  limit: 50
  output: "pending_applications"

Model-Op Properties

Property Required Description
model Yes PascalCase model name from MODEL_REGISTRY
operation Yes create | read | list | update | delete
payload For create/update Dict or {{template}} reference to a context dict
record_id For read/update/delete Primary key value; supports {{template}}
filters For list Equality filter dict
include No Comma-separated relation names to expand (read/list only)
limit No Max rows for list (default 100)
output No Context key for the result (default: {step_id}_result)

Response Steps

Shape the HTTP response without ending the workflow (useful as the last step in complex workflows):

# Source mode — expose an existing context key as-is
- id: "respond"
  kind: "Response"
  source: "candidate"

# Mapping mode — project specific fields from nested context
- id: "respond"
  kind: "Response"
  mapping:
    id: "candidate.id"
    full_name: "candidate.name"
    score: "evaluation.total_score"

The shaped payload is stored in context["_response"], which the engine returns as the HTTP response body (takes priority over the default context serialisation).

Human-in-the-Loop Steps

Pause workflow execution and hand off control to a human reviewer before continuing:

- id: "approve_application"
  kind: "HumanInTheLoop"
  ui:
    title: "Review Application"
    instruction: "Approve or reject {{ candidate_name }}'s application for {{ role }}."
    display_context:
      - candidate_name
      - role
      - cv_summary
  human_feedback:
    - name: approved
      type: boolean
      required: true
      label: "Approve application?"
    - name: notes
      type: string
      label: "Reviewer notes"
  output_key: approval_result
  auth:
    required_group: hr_manager
    assignee_user: "{{ assigned_reviewer }}"
  routes:
    default: "send_outcome"

When the engine reaches a HumanInTheLoop step it:

  1. Persists a HITL instance (a SystemWorkflowInstance row in the database) containing the current context snapshot and the step definition.
  2. Raises a suspension signal — the workflow API responds with HTTP 202 Accepted (REST), a suspended SSE frame, or a suspended gRPC step event, carrying a hitl_request payload.
  3. The frontend displays the review form to the designated user.
  4. Once the reviewer submits their response the workflow resumes from the next step, with the human's answers merged into the context under output_key.

Human-in-the-Loop Properties

Property Required Description
id Yes Unique step identifier
kind Yes Must be HumanInTheLoop
ui.title No Heading shown to the reviewer. Supports {{ var }} interpolation.
ui.instruction No Detailed instructions for the reviewer. Supports {{ var }} interpolation.
ui.display_context No Allowlist of context keys sent to the reviewer. If omitted, no context data is forwarded.
human_feedback No List of form field definitions (see below). If empty the reviewer can only approve/dismiss.
output_key No Context key under which the reviewer's answers are stored. Defaults to hitl_<id>.
auth.required_group No IAM group whose members may resume this instance (enforced with 403; iam:admin bypasses).
auth.assignee_user No Reviewer assignment hint for the UI. Supports {{ var }} interpolation.

human_feedback Field Definition

Each entry in human_feedback defines one input field on the review form:

Key Required Type Description
name Yes string Key used in the output dict
type Yes string boolean, string, integer, or float
label No string Human-readable label shown above the input
required No boolean Prevent form submission until filled. Defaults to false.

hitl_request Payload

The payload delivered to the frontend when a workflow suspends:

{
  "instance_id": "550e8400-e29b-41d4-a716-446655440000",
  "paused_step_id": "approve_application",
  "output_key": "approval_result",
  "ui": {
    "title": "Review Application",
    "instruction": "Approve or reject Jane Doe's application for Senior Engineer.",
    "display_context": ["candidate_name", "role", "cv_summary"]
  },
  "human_feedback": [
    { "name": "approved", "type": "boolean", "required": true, "label": "Approve application?" },
    { "name": "notes",    "type": "string",  "required": false, "label": "Reviewer notes" }
  ],
  "context_data": {
    "candidate_name": "Jane Doe",
    "role": "Senior Engineer",
    "cv_summary": "10 years backend, Python, distributed systems."
  },
  "auth": { "required_group": "hr_manager" }
}

Resuming a Suspended Workflow

POST the reviewer's answers to the resume endpoint, wrapped in human_input:

POST /api/workflows/resume
Authorization: Bearer <token>
Content-Type: application/json

{
  "instance_id": "550e8400-e29b-41d4-a716-446655440000",
  "human_input": {
    "approved": true,
    "notes": "Strong candidate, approved."
  }
}

When the step declares auth.required_group, only members of that group (or an iam:admin) may resume it; otherwise only the user who triggered the workflow. The instance is deleted before the engine re-runs, so resume is one-shot.

The engine resumes execution with context["approval_result"] set to that dict.

See Human-in-the-Loop for the full API reference and UI builder details.

Routing

Routes determine the next step based on signals:

- id: "process"
  kind: "Functional"
  runner: "process_data"
  routes:
    success: "notify"           # On "success" signal → go to "notify"
    retry: "process"            # On "retry" signal → loop back
    error: "handle_error"       # On "error" signal → go to error handler
    default: "cleanup"          # Fallback for unmatched signals

Special Route Targets

Target Behavior
"END" Stop workflow execution
Step ID Jump to that step
(none) Continue to next sequential step

Signal Sources

Nodes can return signals in three ways:

# 1. Return string → signal
@node("check_inventory")
async def check(ctx):
    if ctx["quantity"] > ctx["stock"]:
        return "out_of_stock"
    return "in_stock"

# 2. Return dict → updated context, default signal
@node("process")
async def process(ctx):
    ctx["processed"] = True
    return ctx

# 3. Return tuple → (context, signal)
@node("validate")
async def validate(ctx):
    if valid(ctx):
        return ctx, "valid"
    return ctx, "invalid"

Jinja2 Templating

Prompts and configurations support Jinja2 templates:

prompt: |
  Customer: {{ customer_name }}
  Order Total: ${{ "%.2f"|format(total) }}
  Items:
  {% for item in items %}
  - {{ item.name }}: {{ item.quantity }}
  {% endfor %}

Available in templates:

  • All context keys
  • Jinja2 filters (|format, |upper, |default, etc.)
  • Conditionals and loops

Error Handling

Step-Level Errors

Use the error route to handle step failures:

- id: "risky_step"
  kind: "Functional"
  runner: "external_api_call"
  routes:
    default: "next"
    error: "fallback"

- id: "fallback"
  kind: "Functional"
  runner: "log_and_continue"

Workflow-Level Errors

If an error occurs without a mapped route, the workflow:

  1. Sets _last_error in context
  2. Stops execution
  3. Returns a 400/500 response with error details

Retry Configuration

For agent steps:

agent:
  retry:
    attempts: 3
    on: [parse_error, timeout, rate_limit]
    backoff: 2    # Exponential backoff multiplier

Workflow Versioning

tuvl tracks every (name, schema_version) pair in WORKFLOW_VERSION_REGISTRY (in memory) and in the workflow_versions database table (on the primary datasource). This lets you manage the lifecycle of workflow changes without YAML edits or server restarts.

Listing all versions

GET /admin/workflows

Returns a dict keyed by workflow name, each value being a list of version objects:

{
  "onboard": [
    { "schema_version": "v1", "enabled": true,  "trigger_path": "/api/onboard", "trigger_method": "POST" },
    { "schema_version": "v2", "enabled": false, "trigger_path": "/api/onboard", "trigger_method": "POST" }
  ]
}

Toggling a version on/off

PATCH /admin/workflows/{name}/{version}/toggle

Flips enabled in the database without touching YAML. Changes take effect immediately for new requests.

Forking a version

The fork endpoint deep-copies an existing version's config, stamps it with a new schema_version, writes it to workflows/, and returns the filename:

POST /admin/workflows/onboard/v1/fork
{ "new_version": "v2" }
{ "name": "onboard", "source_version": "v1", "new_version": "v2", "file": "onboard_v2.yaml" }

See Admin Version Management API for full endpoint reference.

Best Practices

1. Keep Steps Small

Each step should do one thing well:

# Good
steps:
  - id: "validate"
  - id: "transform"
  - id: "save"
  - id: "notify"

# Avoid
steps:
  - id: "do_everything"

2. Use Meaningful IDs

# Good
- id: "verify_email_format"
- id: "check_duplicate_user"

# Avoid
- id: "step_1"
- id: "step_2"

3. Handle All Routes

Always define error routes for critical steps:

- id: "payment"
  routes:
    success: "fulfill"
    declined: "notify_decline"
    error: "manual_review"    # Don't forget this!

4. Use Context Prefixes

Organize context keys with prefixes:

outcome:
  map:
    result: "ai_classification"    # Prefix with source
    confidence: "ai_confidence"

Next Steps

  • Nodes — Building node functions
  • Agents — Configuring LLM providers
  • Human-in-the-Loop — Full HITL reference, UI builder, and Lens testing
  • Examples — Complete workflow examples