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
# 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.
- 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"]
# 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:

StyleAppearanceUse Case
warningAmber/Yellow backgroundChanges requiring attention, diff results
dangerRed backgroundCritical items, deletions, errors
successGreen backgroundPositive outcomes, confirmations
infoBlue backgroundInformational, 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

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
---
## 🧩 Advanced Entity Matching (Odoo/ERP)
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:
### 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."
```yaml
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) |
**Passing Parameters to a Data Processor:**
Use the `params:` list to inject dynamic values into the connector call:
```yaml
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

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:

FieldRequiredDefaultDescription
field✅ YesField on each row holding the FK ID(s)
source_id✅ YesConnector source to batch-fetch related records from
as✅ when pick is a stringOutput field name written onto each row
onNo"id"Key field in join records to match against
pickNo"name"String (single field) or dict {output: source} (multiple fields)
paramNo"ids"Parameter name sent to the join source for the collected IDs
separatorNo", "Separator when field is an array and output is a string
as_arrayNofalsetrue → 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):

KeysDirectionScope
data_toasset → variableWhole properties blob
data_fromvariable → assetFull replace of properties
field + data_toasset → variableSingle field value
field + data_fromvariable → assetSingle-field patch (other fields untouched)
fields_toasset → variablesMultiple fields → separate variables
merge_fromvariable dict → assetPartial merge, other fields preserved
fields_fromvariables → assetMultiple variables → separate named fields
delete: trueSoft-delete the named asset (deleted_at = NOW())

Collection modes (no asset_name required):

KeysDirectionScope
list_by_category + save_toassets → variableAll non-deleted assets in a category
bulk_upsertvariable array → assetsCreate/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:

KeyRequiredDescription
asset_nameRequired for single-asset modesName of the asset to read/write/delete; supports {{field}} interpolation
categoryNoCategory label stored on create/update (default: 'data')
data_fromWorkflow variable to write to the asset (full replace)
data_toWorkflow variable to receive the asset’s properties blob
fieldNoScopes data_from/data_to to a single properties key
fields_toDict {field_name: variable_name} — reads multiple fields into separate variables
fields_fromDict {field_name: variable_name} — writes separate variables into named fields
merge_fromVariable containing a partial dict — shallow-merged into properties
deletetrue to soft-delete the named asset
list_by_categoryCategory name to list; use with save_to
save_toRequired with list_by_categoryVariable to receive the list of assets
bulk_upsertBlock 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.


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}}"
TypeDescription
cronRepeating schedule using standard 5-field cron expression.
one_timeFires once at the specified schedule, then auto-pauses.
webhookFires on HTTP POST to a generated webhook URL. No schedule field.
FieldRequiredDescription
type✅ Yescron, one_time, or webhook
scheduleFor cron/one_timeCron expression, e.g. 0 9 * * * for daily 9 AM
max_runsNoAuto-pause after N executions
allow_concurrentNoDefault false — skip the run if a previous instance from this trigger is still in progress. Set true to allow overlap (see below).
preset_form_dataNoStatic form values to inject on launch
requestor_emailNoEmail of the employee to treat as submitter
requestor_company_roleNoRecommended for scheduled workflows — the first active employee with this company_role becomes the submitter
data_conditionNoFetch external data and only launch if changes are detected

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 caseExample preset
Report classificationreport_type: "monthly_reconciliation"
Date tokensperiod: "{{today}}", week_ending: "{{last_sunday}}"
Fixed categoryexpense_category: "travel"
Hidden metadatasource_system: "erp_sync" (use with hidden: true on the form field)

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

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