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
# 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 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.honeypot: Invisible anti-bot trap field.public_submissionforms only — see Public Submission below.turnstile: Cloudflare Turnstile CAPTCHA widget.public_submissionforms only — see Public Submission below.
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 valuePDF-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 handwritingEach 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 | maxcount 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: sumWithout 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: sumDefaults 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: 3600Deliverability 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
automaticstep calling a company-configured connector, routed withconditional_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
decisionstep.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_admingives human review instead; omit the step entirely for no confirmation. - Promoting to a real business instance, correctly attributed to the submitter — a
spawnstep withrequestor_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
endstep witharchive: truehides the instance from normal list views while keeping its full audit trail. - Silent verification completion — an
endstep withnotify_completion: falseskips 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 callWhen 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
Workflow 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
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_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.
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_endA 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.
type: loop step (repeat a child workflow)
Section titled “type: loop step (repeat a child workflow)”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: escalationFields:
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(default20) — hard cap independent ofwhile, 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 asspawn.on_complete/on_failure— routing once the loop exits (condition false, ormax_iterationsreached) 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.
7. End Step (end)
Section titled “7. End Step (end)”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 callerProperties:
| 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 |
metadata |
No | Free-form outcome tracking for analytics |
notify_requestor vs notify_completion:
notify_requestoris an optional custom message on the end step (or on an outcome). Omitting it, or settingfalse, skips that custom message only.notify_completioncontrols the automatic completion email (PDF summary to requestor + all approvers) that fires when a workflow reaches an approved end. Setfalsewhen 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 instanceOnly 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.
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) |
| 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:
- 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" 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)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"Larger print text (dense invoice/table-heavy layouts):
print: theme: fonts: scale: 1.3 # 30% bigger everywhere — labels, table cells, signatures, section headersSettings
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: trueMCP Tool Guards (Optional)
Section titled “MCP Tool Guards (Optional)”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.toolsis a map of glob pattern (fnmatch syntax, e.g."get_*") to one ofauto|gate|deny— neverworkflow, since falling through to this file’s ownworkflow:section already is the escalation path.tool_nameis injected automatically;arguments(the tool call’s raw arguments, as a nested object) is available tojsonatacalculated fields — reference nested values asarguments.<name>.guardshas no effect on a workflow submitted normally through the UI/API — it is only consulted by the MCP wrapper’s classifier.
