Dual-engine architecture
This page is for you if you want to understand why Integration Studio is built the way it is — typically because you're evaluating it for a project, planning a non-trivial integration, or trying to figure out why a workflow you wrote behaves the way it does. If you just want to build a workflow, Workflow Builder is the more useful entry point.
The short version: Integration Studio runs the same workflow definitions in two different execution modes. You pick the mode when you deploy, based on whether the workflow needs durability (the async engine) or low latency (the sync engine, in-process). The node library is shared between both modes — the same HTTP, JSON Transform, Filter, and Switch nodes work in either one.
| Type | Typical use | Engine | Duration | Runtime overhead |
|---|---|---|---|---|
| Async / durable | Nightly imports, scheduled syncs, multi-step batch jobs | Async (durable) | seconds to hours | event history persisted per step |
| Sync / real-time | Live price checks, inventory queries, webhook responses | Sync (in-process) | sub-100ms | none |
The interesting question is why two engines, and the rest of this page answers it.
Why one engine isn't enough
The async engine is excellent at what it's built for: long-running, durable workflows that survive crashes and pick up exactly where they left off. If that were the only thing Integration Studio had to do, there would be one engine and this page would be much shorter. But two requirements that come up constantly in commerce integrations push against that design.
The first is latency. Starting a durable workflow isn't free — the run is queued and its event history is persisted before any of your business logic runs, which adds 10–50ms of overhead up front. For a nightly import that's noise, but for a live price check with a 100ms budget, spending half of it before the first HTTP call even fires is a non-starter.
The second is runtime-configurable workflows. Durable workflows replay their history to recover, so they expect a stable, versioned code path. Integration Studio's premise is the opposite: a workflow definition is data, injected at runtime with arbitrary steps in an arbitrary order. That doesn't fit a replay model that assumes the code path stays the same every time a workflow is recovered.
The answer to both problems is the same — split the work into two execution paths and back both of them with one shared connector library. You get durability where durability matters, and in-process speed where speed matters, without writing your integration logic twice.
The solution: dual engines, one connector library
Architecture overview
Integration Definition (JSON in DB)
─────────────────────────────────────
{
"id": "erp-price-sync",
"mode": "sync" | "async",
"triggers": ["webhook", "schedule"],
"steps": [
{ "connector": "http-request", "config": { "url": "{{env.ERP_URL}}/prices" } },
{ "connector": "json-transform", "config": { "mapping": "..." } },
{ "connector": "http-request", "config": { "method": "POST", "url": "{{env.INTERNAL_API}}/prices" } }
]
}
↓
┌────┴────┐
│ │
mode=async mode=sync
│ │
↓ ↓
┌─────────┐ ┌──────────────┐
│Async │ │Sync Executor │
│(durable)│ │(in-process) │
└────┬────┘ └──────┬───────┘
│ │
└──────┬──────┘
↓
┌───────────────────────┐
│ Connector Library │
│ HTTP · JSON Transform │
│ Filter · Switch · … │
└───────────────────────┘
Async engine (durable)
The async path handles long-running, durable integrations. Notice that there's exactly one workflow function — a generic loop that walks the steps in a definition and executes each connector in turn. It's written once and reused for every async integration; the actual behaviour comes entirely from the definition you pass in.
// Generic workflow (once, reused for all async integrations)
async function GenericIntegrationWorkflow(definition: IntegrationDef) {
for (const step of definition.steps) {
const connector = getConnector(step.connector)
const result = await connector.execute(step.config)
}
}
// Usage: look up config, then submit to the durable engine
const config = await db.integrations.findById('erp-price-sync')
await runtime.startWorkflow(GenericIntegrationWorkflow, { config })
This path is durable. Workflow state is persisted across failures, so a worker crashing mid-run doesn't lose progress — another worker resumes from the last completed step. Failed steps retry automatically with exponential backoff, long-running steps send heartbeats to prove they're still alive, and every step is recorded in an event history that gives you a complete audit trail. Each workflow can also carry its own configurable timeout.
Sync engine (in-process)
The sync path handles real-time, low-latency integrations. It runs the same connectors, but the execution model is just a plain HTTP handler: it loads the definition, feeds the request body through each step in sequence, and returns the result inline.
// Same connectors, different execution model
app.post('/integrations/:id/run', async (req, res) => {
const config = await db.integrations.findById(req.params.id)
let data = req.body
for (const step of config.steps) {
const connector = getConnector(step.connector)
data = await connector.execute(step.config, data)
}
res.json({ result: data })
})
This path trades durability for speed. Because it runs in the same process as the request handler, there's no queue polling and no gRPC roundtrip — the workflow executes inline and its result comes straight back in the HTTP response. To stay resilient under load it supports a configurable circuit breaker, and it can fall back to stale cached data when an upstream system times out, degrading gracefully instead of failing outright.
Connectors: the shared library
If the two engines are the how, connectors are the what. A connector is a pre-built, reusable unit of work that does exactly one job — make an HTTP request, transform some JSON, filter an array — and knows nothing about which engine is running it. That's the piece that makes the dual-engine design pay off: write a connector once, and it works identically in a durable async workflow and in a sub-100ms sync call. In the visual builder these same connectors show up as the nodes you drag onto the canvas; "connector" is the runtime term, "node" is the editor term for the same thing.
The current connectors
| Connector | Type | Purpose |
|---|---|---|
http-request | Action | GET/POST to any HTTP endpoint |
send-email | Action | Transactional email (SMTP) |
json-transform | Transform | Reshape data with jq-like expressions |
filter | Transform | Filter arrays/objects by condition |
switch | Control | Conditional routing based on data |
regex-extract | Transform | Extract substrings using regex |
download | Action | Download file from URL |
upload | Action | Upload file to storage (S3) |
iterator | Control | Loop over array items |
webhook-trigger | Trigger | HTTP webhook entry point |
A connector up close: HTTP Request
The http-request connector is the one you'll use most, so it's a good example of the general shape. Every connector is configured by a small JSON block — a connector name and a config object whose fields are specific to that connector. Here the config carries the method, URL, headers, and body of the request:
{
"connector": "http-request",
"config": {
"method": "POST",
"url": "https://api.example.com/orders",
"headers": { "Authorization": "Bearer {{secrets.API_KEY}}" },
"body": "{{ $ }}", // Pass entire input
"timeoutMs": 30000
}
}
It takes the upstream workflow data as its input and produces one of two outputs: a response object (status, headers, body) on success, or an error on a non-2xx status, timeout, or network failure. That input-in, output-or-error-out contract is the same for every connector, which is what lets you chain them together without special-casing each one.
Expanding the library
New connectors are built and deployed by the platform team, not by integration developers — that's deliberate, because it keeps the runtime surface small and audited. Once a connector ships, your integration definitions reference it like any other, with no workflow code to change on your side:
// Example: hypothetical SOAP connector (not yet available)
{
"connector": "soap-request",
"config": { "endpoint": "...", "method": "..." }
}
Triggers: how integrations start
Every workflow needs something to kick it off, and there are three options. They differ mainly in who starts the run and when — an external system reacting to an event, the clock, or a person in Cockpit. Note that Schedule triggers only make sense on the async engine, since there's no caller waiting on a response. The Triggers page covers each in depth; the table below is the quick comparison.
| Trigger | Type | Async | Sync | Use Case |
|---|---|---|---|---|
| Webhook | Event-driven | ✓ | ✓ | External system calls you (order event, file upload) |
| Schedule | Time-based | ✓ | ✗ | Run at specific times (nightly import, hourly sync) |
| Manual | User-initiated | ✓ | ✓ | Customer clicks "Sync Now" button in Cockpit |
Expression syntax: dynamic parameters
The steps in a workflow aren't isolated — a later step almost always needs a value that an earlier step produced, plus secrets and configuration that shouldn't be hard-coded. Expressions are how you wire those values in. Anywhere a connector's config takes a value, you can write a {{ ... }} expression instead of a literal, and the engine substitutes the real value at runtime:
{
"connector": "http-request",
"config": {
"url": "https://erp.example.com/prices",
"headers": {
"Authorization": "Bearer {{secrets.ERP_API_KEY}}",
"X-Tenant": "{{steps.0.outputs.tenant_id}}"
},
"body": {
"articles": "{{steps.0.outputs.articles}}"
}
}
}
Available Variables:
{{secrets.KEY}}— Secret reference (encrypted){{steps.N.outputs.FIELD}}— Output from step N{{$}}— Entire input data{{env.VAR}}— Environment variable (read at workflow start)
A worked example: ERP price sync
It helps to see all of this in one place. The integration below keeps the catalog's prices in step with a customer's ERP. It's a textbook async case: it runs on a schedule, it touches a system that's occasionally slow or down, and nobody is sitting waiting on the result — so durability matters far more than latency.
The whole thing is three steps: pull the current prices from the ERP, reshape them into the format the internal catalog expects, and push them in. Read it top to bottom and you can see how the pieces from the rest of this page fit together — the mode chooses the engine, the schedule is the trigger, each entry in steps names a connector, and the expressions wire secrets and step outputs into the requests.
The definition (stored in the database)
{
"id": "erp-price-sync",
"mode": "async",
"triggers": ["schedule"],
"schedule": "0 */6 * * *", // Every 6 hours
"steps": [
{
"name": "fetch-prices",
"connector": "http-request",
"config": {
"method": "GET",
"url": "{{secrets.ERP_ENDPOINT}}/api/v1/prices",
"timeoutMs": 60000
}
},
{
"name": "transform",
"connector": "json-transform",
"config": {
"mapping": {
"sku": "$.articles[].supplier_aid",
"price": "$.articles[].price"
}
}
},
{
"name": "push-prices",
"connector": "http-request",
"config": {
"method": "POST",
"url": "{{env.INTERNAL_API}}/catalog/prices/bulk-upsert",
"headers": {
"Authorization": "Bearer {{secrets.INTERNAL_API_TOKEN}}"
},
"body": "{{steps.1.outputs}}"
}
}
]
}
What happens at runtime
When the clock hits a scheduled time, the run unfolds as a fixed sequence:
- The schedule fires at 12:00 UTC.
- The async engine starts
GenericIntegrationWorkflowwith this definition. - Step 1 does an HTTP GET against the ERP, allowing up to 60 seconds.
- Step 2 transforms the response into
[sku, price]pairs. - Step 3 POSTs them to the internal catalog API, tenant-scoped via the auth token.
- The run is recorded as a success, with the full step-by-step audit trail.
- The next run is queued for 18:00 UTC.
When something fails
This is exactly where the async engine earns its keep. If the ERP is unreachable in step 1, the engine retries with exponential backoff — up to five attempts — without you writing any retry logic. Only if every attempt is exhausted does the workflow get marked as failed, at which point it surfaces in Cockpit with the full error context. From there the customer can read the details and trigger a manual retry once the ERP is back. The price sync simply catches up on its next run; nothing is silently lost.
What this design buys you
A few principles fall out of the dual-engine approach, and they're worth stating plainly because they shape how you work day to day.
The foundational one is the separation of code from configuration. Connectors are code, owned by the platform team; integration definitions are configuration, owned by you. Since a new integration is just a new definition, you ship one by changing config rather than redeploying code — which is precisely why a partner can build and adjust workflows without waiting on a platform release.
That same split is what gives you performance at both extremes from one system: durable multi-hour batch jobs and sub-100ms live calls, sharing the identical node library. Everything stays auditable — async runs keep a full step-by-step history, sync runs log request and response — and everything is tenant-scoped, so a workflow's data is automatically scoped to the tenant it runs on. One tenant's workflow can never read or write another tenant's data.
Next Steps
- Workflow Builder — Visual canvas and execution modes
- Node Types — All 10 current nodes + roadmap
- Triggers — Webhook, schedule, manual
- Expression Syntax — Variables, secrets, data flow