Skip to content

ApprovalML YAML Syntax Reference

This page provides a detailed reference for the ApprovalML YAML syntax, designed for AI-powered workflow generation.

ApprovalML is a YAML-based language for defining business approval workflows. It combines form creation with powerful routing logic.

Every ApprovalML file follows this basic structure:

name: "Workflow Name"
description: "A brief summary of the workflow's purpose."
version: "1.0" # Optional version number
type: "workflow_type" # Optional classification
# Optional: Define who can submit this workflow
submission_criteria:
company_roles: [] # Array of roles that can initiate the workflow
org_hierarchy:
include_paths: ["1.1.*"] # Organizational path patterns for eligibility
# Optional: allow anonymous, no-login public submission (bypasses submission_criteria).
# This workflow IS the verification/intake process — see "Public Submission" below.
# public_submission:
# enabled: true
# requestor_email: "automation@company.com" # optional — owner of this workflow's own instances
# Form definition for data collection
form:
fields: []
# Workflow logic with interconnected steps
workflow:
step_name: {}
# Optional: Advanced settings for the workflow
settings:
notifications: {}
compliance: {}
# Optional: PDF export configuration
print:
orientation: "portrait"
page_size: "A4"
margin: "8mm"
show_history: true

Define the data to be collected from the user.

  • text: Single-line text input.
  • textarea: Multi-line text area.
  • email: Validated email input.
  • number: Numeric input with validation.
  • currency: Monetary value with currency formatting.
  • date: Date picker.
  • select: Dropdown menu with predefined choices.
  • multiselect: Dropdown for multiple selections.
  • checkbox: A single boolean checkbox.
  • radio: A group of options where only one can be selected.
  • file_upload: For attaching files.
  • json: Structured JSON data with tree view support.
  • honeypot: Invisible anti-bot trap field. public_submission forms only — see Public Submission below.
  • turnstile: Cloudflare Turnstile CAPTCHA widget. public_submission forms only — see Public Submission below.
- name: "field_name" # Required: A unique identifier for the field.
type: "field_type" # Required: One of the types listed above.
label: "Display Label" # Required: The text shown to the user.
required: true/false # Required: Specifies if the field must be filled.
placeholder: "Hint text" # Optional: Placeholder text for the input.
accept: ".pdf,.jpg,.png" # For `file_upload`: specifies accepted file types.
multiple: true/false # For `file_upload` or `multiselect`: allows multiple values.
style: "warning" # Optional: Visual emphasis style (see below).
# Validation rules for the field
validation:
min: 0.01 # Minimum value for `number` or `currency`.
max: 10000 # Maximum value for `number` or `currency`.
# Options for `select`, `multiselect`, or `radio`
options:
- value: "option_key"
label: "Display Text"
# Or use the shorthand when value and label are the same:
# options: ["operations", "marketing", "sales", "hr", "it", "finance"]
# Or load from a connector (select/multiselect fetch once on mount):
# options:
# data_source:
# source_name: Odoo Injani List Companies
# object_path: $
# value_field: id
# label_field: name
# Optional currency code for `currency` fields
currency: "USD" # Specifies the ISO currency code (e.g., USD, EUR, JPY).

Use the style property to visually emphasize important fields during approval:

Style Appearance Use Case
warning Amber/Yellow background Changes requiring attention, diff results
danger Red background Critical items, deletions, errors
success Green background Positive outcomes, confirmations
info Blue background Informational, read-only context
- name: "change_summary"
type: "textarea"
label: "Change Summary"
style: "warning" # Renders with amber background to draw attention
required: false
- name: "total_amount"
type: "currency"
label: "Total Amount"
required: true
currency: "USD" # Defaults to the company's primary currency if not set.
validation:
min: 0.01
max: 50000

These properties control the presentation and behavior of certain field types.

For radio fields, you can render the options as a button group instead of traditional radio inputs by using the display_as property.

- name: "equipment_check"
type: "radio"
label: "Is all equipment accounted for?"
required: true
display_as: "buttons" # Renders choices as buttons
options:
- { value: "yes", label: "Yes" }
- { value: "no", label: "No" }
- { value: "na", label: "N/A" }

For file_upload fields, you can force the use of the device camera for capturing images directly.

  • capture: Set to "environment" to prefer the rear-facing camera or "user" for the front-facing camera.
  • multiple: Set to true to allow multiple captures, or false (default) for a single image.
- name: "site_photo"
type: "file_upload"
label: "Take a photo of the work site"
required: true
accept: "image/*"
multiple: false
capture: "environment" # Opens the rear camera directly

For creating repeatable sections of fields, like items in an invoice.

- name: "items_to_purchase"
type: "line_items"
label: "Items to Purchase"
min_items: 1
max_items: 20
item_fields:
- name: "item_description"
type: "text"
label: "Description"
required: true
- name: "quantity"
type: "number"
label: "Qty"
validation:
min_value: 1
- name: "unit_price"
type: "currency"
label: "Unit Price"
- name: "total"
type: "currency"
label: "Total"
readonly: true
calculated: true
formula: "quantity * unit_price" # Automatically calculates the value

PDF-only columns, grouped headers, and blank rows:

Use print_only: true on an item_fields column to show it only in the PDF export, and use header_groups to render merged column headers. blank_rows appends empty rows in the PDF for handwritten notes.

- name: "invoice_lines"
type: "line_items"
label: "Invoice Lines"
item_fields:
- name: "description"
type: "text"
label: "Description"
width: "40%" # web read-only table width
print_width: "35%" # PDF column width
- name: "quantity"
type: "number"
label: "Qty"
align: "right"
print_width: "12%"
- name: "unit_price"
type: "currency"
label: "Unit Price"
currency: "IDR"
align: "right"
print_width: "18%"
- name: "amount"
type: "currency"
label: "Amount"
currency: "IDR"
align: "right"
print_width: "18%"
- name: "note_1"
type: "text"
label: "" # empty label allowed for print_only columns
print_only: true # hidden in web form; visible only in PDF
print_width: "6%"
- name: "note_2"
type: "text"
label: ""
print_only: true
print_width: "6%"
header_groups:
- label: "Article Details"
span: 4 # description, quantity, unit_price, amount
- label: "Notes"
span: 2 # note_1, note_2
blank_rows: 3 # extra empty PDF rows for handwriting

Each header_groups entry’s span is a column count, and all spans must sum to exactly the number of item_fields (6 above) — a mismatch misaligns the merged header row against the actual columns in the rendered PDF.

In-table column aggregate — aggregate::

Add aggregate: sum | count | average | min | max to a numeric (currency/number) item_fields column to get an aggregate row rendered as the last row of that table, directly under the column it summarizes:

- name: "amount"
type: "currency"
label: "Amount"
currency: "IDR"
align: "right"
print_width: "18%"
aggregate: sum # sum | count | average | min | max

count counts the non-empty values in the column; sum/average/min/max operate over its non-empty numeric values, skipping blank rows. sum: true still works as a backward-compatible alias for aggregate: sum. The aggregate row also appears in the web read-only view (Request Details / approval page), not just the printed PDF — this is independent of (and can be combined with) a top-level calculated: true + jsonata: "$sum(...)" field, which instead produces a separate, independently-placed form field.

Labeling the aggregate row — aggregate_label on the line_items field itself (not on an item_field):

- name: "invoice_lines"
type: "line_items"
label: "Invoice Lines"
aggregate_label: "TOTAL" # merged across every column before the first aggregate: column
item_fields:
- name: "description"
type: "text"
label: "Description"
- name: "amount"
type: "currency"
label: "Amount"
aggregate: sum

Without aggregate_label, the aggregate row is just the bare value with every other cell blank. With it, every column before the first aggregate: column merges into one right-aligned cell showing the label, immediately followed by the aggregate — e.g. | ... | TOTAL | Rp 34.850.000,00 |. Has no effect if the field has no aggregate: column. Multiple aggregate: columns can coexist — each renders its own value; the label only merges columns before the first one.

Hiding an empty table entirely — hide_when_empty:

Some line_items fields (e.g. an optional “Lampiran”/attachment table) always have at least one row in submitted data, because a min_items: 1 (or higher) requirement makes the web form auto-add a blank placeholder row even when the user leaves it empty. Without hide_when_empty, that placeholder still prints as a table with dashes for every cell and a 0 aggregate row. Set hide_when_empty: true on the line_items field to suppress the label and the table together whenever every row’s real columns (excluding print_only columns) are blank or zero:

- name: "lampiran"
type: "line_items"
label: "Lampiran"
hide_when_empty: true # omit the label + table when there's no real data
item_fields:
- name: "tanggal"
type: "date"
label: "Tanggal"
- name: "keterangan"
type: "text"
label: "Keterangan"
- name: "nilai"
type: "currency"
label: "Nilai"
aggregate: sum

Defaults to false — existing workflows that rely on a visible zero total are unaffected.

Public Submission (Anonymous, No-Login Entry)

Section titled “Public Submission (Anonymous, No-Login Entry)”

Let an unauthenticated stranger submit this workflow’s entry point from a public URL — no login, no personal API token. submission_criteria is bypassed entirely when enabled, since an anonymous submitter has no org_path/company roles to evaluate against.

A public_submission-enabled workflow is the verification/intake process — not a separate staging step. It starts immediately (after free anti-spam checks: honeypot, rate limit, Turnstile, MX lookup) using an already-existing internal employee as requestor, resolved the same way scheduled triggers resolve theirs:

public_submission:
enabled: true
requestor_email: "automation@company.com" # optional — owner of this workflow's own instances
# requestor_company_role: "workflow_admin" # alternative to requestor_email
# falls back to this workflow's creator if both are omitted
rate_limit:
max_requests: 5
period_seconds: 3600

Deliverability checks, spam scoring, and identity confirmation are then just ordinary steps authored directly in workflow: — no separate verification mode to configure:

  • Paid deliverability/spam checks (Kickbox, ZeroBounce, OOPSpam, etc.) — an automatic step calling a company-configured connector, routed with conditional_split. Not a field-validation rule, since it costs money per call and should run once per instance, not on every keystroke.
  • Identity confirmation — an ordinary decision step. approver: { email: "${form.contact_email}", name: "${form.full_name}" } gives self-service email-link confirmation for free (the existing dynamic-approver mechanism already auto-creates the external employee and sends the standard no-login approval link); approver: hr_admin gives human review instead; omit the step entirely for no confirmation.
  • Promoting to a real business instance, correctly attributed to the submitter — a spawn step with requestor_from: <field_name> resolves/creates the child instance’s requestor from that field instead of inheriting the parent’s (internal) requestor.
  • Discarding spam without cluttering dashboards — an end step with archive: true hides the instance from normal list views while keeping its full audit trail.
  • Silent verification completion — an end step with notify_completion: false skips the automatic completion PDF emails to the requestor and approvers (see End Step).

Anti-spam form fields — add these to form.fields on any public_submission form:

- name: website_url
type: honeypot # invisible trap; real users never see or fill it
min_fill_seconds: 3 # also rejects submissions faster than this after the form rendered
- name: captcha
type: turnstile # Cloudflare Turnstile CAPTCHA widget
required: true
- name: contact_email
type: email
required: true
validation:
check_mx: true # free, local MX-record lookup — no API call

When to use public_submission:

  • External parties with no company account need to submit a request (vendors, applicants, guests)
  • One workflow should handle both the public intake form and the internal approval chain that follows

When to omit it (default):

  • Only known, authenticated employees should ever submit this workflow
  • The workflow needs submission_criteria-based role/org-path eligibility targeting

Define the logic and routing of the approval process.

A standard approval step requiring a user to take action. It can have multiple outcomes.

manager_approval:
name: "Manager Approval"
type: "decision"
approver: "${requestor.manager}" # Dynamically assigns to the requestor's manager
sla: "2d 4h" # Target completion time
on_approve:
continue_to: "FinanceReview"
on_reject:
end_workflow: true

Define custom outcomes using on_<action> keys.

triage_step:
name: "Triage Support Ticket"
type: "decision"
approver: "support_lead"
sla: "4h" # Urgent triage target
on_technical:
text: "Assign to Technical Team"
continue_to: "TechnicalReview"
on_billing:
text: "Assign to Billing"
continue_to: "BillingReview"
on_close:
text: "Close as Duplicate"
style: "destructive" # Optional UI hint for the action button
end_workflow: true

Allows multiple approvers to act simultaneously.

parallel_step:
name: "Parallel Step"
type: "parallel_approval"
description: "Requires input from multiple stakeholders."
approvers:
- role: "purchasing_officer_1"
- role: "purchasing_officer_2"
- role: "purchasing_officer_3"
approval_strategy: "any_one" # Can be `any_one`, `all`, or `majority`
sla: "48h" # Shared SLA for all parallel approvers
on_approve:
continue_to: "next_step"
on_reject:
end_workflow: true

Routes the workflow dynamically based on form data.

routing_step:
name: "Routing Step"
type: "conditional_split"
description: "Routes based on the request amount."
choices:
- conditions: "amount > 10000 and urgency == 'critical'"
continue_to: "ceo_approval"
- conditions: "department == 'engineering'"
continue_to: "tech_approval"
default:
continue_to: "auto_approve"

Performs system actions without human intervention. Supports two main operations:

Fetch data from a configured Data Processor and optionally compare against a baseline asset.

fetch_data:
type: "automatic"
name: "Fetch Current Data"
data_processor:
source_id: "src_xxx" # Data Processor unique ID
save_to: "fetched_data" # Save fetched data to this form field
compare_to_asset: "baseline" # Optional: compare with asset baseline
save_diff_to: "diff_result" # Optional: save diff result to this field
field_mapping: # Optional: extract and transform data from the response
customer_name: "$.data.customer.name"
product_name:
source: "$.product.name"
jsonata: "$replace(value, /\\[\\d+\\]\\s*/, '')"
on_complete:
continue_to: "check_changes"

Automatically extract text or structured data from uploaded files using the extract_document processor. You can map the extracted content (like Markdown) to a field and then use it as input for AI-powered steps.

# Step 1: Extract text from the uploaded PDF
extract_document:
type: automatic
data_processor:
source_name: "Docling - Extract Text"
save_to: docling_raw
params:
- name: file
from_field: field.uploaded_document
field_mapping:
extracted_text: "$.data.markdown" # Map the extracted markdown to a form field
on_complete:
continue_to: analyze_document
# Step 2: Use the extracted text in an AI prompt
analyze_document:
type: automatic
mcp:
connector: "claude_mcp"
command: >
Analyze the following document text and summarize the key findings,
specifically looking for any mentions of contract expiration dates:
${extracted_text}
save_to: "ai_summary"
on_complete:
continue_to: manager_approval

Advanced Integration: Document to ERP (Odoo)

Section titled “Advanced Integration: Document to ERP (Odoo)”

This advanced pattern combines document extraction, structured AI processing, and ERP integration. The AI extracts specific fields from a PDF and returns them as valid JSON, which is then posted to Odoo.

workflow:
# 1. Extract raw Markdown from uploaded PDF
extract_pdf:
type: automatic
data_processor:
source_name: "Docling - Extract Text"
save_to: docling_raw
params:
- name: file
from_field: field.uploaded_quote
field_mapping:
extracted_markdown: "$.data.markdown"
on_complete:
continue_to: parse_with_ai
# 2. AI parses Markdown into structured JSON for Odoo
parse_with_ai:
type: automatic
mcp:
connector: "claude_mcp"
command: >
Extract the customer name and line items from this markdown text:
${extracted_markdown}
save_to: "odoo_so_json"
output_schema:
type: object
properties:
partner_name: { type: string }
order_lines:
type: array
items:
type: object
properties:
product_name: { type: string }
quantity: { type: number }
price_unit: { type: number }
required: ["partner_name", "order_lines"]
on_complete:
continue_to: create_odoo_so
# 3. Post the structured JSON to Odoo API
create_odoo_so:
type: automatic
data_processor:
source_name: "Odoo - Create Sales Order"
params:
- name: order_data
from_field: field.odoo_so_json
on_complete:
continue_to: manager_approval

Compile a company-owned .typ template asset — legal-style prose with numbered sections, not a form-structure PDF — into a filled PDF, merging in form field values and any captured signature images. The template asset itself is managed through the normal Asset Registry UI (a “Document Templates” schema with one file_upload field).

render_contract:
type: automatic
typst_render:
template_asset: "Outsourcing PKS+NDA Template" # resolved by name, like data_processor's source_name
save_to: signed_contract_pdf # form field to receive the compiled PDF
data_from:
agency_name: agency_name # template variable: form field name
director_signature: director_signature # a signature field's base64 image is placed like any other input
on_complete:
continue_to: store_signed_file
on_failure:
continue_to: notify_render_failed

Properties:

Property Required Description
template_asset Yes Name of a Document Templates asset holding the finished .typ file
save_to Yes Form field to receive the compiled PDF, in the same {file_path, original_name, content_type} shape a file_upload field uses — so a following asset_file: step can store it with no extra wiring
data_from No Mapping of template variable → form field name, resolved directly against the instance’s current data
template_field No Exact asset field holding the template file. Only needed when the template asset’s schema has more than one file_upload field — without it, the first file field found on the asset is used, which is ambiguous once there’s more than one

A typst_preview form field (see Form Fields) lets users render the same template on demand, before the value in save_to is ever committed — useful for showing external signers the filled document before they sign.

data_processor.source_name may be a {{field}} template. The engine substitutes it from the current form data before looking up the data source, the same way other {{field}} templates work. A literal name is unchanged.

roster_classify and roster_apply are automatic-step blocks for comparing a directory list with the company roster. roster_classify reads the rows saved by a previous fetch (live_rows_from) and writes unknown addresses to save_to. roster_apply reads the reviewer’s line items (rows_from). A checked row is saved; record_as: shared_account saves a shared-account grant and any other value saves an employee. An unchecked row, or force_reject: true, stores a dismissal and leaves the directory account in place.

classify_unknown:
type: automatic
roster_classify:
live_rows_from: live_rows_raw
save_to: unknown_addresses
on_complete:
continue_to: review_unknown
apply_decisions:
type: automatic
roster_apply:
rows_from: unknown_addresses
on_complete:
continue_to: end_decided

A line_items column with type: select renders a dropdown while the reviewer can act. Unchecked rows stay action: reject.


Matching extracted text (e.g., “Laptop Pro 14”) to a specific database record (e.g., Odoo Product ID 452) is the most challenging part of automation. Since Odoo’s standard API doesn’t support semantic “similarity” search, Aptiwise recommends two patterns:

Section titled “Pattern A: AI + MCP Search Tool (Recommended)”

In this pattern, you give the AI an MCP tool (e.g., search_odoo_products) that it can call during its processing loop. This tool performs a Vector Search against a local cache of your Odoo products.

Why this works:

  1. Semantic Understanding: The fastembed service converts Odoo product names into vectors.
  2. AI Judgment: If multiple matches are found, the AI can look at the context (price, vendor) to pick the correct one.
  3. No Code: The logic is handled by the AI’s “thought process.”
workflow:
parse_and_match:
type: automatic
mcp:
connector: "odoo_mcp_gateway"
command: >
For every item in this markdown: ${extracted_markdown},
first use the 'search_product' tool to find the closest Odoo ID.
If the price in the document differs from Odoo by more than 10%,
flag it in the 'ai_notes' field.
save_to: "matched_order_data"
output_schema:
# Schema for a valid Odoo Sales Order
type: object
properties:
partner_id: { type: integer }
order_line:
type: array
items:
type: object
properties:
product_id: { type: integer }
product_uom_qty: { type: number }
price_unit: { type: number }

If you have thousands of items, use a dedicated Data Processor that performs a batch vector lookup.

  1. Sync: Periodically sync Odoo products to an Aptiwise Data Source.
  2. Embed: Aptiwise automatically generates pgvector embeddings for these names.
  3. Match Step: An automatic step calls a processor that runs a SQL query: SELECT id FROM products ORDER BY embedding <=> $1::vector LIMIT 1
  • Filter by Vendor: When searching for products, always pass the vendor_id as a filter to the search tool to narrow down the candidates.
  • Price Verification: Ask the AI to compare the extracted price vs. the database price. If they don’t match, route the workflow to a human for “Exception Review.”
  • Confidence Scores: The vector search returns a similarity score (0.0 to 1.0). You can configure the workflow to auto-approve only if the score is > 0.90.
**Data Processor Properties:**
| Field | Required | Description |
|-------|----------|-------------|
| `source_id` | One of `source_id` or `source_name` | Stable unique ID of the Data Processor (e.g. `src_xxx`) |
| `source_name` | One of `source_id` or `source_name` | Human-readable name — portable across companies and environments |
| `save_to` | Required when using `compare_to_asset`; otherwise required unless `field_mapping` is present | Form field name to store the raw fetched data |
| `compare_to_asset` | No | Name of the asset baseline to compare against (uses DeepDiff) |
| `save_diff_to` | No | Form field for the human-readable diff string (`"None"` if no changes detected) |
| `ignore_keys` | No | List of JSON key paths to exclude from drift comparison (e.g. `["etag", "updated_at"]`) |
| `params` | No | List of parameters to pass to the data source (see below) |
| `join` | No | Inline relational field lookup — resolve FK IDs to display values (see below) |
| `field_mapping` | No | Extract and transform specific data into form fields (see below) |
| `verify` | No | Target-state check consulted only when a retry resumes a genuinely ambiguous prior attempt (see below) |
**Target-State Verification (`verify:`) — safe retries for steps with external side effects**
By default, retrying a `data_processor` step (a Cloud Tasks/ARQ redelivery, or a future manual "Retry" action on a failed step) just re-runs it — fine for a pure read, but risky for a step that writes to an external system (creates a user, posts a transaction) if the original attempt actually succeeded right before the process crashed. `verify:` closes that gap by checking the real target state before deciding whether a re-run is needed:
```yaml
provision_user:
type: automatic
data_source:
source_name: "Odoo Create User"
save_to: create_result
params:
- name: email
from_field: field.email
verify:
check:
source_name: "Odoo Get User By Email" # any data source — same source_id/source_name + params resolution as data_processor itself
params:
- name: email
from_field: field.email
matches: "$exists(data[0]) and data[0].active = true" # JSONata boolean, evaluated against check's raw result
on_complete:
continue_to: notify

verify properties:

  • check (required): a data-source config (same source_id/source_name + params resolution as data_processor itself) pointed at a query for the current state of whatever the step just tried to write
  • matches (required): a JSONata boolean expression evaluated against check’s raw result — you encode create/update/delete semantics yourself (e.g. $exists(data[0]) for a create, $count(data) = 0 for a delete); the engine has no built-in CRUD vocabulary

When it fires: only when the step-execution idempotency ledger shows a pending attempt with no recorded outcome — meaning a prior attempt crashed mid-flight and it’s genuinely unknown whether the write landed. A clean failure never reaches verify: (already known not to have happened); a clean success is caught earlier by the ledger’s own succeeded-attempt guard, before verify: would even be consulted. If verify: is absent, or matches doesn’t match, the step falls back to re-running exactly as it did before this feature existed — verify: is fully optional and changes nothing for a step that doesn’t declare it. Only add it to a step where re-running its side effect would actually be unsafe if it turned out to have already succeeded.

Passing Parameters to a Data Processor:

Use the params: list to inject dynamic values into the connector call:

data_processor:
source_id: src_gcp_iam
save_to: iam_snapshot
params:
- name: project_id
from_field: field.project_id # read from a form field
- name: cursor
from_asset: sync-checkpoint # read from an asset's properties
property: $.last_cursor # JSONPath into asset.properties (optional)
- name: api_version
value: "v3" # literal value

Each param entry requires exactly one source:

  • from_field: field.<name> — reads request_data[name] from the current workflow instance
  • from_asset: <asset-name> — reads assets.properties for the named asset; use optional property: $.path to extract a specific key via JSONPath
  • value: <literal> — a static string or number

Typed Placeholders — {{param:type}}:

The data source’s own url_template/body_template (configured on the connector’s data source, not in the params: list above) uses {{param}} placeholders. By default a substituted value is treated as a plain string. Append :type to preserve a different type when the value is embedded into JSON:

Suffix Effect
{{name}} Plain string (default — same as :string)
{{count:integer}} Coerced to an integer
{{ratio:number}} Coerced to a float
{{active:boolean}} Coerced to true/false
{{ids:json}} Parsed as JSON — required for array/object values, e.g. ["a","b"] or {"k":"v"}
{{doc:file}} Renders a file picker in Run Test; connector dispatches as multipart
# body_template on the data source, e.g. an Odoo JSON/2 "read" call:
body_template: '{"ids": {{ids:json}}, "fields": ["name", "amount"]}'

Without :json, an array-shaped parameter value that arrives as a string (e.g. typed into “Run Test” as [1,2], or forwarded from a text field) stays a literal string like "[1,2]" instead of becoming a real array. Passed to an ERP like Odoo, this typically fails with a confusing low-level error (e.g. Postgres complaining it can’t read "[" as an integer) rather than a clear validation message — because the ORM iterates the string character-by-character instead of treating it as a list of IDs. Always annotate array/object params with :json.

DriftWatch Example — Incremental Cursor Pattern:

fetch_latest:
type: automatic
data_processor:
source_id: src_transactions_api
save_to: new_transactions
params:
- name: since_id
from_asset: tx-checkpoint # reads last processed transaction ID
property: $.last_id
compare_to_asset: tx-checkpoint
save_diff_to: drift_result
ignore_keys: ["timestamp", "etag"]
on_complete:
continue_to: check_drift

Inline Join — Resolving Relational ID Fields

Section titled “Inline Join — Resolving Relational ID Fields”

ERP systems like Odoo store relational fields as integer IDs or arrays (e.g. tax_ids: [49, 50]). The join key resolves these to human-readable values in a single step — the engine batch-fetches the related records, builds an in-memory lookup, and writes the resolved value onto each row before field_mapping runs. No extra workflow steps, no JSONata, no vars wiring.

Single-field pick — extract one value per join, write to one output field:

fetch_invoice_lines:
type: automatic
data_processor:
source_id: src_invoice_lines
save_to: invoice_lines
join:
- field: tax_ids # field on each row (scalar int or array)
source_id: src_tax_api # source to batch-fetch related records from
on: id # key in join records to match against (default: "id")
pick: name # field to extract from each matched record (default: "name")
as: tax_name # output field written onto each row
field_mapping:
invoice_lines:
source: "$.invoice_lines.data"
item_fields:
qty: quantity
unit_price: price_unit
tax: tax_name # already resolved — "Included PPN"
on_complete:
continue_to: manager_approval

Multi-field pick — extract several fields from the same join source in one API call:

data_processor:
source_id: src_invoice_lines
save_to: invoice_lines
join:
- field: tax_ids
source_id: src_tax_api
on: id
pick: # dict: output_field_name: source_field_name
tax_name: name # row.tax_name ← record.name (e.g. "Included PPN")
tax_rate: amount # row.tax_rate ← record.amount (e.g. "11%")
tax_account: code # row.tax_account ← record.code (e.g. "4310")
# `as` is not used when pick is a dict

Then reference all resolved fields directly in field_mapping:

field_mapping:
invoice_lines:
source: "$.invoice_lines.data"
item_fields:
qty: quantity
unit_price: price_unit
tax: tax_name
rate: tax_rate
account: tax_account

join field reference:

Field Required Default Description
field ✅ Yes — Field on each row holding the FK ID(s)
source_id ✅ Yes — Connector source to batch-fetch related records from
as ✅ when pick is a string — Output field name written onto each row
on No "id" Key field in join records to match against
pick No "name" String (single field) or dict {output: source} (multiple fields)
param No "ids" Parameter name sent to the join source for the collected IDs
separator No ", " Separator when field is an array and output is a string
as_array No false true → output is a list of strings instead of a joined string

Notes:

  • Multiple entries under join: are supported — each resolves a different FK field. Each entry makes its own batch API call.
  • pick as a dict extracts multiple fields from the same API call — no extra network requests.
  • For array FKs: tax_ids: [49, 50] → "Included PPN, GST 10%" (string, default) or ["Included PPN", "GST 10%"] (with as_array: true).

Extract and transform data from API responses into form fields using JSONPath and JSONata.

Three Types of Field Mapping:

  1. Simple JSONPath Extraction

Extract a value from the JSON response using JSONPath syntax:

field_mapping:
customer_name: "$.data.customer.name"
invoice_number: "$.invoice.number"
partner_id: "$.data[0].partner_id[0]"
  1. Nested Array Mapping (Line Items)

Map JSON arrays to line_items fields:

form:
fields:
- name: invoice_lines
type: line_items
label: "Invoice Lines"
item_fields:
- name: product_name
type: text
label: Product
- name: quantity
type: number
label: Qty
- name: price
type: currency
label: Price
workflow:
fetch_invoice:
type: automatic
data_processor:
source_id: src_invoice_api
save_to: raw_invoice
field_mapping:
invoice_lines:
source: "$.invoice.invoice_line_ids"
item_fields:
product_name: "display_name"
quantity: "quantity"
price: "price_unit"
  1. JSONata Transformations

Transform data using JSONata expressions for string operations, regex, and more:

field_mapping:
# Remove product ID prefix: [434322544] Plastic Cup -> Plastic Cup
product_name:
source: "$.product.name"
jsonata: "$replace(value, /\\[\\d+\\]\\s*/, '')"
# Concatenate address fields
full_address:
jsonata: "street & ', ' & city & ' ' & zip"
# Extract first 3 characters and uppercase
customer_code:
source: "$.customer.name"
jsonata: "$uppercase($substring(value, 0, 3))"
# Combine first and last name
full_name:
jsonata: "firstName & ' ' & lastName"

JSONata Expression Syntax:

  • String functions: $uppercase(), $lowercase(), $substring(), $trim()
  • Regex replace: $replace(value, /pattern/, "replacement")
  • Concatenation: Use & operator (e.g., field1 & ' ' & field2)
  • Context variable: When using source, access extracted value via value
  • No source: Without source, the entire payload is available

Common JSONata Examples:

# Remove special characters
clean_text:
source: "$.description"
jsonata: "$replace(value, /[^a-zA-Z0-9\\s]/, '')"
# Format phone number
phone_formatted:
source: "$.phone"
jsonata: "$replace(value, /(\\d{3})(\\d{3})(\\d{4})/, '($1) $2-$3')"
# Conditional value
status_text:
source: "$.status"
jsonata: "status = 'active' ? 'Active' : 'Inactive'"
# Extract domain from email
domain:
source: "$.email"
jsonata: "$substringAfter(value, '@')"

Error Handling:

Field mapping is designed to be fault-tolerant:

  • If a JSONPath doesn’t match, the field is skipped (not populated)
  • If an array is empty, it’s set as an empty array []
  • Errors are logged for admin diagnosis but workflow continues
  • Missing nested fields log warnings but don’t crash the workflow

Diff Result Format: The diff result includes markdown-style emphasis for visual highlighting in the approval UI:

⚠️ **3 change(s) detected**
**➕ ADDITIONS:**
• Detected addition at **bindings → item #1 → members**:
Added: **"user:newuser@example.com"**
**➖ REMOVALS:**
• Detected removal at **bindings → item #5 → members**:
Removed: **"user:olduser@example.com"**

Text Emphasis Support:

  • Text and textarea fields support **bold** markdown markers
  • Bold text renders with red color and yellow highlight
  • Fields with emphasis get an amber background for attention

Eleven modes are supported, selected by the keys present. asset_name supports {{field}} interpolation from request_data.

Single-asset read/write modes (require asset_name):

Keys Direction Scope
data_to asset → variable Whole properties blob
data_from variable → asset Full replace of properties
field + data_to asset → variable Single field value
field + data_from variable → asset Single-field patch (other fields untouched)
fields_to asset → variables Multiple fields → separate variables
merge_from variable dict → asset Partial merge, other fields preserved
fields_from variables → asset Multiple variables → separate named fields
delete: true — Soft-delete the named asset (deleted_at = NOW())

Collection modes (no asset_name required):

Keys Direction Scope
list_by_category + save_to assets → variable All non-deleted assets in a category
bulk_upsert variable array → assets Create/update N assets from an array in one step

Write mode — saves a workflow variable to the asset (upsert):

update_asset:
type: automatic
asset:
data_from: fetched_data # workflow variable → asset (full replace)
asset_name: baseline
on_complete:
continue_to: complete

Write with explicit category — use category: to assign a grouping label on create/update:

register_product:
type: automatic
asset:
asset_name: "product-{{odoo_product_id}}"
category: product # stored in assets.category; defaults to 'data' if omitted
fields_from:
odoo_product_id: odoo_product_id
name: product_name
code: product_code
on_complete:
continue_to: done

Read mode — loads the asset value into a workflow variable:

load_baseline:
type: automatic
asset:
data_to: baseline_snapshot # asset → workflow variable (whole blob)
asset_name: baseline
on_complete:
continue_to: compare_step

Multi-field write — writes several variables into separate named fields (other fields preserved):

save_fields:
type: automatic
asset:
asset_name: "supplier-{{supplier_id}}"
fields_from:
status: supplier_status # request_data.supplier_status → properties.status
last_audit_date: audit_date
on_complete:
continue_to: complete

Soft-delete — sets deleted_at on the asset; the record remains in the audit history but is excluded from all list and read operations:

retire_asset:
type: automatic
asset:
asset_name: "product-{{odoo_product_id}}"
delete: true
on_complete:
continue_to: done

List by category — fetches all non-deleted assets in a category into a variable as [{name, properties}]:

fetch_all_product_assets:
type: automatic
asset:
list_by_category: product # category value to filter on
save_to: all_assets # [{name, properties}, ...]
on_complete:
continue_to: next_step

Bulk upsert — creates or updates N assets in one step from an array variable. Uses ON CONFLICT DO UPDATE — safe to re-run:

seed_assets:
type: automatic
asset:
bulk_upsert:
items_from: all_odoo_products # variable containing the source array
asset_name_template: "product-{{id}}" # {{field}} interpolated per item
category: product
properties_mapping: # {target_property: source_field}
odoo_product_id: id
name: name
code: default_code
price: list_price
on_complete:
continue_to: done_end

Asset step properties:

Key Required Description
asset_name Required for single-asset modes Name of the asset to read/write/delete; supports {{field}} interpolation
category No Category label stored on create/update (default: 'data')
data_from — Workflow variable to write to the asset (full replace)
data_to — Workflow variable to receive the asset’s properties blob
field No Scopes data_from/data_to to a single properties key
fields_to — Dict {field_name: variable_name} — reads multiple fields into separate variables
fields_from — Dict {field_name: variable_name} — writes separate variables into named fields
merge_from — Variable containing a partial dict — shallow-merged into properties
delete — true to soft-delete the named asset
list_by_category — Category name to list; use with save_to
save_to Required with list_by_category Variable to receive the list of assets
bulk_upsert — Block for bulk create/update (see above)

Test Mode Behavior:

  • data_processor (fetch): Executes normally, with HTTP request/response logged to the audit trail
  • asset write (data_from, merge_from, fields_from, field+data_from, bulk_upsert): Writes to user-scoped sandbox copy; production assets are not modified
  • asset read (data_to, fields_to, field+data_to, list_by_category): Reads the user-scoped test copy if available, otherwise falls back to the production copy
  • delete: true: In test mode, logs the intended deletion but does not set deleted_at on the production record

Complete Example: Data Change Detection Workflow

Section titled “Complete Example: Data Change Detection Workflow”
name: "Data Compliance Monitor"
type: "compliance"
triggers:
- type: cron
schedule: "*/15 * * * *"
form:
fields:
- name: data_json
type: textarea
label: Current Data
- name: diff_result
type: textarea
label: Change Summary
workflow:
fetch_data:
type: automatic
name: Fetch Current Data
data_processor:
source_id: src_xxx
save_to: data_json
compare_to_asset: data-baseline
save_diff_to: diff_result
on_complete:
continue_to: check_changes
check_changes:
type: conditional_split
choices:
- conditions: diff_result != 'None'
continue_to: review
default:
continue_to: no_changes_end
review:
type: decision
name: Review Changes
approver: admin
on_approve:
continue_to: update_baseline
on_reject:
continue_to: rejected_end
update_baseline:
type: automatic
name: Update Baseline
asset:
data_from: data_json
asset_name: data-baseline
on_complete:
continue_to: approved_end
approved_end:
type: end
notify_requestor: Changes approved and baseline updated.
rejected_end:
type: end
notify_requestor: Changes rejected.
no_changes_end:
type: end

Sends a non-blocking notification to specified recipients. By default notifications are sent by email using the system’s SMTP/email sender, but you can also target chat channels by setting channel and to.

notify_step:
name: "Notify Supervisor"
type: "notification"
description: "Informs the supervisor about the request."
recipients:
- email: "${requestor.supervisor_email}" # Default email channel
- role: "finance_team" # Resolve to employees in that role
- channel: "slack"
to: "#approvals" # Post to a Slack channel
- channel: "teams"
to: "${form.teams_webhook}" # Send via Teams webhook
notification:
message:
subject: "Request Notification"
body: "A new request has been submitted and requires your attention."
on_complete:
continue_to: "next_step"

Recipient forms:

  • email: "..." — Send by email using the system’s SMTP/email sender.
  • role: "finance_team" — Resolve to all users with that role.
  • user_id: 123 — Resolve to a specific user.
  • channel + to — Send through a specific adapter (Slack, Teams, Lark, Telegram, WhatsApp, Google Chat). This is separate from SMTP/email credentials. In the open-source runtime these adapters read APPROVALML_NOTIFICATION_* environment variables; the SaaS backend reads per-employee preferences from the database.

Email vs. channel credentials:

  • SMTP/email settings (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, EMAIL_FROM) are used for approval-request emails and plain email notifications.
  • Chat channel credentials (APPROVALML_NOTIFICATION_SLACK_BOT_TOKEN, APPROVALML_NOTIFICATION_TEAMS_WEBHOOK_URL, etc.) are used only when a notification step explicitly targets that channel.

Use {{ field_name }} syntax to insert form field values into notification messages:

workflow:
manager_approval:
type: decision
approver: manager
on_approve:
notify_requestor: "Invoice {{ invoice_no }} approved on {{ invoice_date }}"
continue_to: next_step
on_reject:
notify_requestor: "Invoice {{ invoice_no }} requires changes"
continue_to: initial

Supported Locations:

  • notify_requestor - Messages sent to workflow requestor
  • notification.message.body - Email body content
  • notification.message.subject - Email subject line

Example with Multiple Fields:

on_approve:
notify_requestor: "PO {{ po_number }} for {{ vendor_name }} (Total: ${{ total_amount }}) has been approved"
notification:
message:
subject: "PO {{ po_number }} Approved"
body: |
Purchase Order Details:
- PO Number: {{ po_number }}
- Vendor: {{ vendor_name }}
- Amount: ${{ total_amount }}
- Requested by: {{ requestor_name }}

Note: Template substitution is evaluated at runtime with the current form data values.

6. Loop (type: loop / lightweight loop: block)

Section titled “6. Loop (type: loop / lightweight loop: block)”

Runs a step (or a child workflow) repeatedly until a condition becomes false — for retry loops, revision cycles, or repeating a sub-process. Two forms are available depending on how much isolation you need between iterations.

Lightweight loop: block (revision/retry within the same instance)

Section titled “Lightweight loop: block (revision/retry within the same instance)”

Attach a loop: block to any decision or automatic step to re-visit it (or another step) without creating a child workflow instance. After the step’s normal outcome routing runs, the engine checks loop.while — if it’s still true and under max_iterations, execution jumps to loop.continue_to instead of following on_approve/on_reject/on_complete.

manager_review:
name: "Manager Review"
type: decision
approver: manager
loop:
continue_to: manager_review # re-enter this same step
while: "needs_revision == 'true'" # evaluated against current form data
max_iterations: 5 # default 20; hard cap regardless of while
on_approve:
continue_to: finance_review
on_reject:
continue_to: rejected_end

A loop: block never changes whether the step approves or rejects — it only changes where execution goes after that outcome is decided, so on_approve/on_reject still fire normally.

For iteration that needs its own isolated form data and audit trail — e.g. “keep running this validation sub-workflow until it passes” — use a full type: loop step. It works like spawn but re-runs the same child workflow repeatedly, one iteration at a time, instead of fanning out once per row.

retry_until_valid:
name: "Validate Until Passing"
type: loop
workflow: "Validation Sub-check" # child workflow name, looked up by name
while: "validation_passed != 'true'" # re-evaluated against the parent's data after each iteration
max_iterations: 10 # default 20
pass: # parent fields copied into every child iteration
- submission_id
map: # rename: child_field: parent_field
check_target: record_id
return: # child fields copied back into the parent after each iteration
validation_passed: validation_passed
on_complete:
continue_to: finish
on_failure:
continue_to: escalation

Fields:

  • workflow (required) — name of the child workflow to run each iteration.
  • while (required) — condition re-evaluated against the parent’s data after each child completes; iteration continues while it’s true.
  • max_iterations (default 20) — hard cap independent of while, so a condition that never becomes false can’t loop forever.
  • pass — parent field names copied into each child iteration unchanged.
  • map — explicit rename, child_field: parent_field.
  • return — child field values copied back into the parent’s data after each iteration, child_field: parent_field.
  • requestor_from — resolve the child’s requestor from a named field instead of inheriting the parent’s, same as spawn.
  • on_complete / on_failure — routing once the loop exits (condition false, or max_iterations reached) vs. if a child instance is rejected.

Only one child instance is active at a time — each iteration must reach a terminal state before the next one starts.

Terminates the workflow. Preferred over inline end_workflow: true when a workflow has multiple outcomes (approved, rejected, cancelled, spam-discarded, etc.).

approved_end:
type: end
notify_requestor: "Your request has been approved."
rejected_end:
type: end
notify_requestor: "Your request was rejected."
verification_end:
type: end
notify_requestor: false
notify_completion: false # no completion PDF emails to requestor or approvers
reject_spam:
type: end
archive: true # soft-hide from dashboards; excluded from monthly quota
odoo_certificate_end:
type: end
notify_completion: false # skip the email; the API caller gets the PDF directly
return_pdf: true # render + return the completion PDF inline to the external caller
contract_signed_end:
type: end
submit_workflow: "Employee Onboarding" # auto-starts this workflow once the instance completes

Properties:

Property Required Description
type: end Yes Marks this step as a terminal end node
notify_requestor No String message emailed to the requestor before termination, or false to skip that custom notify
notify_completion No When false, skip the automatic completion PDF emails sent to the requestor and every participating approver. Defaults to true (emails are sent)
return_pdf No When true, render and return the completion PDF inline in the response to a synchronous external API caller (e.g. a token-exchange trigger), instead of only emailing it. Defaults to false
archive No When true, soft-hide the instance from dashboards/list views while keeping the full audit trail
submit_workflow No Name of another workflow to auto-start immediately after this instance completes. The new instance is fully decoupled from this one — it runs independently, does not block and is not blocked by this instance, and is not a spawn child. This instance’s own request_data is passed as the new instance’s form data (matched by field name), and the new instance’s metadata.source_instance_id traces back to this one
metadata No Free-form outcome tracking for analytics

notify_requestor vs notify_completion:

  • notify_requestor is an optional custom message on the end step (or on an outcome). Omitting it, or setting false, skips that custom message only.
  • notify_completion controls the automatic completion email (PDF summary to requestor + all approvers) that fires when a workflow reaches an approved end. Set false when the end should be silent — e.g. a public-submission verification workflow that should not notify anyone after it finishes.

Rejection end steps (step name containing reject) do not send the completion PDF emails; they go through rejection handling instead. notify_completion only applies to approved/success completion.

return_pdf — returning the completion PDF to an external API caller: When a workflow is submitted via the token-authenticated external-submit API and completes synchronously (no decision/parallel_approval/wait_webhook step in the way), setting return_pdf: true on the end step it reaches renders the completion PDF and includes it (base64-encoded) directly in that same API response — instead of, or alongside, the async notify_completion email. It only fires when this specific end step is actually reached; a workflow that instead pauses on a human step simply returns no PDF yet.

Gotcha — data_processor/data_source steps default to async dispatch, which breaks synchronous return_pdf. An automatic step with data_processor/data_source and no explicit execute_async dispatches its fetch to a background worker (Cloud Tasks in production, ARQ on staging) on first reach — the step stays PENDING and the submit call returns immediately with status: "in_progress", not the completed status return_pdf depends on. If any such step sits between the trigger and the return_pdf end step, set execute_async: false on it so the fetch (and the chain after it) runs inline within the same request:

fetch_po_details:
type: automatic
data_processor:
source_name: "odoo_purchase_orders"
params:
- name: po_number
from_field: po_number
save_to: po_details
execute_async: false # required: keeps the fetch inline so return_pdf sees a completed instance

Only do this for fetches fast enough to finish within the HTTP request — it removes the background-dispatch safety valve that execute_async exists for. A workflow with a genuinely slow upstream fetch is not a good fit for synchronous return_pdf; the caller should poll for completion instead.


Automatically launch workflows on a schedule or via webhook.

triggers:
- type: cron
schedule: "*/15 * * * *"
requestor_company_role: "compliance_officer"
preset_form_data:
audit_period: "{{today}}"
Type Description
cron Repeating schedule using standard 5-field cron expression.
one_time Fires once at the specified schedule, then auto-pauses.
webhook Fires on HTTP POST to a generated webhook URL. No schedule field.
asset_expiry Fires once per asset of a named schema whose date field crosses a threshold. Zero-to-N instances per check, not one-per-tick.

asset_expiry — obligation/expiry-driven workflows

Section titled “asset_expiry — obligation/expiry-driven workflows”

Use this instead of cron when the workflow should fire per-asset, based on that asset’s own date, rather than on a fixed schedule — certificate/permit renewals, contractor access grants/revocations, anything keyed off “this specific record’s date is approaching.”

triggers:
- type: asset_expiry
schema: "Calibration Record" # asset schema name to watch
date_role: expires_at # or effective_from
offset_days: 30 # fire 30 days before the date (0 = on the day)

The fired instance’s form is seeded from that asset’s own properties.owner_email (approver), properties.expires_at/effective_from (the date), and its name (subject/display label) — fixed conventions, nothing else to configure on the asset schema side.

schema and date_role define what the trigger watches and should be treated as fixed once authored; only offset_days is meant to be tuned per install (via the trigger’s edit UI, which — unlike other trigger types — supports a per-company override even on a shared/system-published workflow, so a bug fix to the workflow’s own logic still reaches every installed company while each company’s lead-time preference stays their own).

Field Required Description
type ✅ Yes cron, one_time, webhook, or asset_expiry
schedule For cron/one_time Cron expression, e.g. 0 9 * * * for daily 9 AM
max_runs No Auto-pause after N executions
allow_concurrent No Default false — skip the run if a previous instance from this trigger is still in progress. Set true to allow overlap (see below).
preset_form_data No Static form values to inject on launch
requestor_email No Email of the employee to treat as submitter
requestor_company_role No Recommended for scheduled workflows — the first active employee with this company_role becomes the submitter
data_condition No Fetch external data and only launch if changes are detected
schema For asset_expiry Name of the asset schema to watch
date_role For asset_expiry expires_at or effective_from
offset_days For asset_expiry Days before the date to fire; 0 fires on the day itself

Best Practice: Use requestor_company_role for Ownership

Section titled “Best Practice: Use requestor_company_role for Ownership”

For scheduled workflows, pinning ownership to a single person (requestor_email or the workflow creator) creates a bottleneck and a single point of failure. If that person leaves, the workflow breaks.

Instead, use requestor_company_role to distribute ownership across a functional role:

triggers:
- type: cron
schedule: "0 9 * * 1" # Every Monday 9 AM
requestor_company_role: "finance_manager"
preset_form_data:
report_type: "weekly_payroll"

How it works:

  • At each scheduled tick, the system queries active employees for the first person with company_roles @> ['finance_manager'].
  • If that employee leaves, the next tick automatically finds the next active employee with that role.
  • The resolved requestor ID is stamped in the instance’s trigger_metadata for audit purposes.

Resolution priority:

  1. requestor_id (explicit ID)
  2. requestor_email (email lookup)
  3. requestor_company_role (role-based lookup)
  4. workflow.created_by (fallback)

By default, if a previous instance started by this trigger is still in progress, the next scheduled run is skipped to prevent double-execution. This is the safe default for workflows where running twice simultaneously could cause harm (payroll, billing, compliance audits).

triggers:
- type: cron
schedule: "0 9 * * 1" # Every Monday 9 AM
requestor_company_role: "finance_manager"
# allow_concurrent: false ← default; safe to omit

Set allow_concurrent: true only when each run is fully independent and overlap is intentional:

triggers:
- type: cron
schedule: "0 * * * *" # Every hour
allow_concurrent: true # Each snapshot is independent — overlap is fine

When NOT to use allow_concurrent: true:

  • Payroll, billing, or financial workflows (double-run = duplicate charges)
  • Sequential review processes where each run depends on the prior outcome
  • Workflows that send external notifications or modify shared state on completion

The concurrency check is scoped to trigger_index — if a workflow has multiple triggers (e.g. one cron and one webhook), they are independent and do not block each other.

preset_form_data — Pre-fill Form Fields on Launch

Section titled “preset_form_data — Pre-fill Form Fields on Launch”

Inject static values into form fields when a trigger fires. This is useful for scheduled workflows that need context tokens (e.g. the current date) or classification values that never change per run.

triggers:
- type: cron
schedule: "0 9 * * 1"
requestor_company_role: "finance_manager"
preset_form_data:
report_type: "weekly_payroll"
period: "{{last_week}}"

Behavior:

  • preset_form_data is the base layer of form data.
  • If the same field is also produced by a later field_mapping (webhook or automatic step), the later value overrides the preset.
  • The YAML validator warns if a preset key does not match any field name in the workflow form.

Common use cases:

Use case Example preset
Report classification report_type: "monthly_reconciliation"
Date tokens period: "{{today}}", week_ending: "{{last_sunday}}"
Fixed category expense_category: "travel"
Hidden metadata source_system: "erp_sync" (use with hidden: true on the form field)
Conditional UI field other_reason with hidden: { jsonata: "reason != 'other'" }

Trigger field_mapping — Map Webhook Payloads to Form Fields

Section titled “Trigger field_mapping — Map Webhook Payloads to Form Fields”

For webhook triggers, field_mapping extracts values from the incoming HTTP payload and writes them into form fields. It uses the same syntax as field_mapping inside automatic steps.

triggers:
- type: webhook
requestor_company_role: "support_lead"
field_mapping:
# Simple JSONPath extraction
customer_name: "$.customer.name"
ticket_id: "$.ticket.id"
# JSONata transformation
priority_text:
source: "$.ticket.priority"
jsonata: "$uppercase(value)"
# Nested array mapping (line_items)
line_items:
source: "$.order.items"
item_fields:
product_name: "name"
quantity: "qty"
unit_price: "price"

How form data is built when a trigger fires:

  1. Start with preset_form_data (static values)
  2. Apply trigger field_mapping (JSONPath/JSONata from webhook payload)
  3. Apply field_mapping from automatic steps (if any)
  4. Name-match fallback from webhook payload (legacy — keys that exactly match form field names)

Later steps override earlier ones, so an automatic step’s field_mapping can refine or replace values set by the trigger.

Complete webhook example:

name: "Incoming Support Ticket"
description: "Auto-create an approval when a high-priority ticket arrives"
triggers:
- type: webhook
requestor_company_role: "support_lead"
preset_form_data:
source: "zendesk"
field_mapping:
requester_email: "$.ticket.requester.email"
subject: "$.ticket.subject"
priority: "$.ticket.priority"
tags:
source: "$.ticket.tags"
jsonata: "$join(value, ', ')"
form:
fields:
- name: requester_email
type: email
label: Requester
required: true
- name: subject
type: text
label: Subject
required: true
- name: priority
type: select
label: Priority
required: true
options:
- value: low
label: Low
- value: high
label: High
- name: tags
type: text
label: Tags
- name: source
type: text
label: Source System
hidden: true
workflow:
triage:
type: decision
name: "Triage Ticket"
approver: "support_manager"
sla: "2h"
on_approve:
continue_to: resolved
resolved:
type: end
notify_requestor: "Ticket {{ticket_id}} has been triaged."

Define the nature of an approval required from a user.

  • needs_to_approve (Default): Can approve, reject, or request more information.
  • needs_to_sign: Requires a digital signature.
  • needs_to_recommend: Provides an advisory opinion but cannot block the workflow.
  • needs_to_acknowledge: Requires the user to simply acknowledge receipt.
  • needs_to_call_action: Triggers a system or manual action.
  • receives_a_copy: Receives a notification with no action required.

Used in conditional_split steps to control workflow routing.

  • Comparison: >, <, >=, <=, ==, !=
  • Logical: and, or, not
  • Membership: in, not in
# Numeric comparison
conditions: "amount > 1000"
# String equality
conditions: "department == 'engineering'"
# List membership
conditions: "category in ['equipment', 'software']"
# Complex expression
conditions: "(urgency == 'high' or priority >= 4) and amount > 10000"

Reference users based on their position in the organizational hierarchy.

  • ${requestor.manager}: The direct manager of the user who submitted the request.
  • ${requestor.supervisor}: The supervisor of the requestor.
  • ${requestor.department_head}: The head of the requestor’s department.

Control how the PDF export looks. All fields are optional. To control which sections appear in the PDF, use layout.completed_sections.

print:
orientation: "portrait" # "portrait" (default) or "landscape"
page_size: "A4" # "A4" (default), "Letter", "Legal", "A3", "A5"
margin: "8mm" # CSS margin applied to all sides (default: "8mm")
# Accepts any CSS length: "10mm", "8mm 6mm", "10mm 8mm 6mm 8mm"
suppress_auto_header: true # Hide the auto-generated title/status/QR header
# (useful when the form has its own form.header zone)
suppress_section_header: false # Hide section title bars inside the form body
show_history: true # Include the Approval History table (default: true)
# Optional: override individual theme tokens
theme:
colors:
accent: "#065f46" # Currency values, highlights
primary: "#111827" # Main text
secondary: "#6b7280" # Labels, meta text
border: "#e5e7eb" # Table borders
background_alt: "#f9fafb" # Table headers, alternating rows
fonts:
size_base: "12px"
size_small: "10px"
size_heading: "14px"
size_title: "20px"
scale: 1.0 # Multiplies EVERY rendered font-size uniformly — labels,
# values, table cells, signatures, section header bars, all
# of it. The simplest way to make print text bigger/smaller
# without retuning each size_* token individually.
spacing:
page_margin: "8mm" # Also settable via print.margin (margin takes precedence)

Compact financial report (landscape, narrow margins):

print:
orientation: landscape
page_size: A4
margin: "6mm"

Signature-heavy document (extra breathing room):

print:
orientation: portrait
margin: "12mm"
show_history: false

Custom-branded PDF:

print:
theme:
colors:
accent: "#1d4ed8"
primary: "#0f172a"

Larger print text (dense invoice/table-heavy layouts):

print:
theme:
fonts:
scale: 1.3 # 30% bigger everywhere — labels, table cells, signatures, section headers

Configure advanced behavior for the workflow.

settings:
# Notification preferences
notifications:
send_reminders: true
reminder_intervals: ["24_hours", "2_hours"]
# Compliance requirements
compliance:
receipt_required: true
policy_check: true

guards is only used by the open-source approvalml package’s stdio MCP wrapper (approvalml -- <upstream-command>), which spawns an existing MCP server as a subprocess and re-exposes a gated version of its tools. It lets ONE workflow file act as both the tool classifier for that server AND the escalation workflow — no separate classification file needed.

name: "GitHub MCP Guard"
guards:
tools:
"get_*": auto # forwarded directly, no approval step
"list_*": auto
"search_*": auto
# any tool name not matched here falls through to this file's own
# workflow: below, submitted under this file's own name: ("GitHub MCP Guard")
form:
fields:
- name: tool_name # always present — the upstream tool name being called
type: "text"
label: "Tool"
readonly: true
- name: target_branch
type: "text"
label: "Target Branch"
readonly: true
calculated: true
jsonata: "$exists(arguments.base) ? arguments.base : null" # 'arguments' holds the raw tool-call arguments
workflow:
route:
name: "route"
type: "conditional_split"
choices:
- conditions: "tool_name == 'merge_pull_request'"
continue_to: "reviewer_approval"
default:
continue_to: "done"
reviewer_approval:
name: "reviewer_approval"
type: "decision"
approver: "reviewer"
on_approve: { continue_to: "done" }
on_reject: { end_workflow: true }
done:
name: "done"
type: "end"

Rules:

  • guards.tools is a map of glob pattern (fnmatch syntax, e.g. "get_*") to one of auto | gate | deny — never workflow, since falling through to this file’s own workflow: section already is the escalation path.
  • tool_name is injected automatically; arguments (the tool call’s raw arguments, as a nested object) is available to jsonata calculated fields — reference nested values as arguments.<name>.
  • guards has no effect on a workflow submitted normally through the UI/API — it is only consulted by the MCP wrapper’s classifier.