# Flow Editor — Architecture & Integration Guide > Authoritative reference for the Flow Editor subsystem: visual builder in the > Ops PWA, JSON flow-definition language, runtime engine in `targo-hub`, and > the Frappe scheduler that wakes delayed steps. Companion document to > [../architecture/overview.md](../architecture/overview.md) and > [../architecture/data-model.md](../architecture/data-model.md). --- ## 1. Goals & Principles The Flow Editor lets operators compose automations — onboarding, follow-ups, SLA escalations, billing side-effects — without writing code or leaving the ERPNext/Ops perimeter. It behaves like n8n or Shopify Flow, but is **natively embedded** in the Ops PWA and talks directly to ERPNext via the `targo-hub`. Design tenets: 1. **One UI, many domains.** A single `FlowEditor.vue` tree is parameterised by a *kind catalog* (`PROJECT_KINDS`, `AGENT_KINDS`, …). Adding a new catalog adds a new editor, no UI fork. 2. **Inline-editable from anywhere** (Odoo pattern). `FlowQuickButton` + `useFlowEditor()` let any page pop the editor with preset filters (category/applies-to/trigger) — the user never has to route to Settings to create a flow. 3. **Pluggable runtime via a kind dispatcher.** `KIND_HANDLERS` in `flow-runtime.js` is a plain map — no `if/else` chain. Adding a step kind = write one async function. 4. **No code-as-config.** Predicates go through a pure evaluator (`evalCondition`) — never `eval()` / `Function()`. Templates are rendered with a regex (`{{a.b.c}}`), no Mustache lib. 5. **Persist decisions, not heartbeats.** Delayed steps become `Flow Step Pending` rows with a `trigger_at`. The scheduler never re-executes logic itself — it just nudges the Hub. 6. **Seeded templates are protected** (`is_system=1`). Operators can duplicate them but not delete or rename. --- ## 2. File Inventory ### Front-end — `apps/ops/src/` | Path | Responsibility | |-------------------------------------------------------|-----------------------------------------------------------------| | `components/flow-editor/FlowEditor.vue` | Vertical-tree view + drag-to-reorder, consumes a kind catalog | | `components/flow-editor/FlowNode.vue` | Single step card (icon, label, menu, branch connectors) | | `components/flow-editor/StepEditorModal.vue` | Modal that edits one step's payload (trigger + kind fields) | | `components/flow-editor/FieldInput.vue` | Polymorphic field widget (text/number/select/datetime/…) | | `components/flow-editor/FlowEditorDialog.vue` | Full-screen dialog for template-level edits (meta + tree) | | `components/flow-editor/FlowTemplatesSection.vue` | Settings list: filter, duplicate, delete, toggle active | | `components/flow-editor/FlowQuickButton.vue` | Drop-in "New flow / Edit flow" button | | `components/flow-editor/kind-catalogs.js` | `PROJECT_KINDS`, `AGENT_KINDS`, `TRIGGER_TYPES`, `buildEmptyStep` | | `components/flow-editor/index.js` | Barrel export | | `composables/useFlowEditor.js` | Module-level singleton reactive store | | `api/flow-templates.js` | REST client for `/flow/templates/*` | ### Back-end — `services/targo-hub/lib/` | Path | Responsibility | |------------------------|-------------------------------------------------------------------| | `flow-templates.js` | REST API: CRUD + duplicate + validation | | `flow-runtime.js` | Execution engine: kind handlers, wave loop, template render | | `flow-api.js` | REST API: `/flow/start`, `/flow/advance`, `/flow/complete`, `/flow/event`, `/flow/runs` | | `contracts.js` | Fires `on_contract_signed` on JWT confirm | | `payments.js` | Fires `on_payment_received` on Stripe webhook | | `acceptance.js` | Fires `on_quotation_accepted` on accept/confirm | ### ERPNext — `erpnext/` | Path | Responsibility | |------------------------------|-------------------------------------------------------------| | `flow_scheduler.py` | Frappe cron hook (every minute) — claims due pending steps | | `seed_flow_templates.py` | Seeds the 4 project templates + `residential_onboarding` + `quotation_follow_up` | | `setup_flow_templates.py` | DocType creation (Flow Template / Flow Run / Flow Step Pending) | --- ## 3. Data Model (ERPNext doctypes) Three custom doctypes live under `ERPNext`. All named by series (`FT-NNNNN`, `FR-NNNNN`, `FSP-NNNNN`). ### 3.1 `Flow Template` Authoritative definition of an automation. Edited via the Flow Editor. | Field | Type | Notes | |----------------------|----------|---------------------------------------------------------------| | `template_name` | Data | Human label (unique within category) | | `category` | Select | `residential` / `commercial` / `incident` / `billing` / `other` | | `applies_to` | Link | Target DocType (Service Contract, Quotation, Issue, …) | | `icon` | Data | Material icon name | | `description` | Small Text | One-liner | | `is_active` | Check | Only active templates dispatch on events | | `is_system` | Check | Seeded by code — cannot be deleted; duplicating is allowed | | `version` | Int | Bumped on every save (used for audit trail) | | `trigger_event` | Select | `on_contract_signed`, `on_payment_received`, `on_subscription_active`, `on_quotation_created`, `on_quotation_accepted`, `on_issue_opened`, `on_customer_created`, `on_dispatch_completed`, `manual` | | `trigger_condition` | Code | Optional JSONLogic-ish gate — today only `==` is wired | | `flow_definition` | Long Text (JSON) | The whole graph — see §4 | | `step_count` | Int | Computed on save for list display | | `tags`, `notes` | Data / Text | Free-form metadata | ### 3.2 `Flow Run` One execution of a template against a specific trigger doc. | Field | Type | Notes | |----------------------|---------------|--------------------------------------------------------| | `flow_template` | Link | The FT we're executing | | `template_version` | Int | Snapshot at start — template edits don't retro-affect | | `status` | Select | `running` / `completed` / `failed` / `cancelled` | | `trigger_event` | Data | What caused this run (for audit) | | `context_doctype` | Data | Trigger doc's doctype | | `context_docname` | Data | Trigger doc's name | | `customer` | Link Customer | Resolved customer for template rendering | | `variables` | Long Text (JSON) | Runtime bag passed by `dispatchEvent` (amount, signed_at, …) | | `step_state` | Long Text (JSON) | `{ [stepId]: { status, started_at, completed_at, result, error } }` | | `started_at`, `completed_at` | Datetime | Timestamps | ### 3.3 `Flow Step Pending` One row per delayed step. The scheduler processes these. | Field | Type | Notes | |-------------------|--------------|---------------------------------------------------------| | `flow_run` | Link | Parent run | | `step_id` | Data | The step within `flow_definition.steps` | | `trigger_at` | Datetime | When to fire (indexed — scheduler queries this) | | `status` | Select | `pending` → `running` → `completed` or `failed` | | `retry_count` | Int | Increments on scheduler failures (caps at 5) | | `last_error` | Small Text | Truncated error from last attempt | | `context_snapshot`| Long Text (JSON) | Step payload captured at schedule time (optional) | | `executed_at` | Datetime | Set when scheduler successfully nudges the Hub | --- ## 4. Flow Definition JSON Schema Stored as the `flow_definition` field of `Flow Template` and as `{step}` in `Flow Step Pending.context_snapshot`. ```json { "version": 1, "trigger": { "event": "on_contract_signed", "condition": "" }, "variables": { "default_group": "Tech Targo" }, "steps": [ /* Step[] */ ] } ``` ### 4.1 Step shape ```json { "id": "step_abc123", "kind": "dispatch_job", "label": "Installation fibre", "parent_id": null, "branch": null, "depends_on": [], "trigger": { "type": "on_flow_start" }, "payload": { "subject": "Installation {{customer.customer_name}}", "job_type": "Installation", "priority": "medium", "duration_h": 2, "assigned_group": "Tech Targo" } } ``` | Field | Type | Notes | |--------------|----------------|---------------------------------------------------------| | `id` | string | Stable UID, generated client-side (`step_xxxxxx`) | | `kind` | string | Must exist in `KIND_HANDLERS` | | `label` | string | Free text shown in the editor + timeline | | `parent_id` | `string\|null` | If set, this step is inside the parent's branch | | `branch` | `string\|null` | Matches `parent.result.branch` (e.g. `"yes"` / `"no"`) | | `depends_on` | `string[]` | Must all be `done` before this step becomes ready | | `trigger` | object | When to fire — see §4.2 | | `payload` | object | Kind-specific fields (see §4.3) | ### 4.2 Trigger types | `trigger.type` | Extra fields | Semantics | |---------------------|---------------------------|----------------------------------------------------------| | `on_flow_start` | — | Fires in the very first wave of `advanceFlow` | | `on_prev_complete` | — | Fires when all `depends_on` are `done` | | `after_delay` | `delay_hours`, `delay_days` | Scheduled — creates a `Flow Step Pending` row | | `on_date` | `at` (ISO) | Scheduled — creates a `Flow Step Pending` row | | `on_webhook` | — | Stays `pending`, external POST to `/flow/complete` fires it | | `manual` | — | Stays `pending`, user clicks a button to fire | ### 4.3 Payloads per kind The authoritative list lives in `apps/ops/src/components/flow-editor/kind-catalogs.js` (PROJECT_KINDS). The runtime map in `flow-runtime.js` must be kept in sync. | Kind | Payload | |--------------------------|----------------------------------------------------------------------| | `dispatch_job` | `subject`, `job_type`, `priority`, `duration_h`, `assigned_group`, `on_open_webhook`, `on_close_webhook`, `merge_key` | | `issue` | `subject`, `description`, `priority`, `issue_type` | | `notify` | `channel` (sms/email), `to`, `template_id`, `subject`, `body` | | `webhook` | `url`, `method`, `body_template` (JSON string) | | `erp_update` | `doctype`, `docname_ref`, `fields_json` (JSON string) | | `wait` | — (delay controlled by `trigger.type=after_delay`) | | `condition` | `field`, `op`, `value` → exposes `result.branch = "yes"\|"no"` | | `subscription_activate` | `subscription_ref` | ### 4.4 Template interpolation Strings in `payload` support `{{a.b.c}}` dotted paths. The context is: ```js { doc, // the trigger doc (contract, quotation, issue) customer, // resolved Customer doc run, // the Flow Run (name, variables, step_state) template, // the Flow Template (name, version) variables, // run-level variable bag now // current ISO datetime } ``` Unknown paths render as empty string — rendering never throws. --- ## 5. Front-end Architecture ### 5.1 Component hierarchy ``` MainLayout.vue └─ FlowEditorDialog.vue (mounted once, globally) └─ FlowEditor.vue └─ FlowNode.vue (xN) └─ StepEditorModal.vue └─ FieldInput.vue (xN) Any page └─ FlowQuickButton.vue (opens the global dialog) SettingsPage.vue └─ FlowTemplatesSection.vue (list/filter/toggle/duplicate) ``` ### 5.2 `useFlowEditor()` — singleton reactive store Module-level `ref`s shared across every call to `useFlowEditor()` — no provide/inject, no Pinia store. Any component can call `openTemplate()` and the globally-mounted `` reacts automatically. Read-only exposed refs: `isOpen`, `loading`, `saving`, `error`, `dirty`, `mode`, `templateName`. Mutable: `template` (the draft body). Actions: | Method | Behaviour | |----------------------------|-----------------------------------------------------------------| | `openTemplate(name, opts)` | Fetches FT, populates `template`, `isOpen = true`, mode=`edit` | | `openNew(opts)` | Blank draft with `category`/`applies_to`/`trigger_event` presets | | `close(force?)` | Confirms unsaved changes unless `force=true` | | `save()` | Create or patch — bumps version, invokes `onSaved` callbacks | | `duplicate(newName)` | Clones current, loads the clone | | `remove()` | Deletes current (forbidden on `is_system=1`) | | `markDirty()` | Called on every field mutation — O(1), avoids deep-watch cost | ### 5.3 Pluggable kind catalogs `FlowEditor` takes a `kindCatalog` prop. Two are shipped: * `PROJECT_KINDS` — 8 kinds for service-delivery automations * `AGENT_KINDS` — 6 kinds for conversational agent flows (preserves the old `AgentFlowsPage` semantics for a later drop-in refactor) Each catalog entry defines: `label`, `icon`, `color`, optional `hasBranches`/`branchLabels`, and a `fields[]` array of field descriptors. Descriptor shape is documented at the top of `kind-catalogs.js`. Adding a field descriptor = the modal renders the right widget automatically. ### 5.4 `FlowQuickButton` — inline entry points Drop-in button with two modes: ```vue ``` Used in: `ProjectWizard.vue`, `TicketsPage.vue`, `IssueDetail.vue`, `ClientDetailPage.vue` (Contrats de service header). Add it wherever a contextual "spin up an automation from here" shortcut makes sense. --- ## 6. Runtime (`flow-runtime.js`) ### 6.1 Wave loop — `advanceFlow(runName)` 1. Fetch run + template + build context (`_buildContext`). 2. Loop up to 50 waves: 1. For each step, `isStepReady(step, state, def)`: - all `depends_on` done? - parent done + `branch` matches parent's result? 2. If ready: - `after_delay`/`on_date` → schedule `Flow Step Pending`, state=`scheduled` - `on_webhook`/`manual` → leave `pending`, caller resumes later - else → inline execute via `KIND_HANDLERS[step.kind]`, mutate state 3. If no step progressed, break. 3. Compute run-level status: `completed` if all done, `failed` if any failed. 4. Persist `step_state` + patch once (single PUT). The wave loop means a single `advanceFlow` call collapses an entire chain of instant steps into one write — the scheduler's job is just to kick the first domino after a delay. ### 6.2 Kind dispatcher ```js const KIND_HANDLERS = { dispatch_job, issue, notify, webhook, erp_update, subscription_activate, wait, condition, } ``` Every handler has the same signature: ```js async function handleXxx(step, ctx) { … return { status, result?, error? } } ``` `status` is one of `STATUS.DONE`, `STATUS.FAILED`, or `STATUS.SCHEDULED`. ### 6.3 Condition evaluator ```js evalCondition({ field, op, value }, ctx) // → boolean ``` Supported ops: `== != < > <= >= in not_in empty not_empty contains starts_with ends_with`. No `eval`, no `Function`, no prototype access. `condition` steps return `{ branch: ok ? 'yes' : 'no' }` so child steps can filter on `branch`. ### 6.4 `dispatchEvent(eventName, opts)` The public event hook used by `contracts.js`, `payments.js`, `acceptance.js`, etc. It: 1. Queries `Flow Template` with `trigger_event = eventName AND is_active = 1`. 2. For each, evaluates `trigger_condition` (simple `==` JSON gate today). 3. Calls `startFlow` for matching templates. Callers use it non-blocking (`.catch(()=>{})`) so automation failures never break the primary flow (contract signature, payment, etc.). ### 6.5 Scheduler (`flow_scheduler.py`) Runs every minute via Frappe `hooks.py`: ```python scheduler_events = { "cron": { "* * * * *": ["flow_scheduler.tick"] } } ``` Algorithm (`_claim_due_rows` → `_fire_row`): 1. Select `Flow Step Pending` where `status='pending' AND trigger_at <= now()` (limited to 50 per tick). 2. Atomically flip them to `status='running'` in one SQL UPDATE. 3. For each, POST `{run, step_id}` to `HUB_URL/flow/complete`. 4. On success: `status='completed'`, `executed_at=now()`. 5. On failure: increment `retry_count`, set `status='pending'` (or `'failed'` after 5 retries), store truncated error in `last_error`. The claim-then-fire split guarantees at-most-once execution even under concurrent ticks. `retry_count` gives durable back-off without polling logic. --- ## 7. HTTP API ### 7.1 `/flow/templates` (mounted in `flow-templates.js`) | Verb | Path | Body / Query | Response | |------|--------------------------------|---------------------------|-----------------------| | GET | `/flow/templates` | `?category=&applies_to=&is_active=&trigger_event=` | `{templates: FT[]}` | | GET | `/flow/templates/:name` | — | `{template: FT}` | | POST | `/flow/templates` | `{template_name, …}` | `{template}` | | PUT | `/flow/templates/:name` | partial patch | `{template}` | | DELETE | `/flow/templates/:name` | — | `{ok: true}` (blocks `is_system`) | | POST | `/flow/templates/:name/duplicate` | `{new_name?}` | `{template}` | Validation runs on every write: unique step IDs, known kinds, referential integrity of `depends_on`/`parent_id`. ### 7.2 `/flow/*` (mounted in `flow-api.js`) | Verb | Path | Body | Response | |------|----------------------|---------------------------------------------------|-------------------------------| | POST | `/flow/start` | `{template, doctype, docname, customer, variables}` | `{run, executed, scheduled}` | | POST | `/flow/advance` | `{run}` | `{run, executed, scheduled}` | | POST | `/flow/complete` | `{run, step_id, result?}` | `{run, …}` | | POST | `/flow/event` | `{event, doctype, docname, customer, variables}` | `{results: [...]}` | | GET | `/flow/runs` | `?status=&template=&customer=` | `{runs: FR[]}` | | GET | `/flow/runs/:name` | — | `{run: FR}` | All endpoints support optional `Authorization: Bearer $HUB_INTERNAL_TOKEN` (controlled via `INTERNAL_TOKEN` env). Used by the Frappe scheduler. --- ## 8. Trigger Wiring Event emitters are intentionally thin — two lines each, failure-tolerant. ### 8.1 `on_contract_signed` (in `contracts.js`) Fired by the JWT accept flow (residential) on `POST /contract/confirm`: ```js _fireFlowTrigger('on_contract_signed', { doctype: 'Service Contract', docname: contractName, customer: payload.sub, variables: { contract_type, signed_at }, }) ``` `_fireFlowTrigger` lazy-requires `./flow-runtime` so the cost is paid only when a trigger actually fires. ### 8.2 `on_payment_received` (in `payments.js`) Fired from the Stripe `checkout.session.completed` webhook handler, after the Sales Invoice is marked paid: ```js require('./flow-runtime').dispatchEvent('on_payment_received', { doctype: 'Sales Invoice', docname: invoiceName, customer, variables: { amount, payment_intent: piId }, }) ``` Wrapped in `try/catch` so a flow bug never blocks a payment. ### 8.3 `on_quotation_accepted` (in `acceptance.js`) Fired by the commercial accept flow (DocuSeal/JWT) on `POST /accept/confirm`: ```js require('./flow-runtime').dispatchEvent('on_quotation_accepted', { doctype: 'Quotation', docname: payload.doc, customer: payload.sub, variables: { accepted_at }, }) ``` ### 8.4 Future wiring To hook another event (e.g. `on_ticket_resolved`): 1. Pick the source of truth (ERPNext Issue webhook, Ops inline save, etc.). 2. From that handler, call `require('./flow-runtime').dispatchEvent(...)`. 3. Add the event name to `TRIGGER_EVENT_OPTIONS` in `FlowEditorDialog.vue` so operators can pick it from the UI. 4. (Optional) add a pre-seeded template in `seed_flow_templates.py`. --- ## 9. How-To Recipes ### 9.1 Add a new step kind 1. **Front-end** — in `kind-catalogs.js`, extend `PROJECT_KINDS` with a new entry: ```js send_portal_invite: { label: 'Invitation portail', icon: 'mail_lock', color: '#0ea5e9', fields: [ { name: 'to', type: 'text', label: 'Email', required: true }, { name: 'role', type: 'select', label: 'Rôle', options: ['customer', 'tech'] }, ], } ``` 2. **Back-end** — in `flow-runtime.js`, implement the handler: ```js async function handlePortalInvite (step, ctx) { const p = renderDeep(step.payload || {}, ctx) // … call the existing portal-invite endpoint … return { status: STATUS.DONE, result: { … } } } ``` 3. Register it in `KIND_HANDLERS`: ```js const KIND_HANDLERS = { …, send_portal_invite: handlePortalInvite } ``` 4. Add the kind name to `flow-templates.js → validateFlowDefinition.validKinds`. 5. Rebuild + deploy Hub; rebuild + deploy Ops SPA. No UI changes: the new kind renders its fields automatically. ### 9.2 Add a new trigger event 1. Emit it: `require('./flow-runtime').dispatchEvent('on_foo', {…})` from wherever the event occurs. 2. Add the label in `FlowEditorDialog.vue` `TRIGGER_EVENT_OPTIONS`. 3. (Optional) pre-seed a template in `seed_flow_templates.py`. ### 9.3 Add a new kind catalog (e.g. for billing flows) 1. Export a new const from `kind-catalogs.js`: `export const BILLING_KINDS = {...}`. 2. Pass it into any `` instance. 3. Reuse the same `FlowEditorDialog` if the kinds live side by side; fork it only if you need dramatically different metadata panels. ### 9.4 Add a trigger condition Today `trigger_condition` supports a simple `{ "==": [{"var": "path"}, "value"] }` JSON gate. To extend to full JSONLogic: 1. Replace `_matchCondition` in `flow-runtime.js` with the `json-logic-js` library (already a stable dep elsewhere in Hub — check first). 2. Update `validateFlowDefinition` to reject unknown operators. 3. Document the new operators in this file. --- ## 10. Debugging Guide ### 10.1 Flow didn't run when expected 1. **Check the event was emitted.** `docker logs targo-hub | grep '\[flow\]'`. You should see `[flow] started FR-XXXXX from FT-YYYYY`. 2. **Check the template is active + matches.** In Ops → Settings → Flow Templates, confirm `is_active`, `trigger_event`, and `trigger_condition` (if set) match the event. 3. **Inspect the Flow Run.** `GET /flow/runs/:name` returns full state. `step_state[stepId].status = 'failed'` + `.error` pinpoints the cause. ### 10.2 Delayed step never fires 1. **Check the scheduler is ticking.** ERPNext backend logs: `docker logs erpnext-backend-1 | grep flow_scheduler`. If silent, confirm the cron entry in `hooks.py` and the Frappe scheduler worker is up (`bench doctor`). 2. **Check the pending row.** ERPNext → Flow Step Pending list: `status`, `trigger_at`, `retry_count`, `last_error`. 3. **Manually fire it:** `POST /flow/complete` with `{run, step_id}` from any host with access to the Hub. ### 10.3 Template renders empty strings `{{foo.bar}}` returned empty means the path is absent from the context. Add a `console.log(ctx)` in the handler, or use a `condition` step upstream to verify the data is populated before the branch that uses it. Remember that `customer` is only loaded if `run.customer` is set *or* the trigger doc has a `customer` field. ### 10.4 "Step has no handler" error The kind name in the template no longer matches `KIND_HANDLERS`. Either a deploy mismatch (front-end ahead of Hub) or a kind was removed. Revert the Hub or update the Flow Template. ### 10.5 Retry loop stuck `Flow Step Pending.retry_count` maxes at 5 then flips to `failed`. To retry manually: set `status='pending'`, `retry_count=0` on the row, wait a minute. Fix root cause before retrying — the scheduler won't back off past 5. --- ## 11. Seeded Templates Living in `erpnext/seed_flow_templates.py`. All created with `is_system=1`. | FT Name | Category | Trigger event | Purpose | |----------------------|--------------|--------------------------|------------------------------------------------------------| | fiber_install | residential | manual | 4-step install (dispatch install + notify + activate sub + 24h survey) | | phone_service | residential | manual | Phone-only activation | | move_service | residential | manual | Move existing service to new address | | repair_service | incident | on_issue_opened | Triage + dispatch repair | | residential_onboarding | residential | on_contract_signed | Install + welcome SMS + sub activate + 24h survey + 11-month renewal reminder | | quotation_follow_up | commercial | on_quotation_accepted | 3-touch email sequence (D+3, D+7, D+14) | Run the seeder once after deploying the scheduler: ```bash docker exec -u frappe erpnext-backend-1 bash -c \ 'cd /home/frappe/frappe-bench/sites && \ /home/frappe/frappe-bench/env/bin/python -c \ "import frappe; frappe.init(site=\"erp.gigafibre.ca\"); frappe.connect(); \ from seed_flow_templates import seed_all; seed_all()"' ``` --- ## 12. Security & Governance * **Seeded templates** (`is_system=1`) cannot be deleted — only duplicated. This preserves the baseline automations even after operator mistakes. * **Internal token**: all `/flow/*` endpoints honour `Authorization: Bearer $HUB_INTERNAL_TOKEN`. Set it in both Hub and ERPNext environment to lock down the API surface between containers. * **No `eval`** anywhere: conditions run through a fixed predicate table, templates through a regex. Adding arbitrary code to a flow is not a supported feature — if a use case needs it, the answer is "add a new kind". * **Validation is mandatory** on every write: duplicate IDs, unknown kinds, dangling `depends_on`, orphan `parent_id` all reject at the API. * **Audit**: every Flow Run carries `template_version` so historical runs stay traceable even after the template is edited. --- ## 13. Deployment Topology ``` ERPNext backend ──cron every 60s──► flow_scheduler.tick() │ │ (INTERNAL_TOKEN) ▼ Ops PWA ───REST───► targo-hub ─PUT/POST─► ERPNext (Flow Template, Flow Run, Flow Step Pending) FlowEditorDialog flow-api.js (+ Dispatch Job, Issue, Customer, Subscription, …) flow-runtime.js flow-templates.js ▲ │ └── dispatchEvent ◄──────┘ (contracts.js, payments.js, acceptance.js) ``` Deploy order (important): 1. ERPNext: create doctypes via `setup_flow_templates.py`, wire `flow_scheduler.tick` in `hooks.py`, restart scheduler. 2. targo-hub: ship `flow-runtime.js`, `flow-api.js`, `flow-templates.js` + the trigger emitters in `contracts.js` / `payments.js` / `acceptance.js`. Restart container. 3. Ops SPA: build + deploy. The editor is usable instantly; it just can't dispatch until the Hub is up. 4. Seed the templates (`seed_flow_templates.py`). --- ## 14. Glossary - **Flow Template (FT)** — the definition, reusable, edited by operators. - **Flow Run (FR)** — one execution against one trigger doc. - **Flow Step Pending (FSP)** — one row per delayed step, the scheduler's inbox. - **Kind** — a step type (`dispatch_job`, `notify`, …). Runtime-dispatched via `KIND_HANDLERS`. - **Catalog** — a set of kinds exposed in one editor instance (`PROJECT_KINDS`, `AGENT_KINDS`). - **Wave** — one iteration of `advanceFlow`'s inner loop. A flow typically needs 1–3 waves; the 50 cap is a safety net against cycles. - **Trigger event** — the incoming signal that kicks off flows (`on_contract_signed`, …). - **Trigger type** — the per-step firing rule inside a flow (`on_prev_complete`, `after_delay`, …). --- *Last updated: 2026-04-21. Owners: Targo Platform team (). See [../roadmap.md](../roadmap.md) for upcoming Flow Editor v2 items (JSONLogic conditions, sub-flows, parallel branches, retry policies).*