Skip to content

Architecture

The two Workers, the service binding between them, the packages, the bindings and the connectors.

Order Loop is two Workers in one pnpm monorepo. orderloop-web serves the dashboard, the flow editor and the REST API; orderloop-engine runs orders. The web app reaches the engine only through a service binding, and the engine has no public URL.

browser ──► orderloop-web (apps/web, React Router on Workers)
│ D1: flows + versions, orders index, approvals, back office
│ R2: order documents
├── ENGINE service binding ──RPC──► orderloop-engine (apps/engine)
│ ├─ EngineApi WorkerEntrypoint (RPC)
└── /agents/order-agent/:id ─fetch──► ├─ OrderAgent Durable Object, one per order
├─ OrderLoop Workflow, one instance per generation
├─ Replenish Workflow, daily cron
inbound mail ── Email Routing ──email()────────────►├─ receiveEmail email intake (email.ts)
└─ Workers AI, D1, R2
Path Role
packages/core (@orderloop/core) Flow schema, node catalog, planner and validation, layout, JSON Logic expressions, policy rules, address checks, the pricing chain resolver, templates, webhook signing, the EngineRpc contract. No bindings and no Cloudflare imports
packages/db (@orderloop/db) Drizzle schema for D1, numbered SQL migrations with seed data, flow persistence (getFlowVersionJson, publishFlow)
packages/ui (@orderloop/ui) shadcn/ui primitives, consumed as raw .tsx
apps/engine (@orderloop/engine) OrderAgent, OrderLoop, Replenish, node executors, connectors, AI calls, outbox, EngineApi
apps/web (@orderloop/web) Dashboard, editor, live order view, approvals inbox, back office, /api/v1
apps/docs (@orderloop/docs) This site

packages/core is pure TypeScript so its semantics can be unit tested in Node: anything that needs env belongs in an app. The planner lives there, not in the Workflow (see Planner).

EngineRpc in packages/core/src/api.ts is the whole surface the web app may use:

Method What it does
startOrder(input) Inserts the order row, initialises its OrderAgent, creates generation 1 of OrderLoop. Honours idempotencyKey
sendEvent(orderId, event, payload?) Delivers a business event to the node waiting for it; returns { delivered, nodeId? }
decide(orderId, nodeId, decision, by, note?) Approves or rejects the approval a node waits on
cancel(orderId, reason) Terminates the Workflow and compensates
snapshot(orderId) The agent’s OrderState, or null for an unknown order
copilot(req) Edits or creates a flow graph from a prompt
runReplenish() Creates a Replenish instance now

EngineApi (apps/engine/src/api.ts) implements it as a WorkerEntrypoint. Every method except startOrder, copilot and runReplenish forwards to the order’s agent via ORDER_AGENT.getByName(orderId). EngineApi trusts its caller: authorization happens in the web app before the call. The web app types its ENGINE binding with EngineRpc and never imports engine source.

apps/web/workers/app.ts routes every request before React Router sees it:

Path Handling
GET /health { ok: true, service: "orderloop-web" }
POST /api/v1/webhooks/events handleSignedEvent: authenticated by HMAC with WEBHOOK_SECRET, not the password
GET /api/v1/openapi.json The OpenAPI document, public
/api/v1/* apiPrincipal (an ol_live_ token, APP_PASSWORD, or the session cookie), then handleApi(request, caller), which checks each route’s role
/agents/order-agent/* principal, then proxied to the engine’s fetch (routeAgentRequest) with the caller in x-orderloop-principal (replacing any the browser sent), carrying the Agents SDK WebSocket
/docs/orders/* principal, then the R2 object, served with content-security-policy: default-src 'none'; style-src 'unsafe-inline'
everything else React Router (UI routes guard themselves with requireUser(request, role))

apps/web/app/lib/auth.server.ts resolves the principal: a __Host-ol_session cookie (a sessions row), the shared-password cookie, or a bearer token. With APP_PASSWORD unset and no users, it is an open operator, which is the local dev default. See Team and roles. The engine’s own fetch (apps/engine/src/index.ts) answers /health and routes agent requests; with workers_dev: false and no routes it is reachable only through the binding. Its email handler receives messages from Email Routing and hands them to receiveEmail; see Email intake.

Binding Worker Resource
ORDER_AGENT engine Durable Object class OrderAgent (SQLite, migration tag v1)
ORDER_LOOP engine Workflow orderloop-order, class OrderLoop, limits.steps: 10000
REPLENISH engine Workflow orderloop-replenish, class Replenish, schedule 0 6 * * *
DB both D1 orderloop, migrations in packages/db/migrations
DOCS both R2 orderloop-docs
AI engine Workers AI
ENGINE web Service orderloop-engine, entrypoint EngineApi

Variables and secrets are listed in Configuration.

apps/engine/src/connectors.ts holds mock commerce connectors over the D1 back office (customers, products, contract_prices, price_sources, price_rules, reservations, stock_movements, payments, shipments, messages) and R2. Each function is the seam where a real adapter (Stripe, a WMS, a carrier API, an ERP) replaces the mock. validateAddress is the seam for an address provider (USPS, Loqate); the mock calls checkAddress from core.

Two rules hold for every connector:

  • Writes are keyed by the step that made them. Ids embed the order id and the step key, for example res_${orderId}_${stepKey}_${line}_${sku}, pay_${orderId}_${stepKey}, shp_${orderId}_${stepKey}, and inserts use onConflictDoNothing. Every order on a flow shares its step keys, so the order id is what keeps two orders apart. A retried step finds its own row instead of writing a second one. refund returns the refund its step already made, and never refunds more than was captured or charged. Stock changes (reserveInventory’s holds and restock) go through the stock_movements ledger, which records each change and applies it to products in one batch, once.
  • Compensations are idempotent. releaseInventory releases only held reservations, voidAuthorizations voids only authorized payments, cancelShipment sets one shipment cancelled. Running one twice changes nothing.

The saga compensations a failed order runs are ROLLBACKS in apps/engine/src/nodes.ts; a cancel runs compensateOrder, which releases, voids and cancels open shipments in one pass. A node type with rollback: true in the catalog must have a ROLLBACKS entry.

To exercise failure paths, set meta.simulate on the order input:

meta.simulate Effect
out_of_stock reserveInventory reports every line short
payment_decline authorizePayment records a declined authorization
charge_fail charge records a failed charge
three_way_mismatch commerce.three_way_match sees an invoice 10% over the order total

Documents go to R2 through storeDocument at orders/<orderId>/<kind>/<name> and are indexed in the documents table (see Data model).

Replenish (apps/engine/src/replenish.ts) runs on the REPLENISH binding’s cron. Its scan stock step lists SKUs where stock - reserved <= reorder_point; if there are any, its start reorder step seeds the templates and starts one order on tpl-reorder (or payload.flowId) for customer cus_internal, with the SKUs in meta.skus. The reorder flow’s ai.forecast node decides the quantities. EngineRpc.runReplenish creates an instance on demand.

State Owner
Live run state of one order (nodes, edges, waits, vars, timeline, chat) Its OrderAgent
Durable progress (memoized step results, sleeps, waits) Its OrderLoop instance
Order index for lists and filters (orders row) D1, written by the agent’s syncIndex
Flows, versions, approvals, back office, webhook outbox, email intake log D1
Invoices, POs, inbound documents and raw inbound email R2

See OrderLoop Workflow and OrderAgent.