6. Writing a workflow
A workflow is a graph of nodes joined by edges. Each node names a kind and passes it
parameters in with.
id: invoice_v1 # unique; used in /agui/{id}
version: 3 # recorded with every run
enabled: true
title: Invoice processing # shown in the agent picker
description: Extract, validate, match, approve and post supplier invoices.
entry: parse # the first node
limits:
task_timeout_sec: 180 # per step; also when an unfinished post counts as interrupted
max_steps: 50
state_whitelist: [invoice, validation_issues, match, awaiting_approval, approval, posted]
ui: # free-form; served by GET /api/agents
approval_card:
title: "Approve invoice {{ invoice.invoice_number }}"
fields: [invoice.vendor_name, invoice.total, invoice.currency, invoice.due_date, validation_issues]
editable: [invoice.po_number, invoice.due_date]
nodes: [ … ]
edges: [ … ]
Only keys listed in state_whitelist ever reach the browser. Keep document text and identifiers
out of it.
6.1 Task kinds
document.parse
Turns a document into markdown.
with | Meaning |
|---|---|
parser | key in settings.documents.parsers |
input | context key holding { document_id } (an upload) or { markdown } (text supplied directly) |
output | context key to write the markdown to |
It is skipped if output already has text, and a document that ingestion already parsed is not
parsed again. A chat message becomes document_ref: { markdown } automatically.
llm.extract
Structured extraction with a model, a prompt and a schema.
with | Meaning |
|---|---|
model | key in models.yaml; must have role: chat |
prompt | key in prompts/; its user template must contain {{ input }} |
schema | key in schemas/ |
input, output | context keys |
The document text is fenced: the prompt's fence_tag is stripped from the text so a document
cannot close its own fence, and the text is cut to max_input_chars. Decoding is constrained to
the schema, and the result is validated again. Output that does not match fails the step, and the
run becomes failed. Fields under output that are listed in policies.yaml pii.hash_fields
(here invoice.vendor_iban) are replaced by their SHA-256 before they are stored.
rules.validate
Deterministic checks after the model. It writes a list of { rule, message }, and an empty list
means the record passed.
| Rule | Parameters | Passes when |
|---|---|---|
sum_equals | left: [fields], right, tolerance, message | the left fields sum to right |
lines_sum_equals | lines, field, right, tolerance, message | field summed over the lines array equals right |
required | fields: [..], message | every field is present and not blank |
matches | field, pattern (regex), message | the field is a string matching pattern |
message is optional; without it, a description of the failure is used.
records.match
Looks the record up in your data, trying each strategy in order.
| Strategy | Parameters | Uses |
|---|---|---|
vendor_tax_id | — | vendor_tax_id = vendors.tax_id (score 1.0) |
po_number | — | po_number = purchase_orders.po_number |
vendor_name_fuzzy | min_score | vendor_name similarity to vendors.name (0–1) |
vector | model (embedding), top_k, min_score | similar ingested documents |
It writes { vendor, purchase_order, score, bank_details_changed, candidates }. score is the
matched vendor's score. bank_details_changed is true when the record carries a vendor_iban
whose hash differs from the vendor's stored iban_hash.
human.approval
Pauses for a person; see §7.
connector.invoke
Calls a connector action once per idempotency key; see §9.
with | Meaning |
|---|---|
connector, action | must be declared in connectors/ |
input, output | context keys |
idempotency_key | a template that must contain a placeholder, e.g. "{{ session_id }}:post_bill" |
flow.end
Ends the run with message. A run that ends at a node named reject is recorded as rejected.
Retries
Any node may add retry: { max: 3, backoff_sec: 5 }. Only transient failures are retried
(network errors, HTTP 429 and 5xx), waiting backoff_sec × attempt between tries.
6.2 Edges and conditions
edges:
- { from: parse, to: extract }
- from: approval
when: { key: approval.approved, op: eq, value: true }
to: post
else: reject
- Each node has at most one outgoing edge. A branch is one edge with
whenandelsetogether. keyis a dotted path whose first segment is a context key.- Operators:
eq,ne,gt,gte,lt,lte,empty,not_empty,in. Combine withall: [..]andany: [..]. Comparing a string numerically is false, not an error. flow.endnodes have no outgoing edge; every other node needs one.
6.3 What boot checks
- Unknown keys, duplicate ids, an
entrythat is not a node, edges to missing nodes,whenwithoutelse(or the reverse), more than one outgoing edge, unreachable nodes. - Unknown kinds, invalid
withparameters, references to models, prompts, schemas, parsers, connectors, actions or roles that do not exist, and models with the wrong role. - A non-terminal node with no way out, or a
flow.endwith one. - A payment reachable without approval: any path from
entryto aconnector.invokewhose action hasrequires_approval: truemust pass ahuman.approvalnode. - Model licenses outside
allowed_licenses, invalid JSON Schemas, tools naming undeclared actions, roles inheriting unknown roles, and tenant files naming unknown workflows, nodes or connectors.
6.4 Worked example: a second workflow
Save this as config/agents/workflows/vendor_check_v1.yaml. It reuses the invoice prompt and
schema, posts nothing, and needs no Rust:
# Check an invoice against the vendor master without posting anything.
id: vendor_check_v1
version: 1
title: Vendor check
description: Extract an invoice and report which vendor and purchase order it matches.
entry: parse
state_whitelist: [invoice, validation_issues, match]
nodes:
- id: parse
kind: document.parse
with: { parser: docling, input: document_ref, output: document_md }
- id: extract
kind: llm.extract
with: { model: chat_default, prompt: invoice_extract, schema: invoice, input: document_md, output: invoice }
- id: validate
kind: rules.validate
with:
input: invoice
output: validation_issues
rules:
- { check: required, fields: [vendor_name, invoice_number] }
- id: match
kind: records.match
with:
input: invoice
output: match
strategies:
- { by: vendor_tax_id }
- { by: vendor_name_fuzzy, min_score: 0.8 }
- id: report
kind: flow.end
with: { message: "Vendor check finished" }
edges:
- { from: parse, to: extract }
- { from: extract, to: validate }
- { from: validate, to: match }
- { from: match, to: report }
Validate it, then restart the server. Vendor check appears in the agent picker:
$ LOCO_ENV=demo cargo run -p bo-app -- task config_validate
OK: 2 workflow(s) (1 tenant variant(s)), 1 connector(s), 2 model(s), 1 prompt(s), 1 schema(s), 1 tenant(s)
A mistake is refused with its location. Pointing the last edge at a node that doesn't exist gives:
config/agents is invalid: workflow `vendor_check_v1`: edge references unknown node `post_it`
A tenant whose file lists workflows.enabled (like acme) sees only the workflows named there.
Add the new id to its list to offer it to that tenant.