Skip to content

ApprovalML YAML Syntax Reference

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

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

Every ApprovalML file follows this basic structure:

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

Define the data to be collected from the user.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

In-table column aggregate — aggregate::

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

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

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

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

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

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

Hiding an empty table entirely — hide_when_empty:

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

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

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

Public Submission (Anonymous, No-Login Entry)

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

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

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

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

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

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

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

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

When to use public_submission:

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

When to omit it (default):

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

Define the logic and routing of the approval process.

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

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

Define custom outcomes using on_<action> keys.

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

Allows multiple approvers to act simultaneously.

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

Routes the workflow dynamically based on form data.

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

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

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

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

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

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

Advanced Integration: Document to ERP (Odoo)

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

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

workflow:
# 1. Extract raw Markdown from uploaded PDF
extract_pdf:
type: automatic
data_processor:
source_name: "Docling - Extract Text"
save_to: docling_raw
params:
- name: file
from_field: field.uploaded_quote
field_mapping:
extracted_markdown: "$.data.markdown"
on_complete:
continue_to: parse_with_ai
# 2. AI parses Markdown into structured JSON for Odoo
parse_with_ai:
type: automatic
mcp:
connector: "claude_mcp"
command: >
Extract the customer name and line items from this markdown text:
${extracted_markdown}
save_to: "odoo_so_json"
output_schema:
type: object
properties:
partner_name: { type: string }
order_lines:
type: array
items:
type: object
properties:
product_name: { type: string }
quantity: { type: number }
price_unit: { type: number }
required: ["partner_name", "order_lines"]
on_complete:
continue_to: create_odoo_so
# 3. Post the structured JSON to Odoo API
create_odoo_so:
type: automatic
data_processor:
source_name: "Odoo - Create Sales Order"
params:
- name: order_data
from_field: field.odoo_so_json
on_complete:
continue_to: manager_approval
---
## 🧩 Advanced Entity Matching (Odoo/ERP)
Matching extracted text (e.g., "Laptop Pro 14") to a specific database record (e.g., Odoo Product ID `452`) is the most challenging part of automation. Since Odoo's standard API doesn't support semantic "similarity" search, Aptiwise recommends two patterns:
### Pattern A: AI + MCP Search Tool (Recommended)
In this pattern, you give the AI an MCP tool (e.g., `search_odoo_products`) that it can call during its processing loop. This tool performs a **Vector Search** against a local cache of your Odoo products.
**Why this works:**
1. **Semantic Understanding:** The `fastembed` service converts Odoo product names into vectors.
2. **AI Judgment:** If multiple matches are found, the AI can look at the context (price, vendor) to pick the correct one.
3. **No Code:** The logic is handled by the AI's "thought process."
```yaml
workflow:
parse_and_match:
type: automatic
mcp:
connector: "odoo_mcp_gateway"
command: >
For every item in this markdown: ${extracted_markdown},
first use the 'search_product' tool to find the closest Odoo ID.
If the price in the document differs from Odoo by more than 10%,
flag it in the 'ai_notes' field.
save_to: "matched_order_data"
output_schema:
# Schema for a valid Odoo Sales Order
type: object
properties:
partner_id: { type: integer }
order_line:
type: array
items:
type: object
properties:
product_id: { type: integer }
product_uom_qty: { type: number }
price_unit: { type: number }

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

  1. Sync: Periodically sync Odoo products to an Aptiwise Data Source.
  2. Embed: Aptiwise automatically generates pgvector embeddings for these names.
  3. Match Step: An automatic step calls a processor that runs a SQL query: SELECT id FROM products ORDER BY embedding <=> $1::vector LIMIT 1
  • Filter by Vendor: When searching for products, always pass the vendor_id as a filter to the search tool to narrow down the candidates.
  • Price Verification: Ask the AI to compare the extracted price vs. the database price. If they don’t match, route the workflow to a human for “Exception Review.”
  • Confidence Scores: The vector search returns a similarity score (0.0 to 1.0). You can configure the workflow to auto-approve only if the score is > 0.90.
**Data Processor Properties:**
| Field | Required | Description |
|-------|----------|-------------|
| `source_id` | One of `source_id` or `source_name` | Stable unique ID of the Data Processor (e.g. `src_xxx`) |
| `source_name` | One of `source_id` or `source_name` | Human-readable name — portable across companies and environments |
| `save_to` | Required when using `compare_to_asset`; otherwise required unless `field_mapping` is present | Form field name to store the raw fetched data |
| `compare_to_asset` | No | Name of the asset baseline to compare against (uses DeepDiff) |
| `save_diff_to` | No | Form field for the human-readable diff string (`"None"` if no changes detected) |
| `ignore_keys` | No | List of JSON key paths to exclude from drift comparison (e.g. `["etag", "updated_at"]`) |
| `params` | No | List of parameters to pass to the data source (see below) |
| `join` | No | Inline relational field lookup — resolve FK IDs to display values (see below) |
| `field_mapping` | No | Extract and transform specific data into form fields (see below) |
**Passing Parameters to a Data Processor:**
Use the `params:` list to inject dynamic values into the connector call:
```yaml
data_processor:
source_id: src_gcp_iam
save_to: iam_snapshot
params:
- name: project_id
from_field: field.project_id # read from a form field
- name: cursor
from_asset: sync-checkpoint # read from an asset's properties
property: $.last_cursor # JSONPath into asset.properties (optional)
- name: api_version
value: "v3" # literal value

Each param entry requires exactly one source:

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

Typed Placeholders — {{param:type}}:

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

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

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

DriftWatch Example — Incremental Cursor Pattern:

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

Inline Join — Resolving Relational ID Fields

Section titled “Inline Join — Resolving Relational ID Fields”

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

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

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

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

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

Then reference all resolved fields directly in field_mapping:

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

join field reference:

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

Notes:

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

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

Three Types of Field Mapping:

  1. Simple JSONPath Extraction

Extract a value from the JSON response using JSONPath syntax:

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

Map JSON arrays to line_items fields:

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

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

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

JSONata Expression Syntax:

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

Common JSONata Examples:

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

Error Handling:

Field mapping is designed to be fault-tolerant:

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

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

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

Text Emphasis Support:

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

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

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

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

Collection modes (no asset_name required):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Asset step properties:

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

Test Mode Behavior:

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

Complete Example: Data Change Detection Workflow

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

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

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

Recipient forms:

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

Email vs. channel credentials:

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

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

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

Supported Locations:

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

Example with Multiple Fields:

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

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

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

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

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

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

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

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

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

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

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

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

Fields:

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

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

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

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

Properties:

Property Required Description
type: end Yes Marks this step as a terminal end node
notify_requestor No String message emailed to the requestor before termination, or false to skip that custom notify
notify_completion No When false, skip the automatic completion PDF emails sent to the requestor and every participating approver. Defaults to true (emails are sent)
return_pdf No When true, render and return the completion PDF inline in the response to a synchronous external API caller (e.g. a token-exchange trigger), instead of only emailing it. Defaults to false
archive No When true, soft-hide the instance from dashboards/list views while keeping the full audit trail
metadata No Free-form outcome tracking for analytics

notify_requestor vs notify_completion:

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

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

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

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

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

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


Automatically launch workflows on a schedule or via webhook.

triggers:
- type: cron
schedule: "*/15 * * * *"
requestor_company_role: "compliance_officer"
preset_form_data:
audit_period: "{{today}}"
Type Description
cron Repeating schedule using standard 5-field cron expression.
one_time Fires once at the specified schedule, then auto-pauses.
webhook Fires on HTTP POST to a generated webhook URL. No schedule field.
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_metadata for audit purposes.

Resolution priority:

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

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

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

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

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

When NOT to use allow_concurrent: true:

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

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

preset_form_data — Pre-fill Form Fields on Launch

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

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

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

Behavior:

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

Common use cases:

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

Trigger field_mapping — Map Webhook Payloads to Form Fields

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

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

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

How form data is built when a trigger fires:

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

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

Complete webhook example:

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

Define the nature of an approval required from a user.

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

Used in conditional_split steps to control workflow routing.

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

Reference users based on their position in the organizational hierarchy.

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

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

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

Compact financial report (landscape, narrow margins):

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

Signature-heavy document (extra breathing room):

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

Custom-branded PDF:

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

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

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

Configure advanced behavior for the workflow.

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

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

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

Rules:

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