Skip to main content

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.

withMeaning
parserkey in settings.documents.parsers
inputcontext key holding { document_id } (an upload) or { markdown } (text supplied directly)
outputcontext 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.

withMeaning
modelkey in models.yaml; must have role: chat
promptkey in prompts/; its user template must contain {{ input }}
schemakey in schemas/
input, outputcontext 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.

RuleParametersPasses when
sum_equalsleft: [fields], right, tolerance, messagethe left fields sum to right
lines_sum_equalslines, field, right, tolerance, messagefield summed over the lines array equals right
requiredfields: [..], messageevery field is present and not blank
matchesfield, pattern (regex), messagethe 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.

StrategyParametersUses
vendor_tax_id—vendor_tax_id = vendors.tax_id (score 1.0)
po_number—po_number = purchase_orders.po_number
vendor_name_fuzzymin_scorevendor_name similarity to vendors.name (0–1)
vectormodel (embedding), top_k, min_scoresimilar 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.

withMeaning
connector, actionmust be declared in connectors/
input, outputcontext keys
idempotency_keya 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 when and else together.
  • key is a dotted path whose first segment is a context key.
  • Operators: eq, ne, gt, gte, lt, lte, empty, not_empty, in. Combine with all: [..] and any: [..]. Comparing a string numerically is false, not an error.
  • flow.end nodes have no outgoing edge; every other node needs one.

6.3 What boot checks​

  • Unknown keys, duplicate ids, an entry that is not a node, edges to missing nodes, when without else (or the reverse), more than one outgoing edge, unreachable nodes.
  • Unknown kinds, invalid with parameters, 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.end with one.
  • A payment reachable without approval: any path from entry to a connector.invoke whose action has requires_approval: true must pass a human.approval node.
  • 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.