ApprovalML YAML Syntax Reference
ApprovalML YAML Syntax Reference
Section titled “ApprovalML YAML Syntax Reference”This page provides a detailed reference for the ApprovalML YAML syntax, designed for AI-powered workflow generation.
Overview
Section titled “Overview”ApprovalML is a YAML-based language for defining business approval workflows. It combines form creation with powerful routing logic.
Core Structure
Section titled “Core Structure”Every ApprovalML file follows this basic structure:
name: "Workflow Name"description: "A brief summary of the workflow's purpose."version: "1.0" # Optional version numbertype: "workflow_type" # Optional classification
# Optional: Define who can submit this workflowsubmission_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 collectionform: fields: []
# Workflow logic with interconnected stepsworkflow: step_name: {}
# Optional: Advanced settings for the workflowsettings: notifications: {} compliance: {}
# Optional: PDF export configurationprint: orientation: "portrait" page_size: "A4" margin: "8mm" show_history: trueForm Fields
Section titled “Form Fields”Define the data to be collected from the user.
Basic Field Types
Section titled “Basic Field Types”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.
Field Properties
Section titled “Field Properties”- 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).Field Style Property
Section titled “Field Style Property”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: falseExample: Currency Field
Section titled “Example: Currency Field”- 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: 50000Advanced Field Properties
Section titled “Advanced Field Properties”These properties control the presentation and behavior of certain field types.
Button-style Choices
Section titled “Button-style Choices”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" }Camera-Only File Upload
Section titled “Camera-Only File Upload”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 totrueto allow multiple captures, orfalse(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 directlyAdvanced Field Type: Line Items
Section titled “Advanced Field Type: Line Items”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 valueWorkflow Steps
Section titled “Workflow Steps”Define the logic and routing of the approval process.
1. Decision Step (decision)
Section titled “1. Decision Step (decision)”A standard approval step requiring a user to take action. It can have multiple outcomes.
Example: Simple Approve/Reject
Section titled “Example: Simple Approve/Reject”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: trueExample: Multi-Outcome Decision
Section titled “Example: Multi-Outcome Decision”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: true2. Parallel Approval (parallel_approval)
Section titled “2. Parallel Approval (parallel_approval)”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: true3. Conditional Split (conditional_split)
Section titled “3. Conditional Split (conditional_split)”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"4. Automatic Step (automatic)
Section titled “4. Automatic Step (automatic)”Performs system actions without human intervention. Supports two main operations:
Data Processor Fetch (Read)
Section titled “Data Processor Fetch (Read)”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"Document Extraction (Docling)
Section titled “Document Extraction (Docling)”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 PDFextract_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 promptanalyze_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_approvalAdvanced 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."
```yamlworkflow: 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 }Pattern B: The “Hybrid” Vector Lookup
Section titled “Pattern B: The “Hybrid” Vector Lookup”If you have thousands of items, use a dedicated Data Processor that performs a batch vector lookup.
- Sync: Periodically sync Odoo products to an Aptiwise Data Source.
- Embed: Aptiwise automatically generates
pgvectorembeddings for these names. - Match Step: An
automaticstep calls a processor that runs a SQL query:SELECT id FROM products ORDER BY embedding <=> $1::vector LIMIT 1
How to ensure accuracy?
Section titled “How to ensure accuracy?”- Filter by Vendor: When searching for products, always pass the
vendor_idas 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:
```yamldata_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 valueEach param entry requires exactly one source:
from_field: field.<name>— readsrequest_data[name]from the current workflow instancefrom_asset: <asset-name>— readsassets.propertiesfor the named asset; use optionalproperty: $.pathto extract a specific key via JSONPathvalue: <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_driftInline 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_approvalMulti-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 dictThen 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_accountjoin 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. pickas 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%"](withas_array: true).
Field Mapping
Section titled “Field Mapping”Extract and transform data from API responses into form fields using JSONPath and JSONata.
Three Types of Field Mapping:
- 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]"- 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"- 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 viavalue - No source: Without
source, the entire payload is available
Common JSONata Examples:
# Remove special charactersclean_text: source: "$.description" jsonata: "$replace(value, /[^a-zA-Z0-9\\s]/, '')"
# Format phone numberphone_formatted: source: "$.phone" jsonata: "$replace(value, /(\\d{3})(\\d{3})(\\d{4})/, '($1) $2-$3')"
# Conditional valuestatus_text: source: "$.status" jsonata: "status = 'active' ? 'Active' : 'Inactive'"
# Extract domain from emaildomain: 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
Asset Registry Step (asset:)
Section titled “Asset Registry Step (asset:)”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: completeWrite 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: doneRead 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_stepMulti-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: completeSoft-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: doneList 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_stepBulk 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_endAsset 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 trailassetwrite (data_from,merge_from,fields_from,field+data_from,bulk_upsert): Writes to user-scoped sandbox copy; production assets are not modifiedassetread (data_to,fields_to,field+data_to,list_by_category): Reads the user-scoped test copy if available, otherwise falls back to the production copydelete: true: In test mode, logs the intended deletion but does not setdeleted_aton 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: end5. Notification (notification)
Section titled “5. Notification (notification)”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 readAPPROVALML_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.
Template Substitution in Notifications
Section titled “Template Substitution in Notifications”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: initialSupported Locations:
notify_requestor- Messages sent to workflow requestornotification.message.body- Email body contentnotification.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.
Triggers
Section titled “Triggers”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}}"Trigger Types
Section titled “Trigger Types”| 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. |
Trigger Fields
Section titled “Trigger Fields”| Field | Required | Description |
|---|---|---|
type | ✅ Yes | cron, one_time, or webhook |
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 |
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_metadatafor audit purposes.
Resolution priority:
requestor_id(explicit ID)requestor_email(email lookup)requestor_company_role(role-based lookup)workflow.created_by(fallback)
allow_concurrent — Concurrency Control
Section titled “allow_concurrent — Concurrency Control”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 omitSet 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 fineWhen 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_datais 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) |
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:
- Start with
preset_form_data(static values) - Apply trigger
field_mapping(JSONPath/JSONata from webhook payload) - Apply
field_mappingfrom automatic steps (if any) - 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."Approval Types
Section titled “Approval Types”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.
Conditional Expression Syntax
Section titled “Conditional Expression Syntax”Used in conditional_split steps to control workflow routing.
Operators
Section titled “Operators”- Comparison:
>,<,>=,<=,==,!= - Logical:
and,or,not - Membership:
in,not in
Examples
Section titled “Examples”# Numeric comparisonconditions: "amount > 1000"
# String equalityconditions: "department == 'engineering'"
# List membershipconditions: "category in ['equipment', 'software']"
# Complex expressionconditions: "(urgency == 'high' or priority >= 4) and amount > 10000"Dynamic Role References
Section titled “Dynamic Role References”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.
Print / PDF Configuration
Section titled “Print / PDF Configuration”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)Common Print Recipes
Section titled “Common Print Recipes”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: falseCustom-branded PDF:
print: theme: colors: accent: "#1d4ed8" primary: "#0f172a"Settings
Section titled “Settings”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