Most embedded workflow tools give you two choices: embed their editor, or don't offer automations at all. There's a third option. Build the editor yourself, and let the platform run whatever it produces.
This post walks through the Custom Workflow Builder in the ByteChef embedded sample app. It's a small React page that lets users build "when X happens, if Y, do Z" rules in your own UI. ByteChef never draws a pixel of it. It only sees the JSON the page produces.
TL;DR
- Builder state lives in plain React
useState. - A pure function renders that state into ByteChef's workflow-definition JSON on every keystroke.
-
POST /automation/workflowscreates the workflow in the connected user's project. -
PUT .../workflow-nodes/{node}/connection/{component}binds each node to the user's own connection. - The result is an ordinary ByteChef workflow. You can open it in ByteChef's editor, publish it, enable it, and it runs on ByteChef's engine.
Why not just embed the full editor?
ByteChef's own editor can be embedded, and it's powerful: canvas, hundreds of components, data pills, loops, branches, a test runner.
That's exactly what some products don't want. If your users are "set up a rule" people rather than "build a flow" people, you want a narrow, opinionated form in your design system:
When an email arrives, if the subject says invoice, post to #finance.
The Custom Workflow Builder shows how small that layer can be.
The builder in three files
No graph library, no drag and drop, no canvas. Just form cards stacked top to bottom: a Trigger card, then steps you add with Add Action and Add Condition. A live panel on the right shows the exact JSON the page will send.
| File | Job |
|---|---|
catalog.ts |
Which components, triggers, actions and fields your builder offers |
definition.ts |
Builder state types, plus a pure function that renders state into ByteChef JSON |
page.tsx |
The UI, plus the save flow that calls the embedded API |
Let's follow the data through each one.
1. The catalog is your product surface
ByteChef has hundreds of components. Your builder doesn't have to offer them. catalog.ts is a hardcoded list of exactly what this product supports, Gmail and Slack, with only the fields worth asking for:
export const COMPONENTS: CatalogComponent[] = [
{
name: 'googleMail',
label: 'Gmail',
triggers: [{name: 'newEmail', label: 'New Email', fields: []}],
actions: [
{
name: 'sendEmail',
label: 'Send Email',
fields: [
{key: 'to', label: 'To (comma-separated)', kind: 'stringArray', required: true},
{key: 'subject', label: 'Subject', kind: 'string', required: true},
{key: 'bodyType', label: '', kind: 'hidden', defaultValue: 'TEXT'},
{key: 'body', label: 'Body', kind: 'string', required: true},
],
},
],
},
{
name: 'slack',
label: 'Slack',
triggers: [{name: 'anyEvent', label: 'Any Event', fields: []}],
actions: [/* sendChannelMessage, sendDirectMessage, addReaction */],
},
];
Three things make this more than a list:
-
namevalues are ByteChef's.googleMail,newEmail,sendEmailend up in each node'stype. Labels can say anything. Names must match. -
keyvalues are ByteChef property names.to,subject,bodyare Send Email's real inputs. -
kindcontrols serialization.stringArrayturns"a@x.com, b@x.com"into an array.hiddennever shows in the form and always sends a fixed value, likebodyType: "TEXT".
This is where your product's opinions live. Hide delete actions. Fix the email format. Rename "Send Channel Message" to "Notify the team". ByteChef doesn't need to know.
2. Keep state in your shape
The example workflow: Route invoice emails. When a new Gmail message arrives, post it to Slack if the subject mentions an invoice, otherwise send an auto-reply.
The condition compares ${trigger_1.subject} against invoice with contains. That ${...} is ByteChef's reference syntax. trigger_1 is the trigger node's name, subject is a field the Gmail trigger outputs. The builder doesn't evaluate any of it. It passes strings through, and ByteChef resolves them at run time.
All of it lives in one useState:
interface BuilderState {
label: string;
description: string;
trigger?: TriggerState; // componentName, operation, parameters, connectionId
steps: StepState[]; // ActionStepState | ConditionStepState (caseTrue / caseFalse)
}
The state is your shape, not ByteChef's. Connection IDs sit next to parameters, steps have client-side IDs for React keys, branches are plain arrays. You convert only at the edge.
3. Render the workflow definition
buildWorkflowDefinition(state) in definition.ts is a pure function with no I/O. The page runs it through useMemo on every change, which is why the JSON panel updates as you type. For our example:
{
"label": "Route invoice emails",
"description": "Post invoice emails to #finance, auto-reply to everything else",
"triggers": [
{"name": "trigger_1", "label": "New Email", "type": "googleMail/v1/newEmail", "parameters": {}}
],
"tasks": [
{
"name": "condition_1",
"label": "Condition",
"type": "condition/v1",
"parameters": {
"rawExpression": false,
"conditions": [[
{"type": "string", "value1": "${trigger_1.subject}", "operation": "CONTAINS", "value2": "invoice"}
]],
"caseTrue": [
{
"name": "slack_1",
"label": "Send Channel Message",
"type": "slack/v1/sendChannelMessage",
"parameters": {
"channel": "C07FINANCE1",
"text": "New invoice from ${trigger_1.from}: ${trigger_1.subject}"
}
}
],
"caseFalse": [
{
"name": "googleMail_1",
"label": "Send Email",
"type": "googleMail/v1/sendEmail",
"parameters": {
"to": ["${trigger_1.from}"],
"subject": "Re: ${trigger_1.subject}",
"bodyType": "TEXT",
"body": "Thanks, we got your message and will reply within one business day."
}
}
]
}
}
]
}
That's the whole contract. Read it once and you can generate it from any UI:
-
triggersandtasksare arrays of nodes with a uniquename, alabel, atypeandparameters. -
typeiscomponent/version/operation. Flow controls drop the operation:condition/v1. - Flow controls nest.
caseTrueandcaseFalseare task arrays, so the JSON mirrors your UI's branches.conditionsis a list of OR groups, each a list of AND comparisons. - Parameters are typed.
tois an array because the field wasstringArray. Blank optional fields are omitted.
Gotcha: node names must be stable
Nodes get a per-component counter: slack_1, googleMail_1, condition_1. The condition is named last, after its branch actions, because the code builds branches first.
That order matters. Connection binding (step 5) uses node names from a second function, collectConnectionBindings, which walks state in the same order. If the two ever disagree, a connection lands on the wrong node. In production, assign the node name when the step is created and store it in state.
4. Let users pick their connections
Each card has a Connection dropdown, filled by one call per component:
// GET /api/embedded/v1/components/{componentName}/connections
const connections = await fetchComponentConnections('googleMail'); // [{id, name}, ...]
The user's JWT scopes the call, so each user only sees their own accounts.
Connections are not part of the definition. The definition says what to do. Which account to do it with is stored per node. That separation is what lets one definition run against thousands of users' accounts.
5. Save and bind
const workflowUuid = await createBuilderWorkflow(buildWorkflowDefinition(state));
for (const binding of collectConnectionBindings(state)) {
const response = await bindWorkflowNodeConnection(
workflowUuid,
binding.nodeName, // e.g. "googleMail_1"
binding.componentName, // e.g. "googleMail"
binding.connectionId
);
if (!response.ok) {
failedBindings.push(binding.nodeName);
}
}
Both helpers are thin wrappers around the embedded API. Every request carries the user's JWT in Authorization and the target environment in X-Environment.
| Call | Endpoint | What it does |
|---|---|---|
createBuilderWorkflow |
POST /api/embedded/v1/automation/workflows with {definition}
|
Creates the workflow in the user's project, returns its UUID |
bindWorkflowNodeConnection |
PUT /api/embedded/v1/automation/workflows/{uuid}/workflow-nodes/{nodeName}/connection/{connectionKey} with {connectionId}
|
Attaches one connection to one node |
Worth knowing:
- Each user gets a project automatically. ByteChef creates it the first time it's needed. Your app never manages projects.
-
definitionis a string. Stringified JSON, the same format ByteChef's editor stores. - The connection key is the component name for single-connection components like Gmail and Slack.
- Binding is best-effort. The workflow is already saved, so report which nodes failed instead of failing the whole save.
What ByteChef sees
Click View workflow and ByteChef's own editor opens your JSON as a regular graph: Gmail trigger, condition with TRUE and FALSE branches, Slack and Gmail actions, with your node names.
It's also your safety net. In the demo, the Slack node is left without a connection on purpose. ByteChef flags it with a required-connection warning and a Create Connection button. Same for Gmail's New Email trigger, which needs a Pub/Sub Topic Name the catalog never asks for. ByteChef doesn't guess. It marks it required. Your UI can be as forgiving as you like, and ByteChef still validates the result.
From draft to running
Saving creates a DRAFT. Two more calls make it run:
// 1. Freeze the current definition as a version (V1, V2, ...)
await fetchWithAuth(`/api/embedded/v1/automation/workflows/${workflowUuid}/publish`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({description: 'First version'}),
});
// 2. Turn it on (DELETE to turn it off)
await fetchWithAuth(`/api/embedded/v1/automation/workflows/${workflowUuid}/enable`, {method: 'POST'});
A custom builder can fold both into one Activate button. Once enabled, new mail starts runs on the user's bound connections.
The rest of the lifecycle:
-
GET /automation/workflows/{uuid}andPUT /automation/workflows/{uuid}for editing. Re-bind connections after structural edits. -
DELETE /automation/workflows/{uuid}to remove. -
GET /automation/workflowsto list.
Every endpoint also has a server-to-server form under /{externalUserId}/automation/workflows/..., authenticated by your backend. Use it to provision default automations when a customer signs up.
Before you ship it
The sample is a teaching tool. Close these gaps first:
- Validate before saving, including required fields the catalog skips.
- Store node names on steps so bindings and edits stay stable.
-
Offer data pills, not expressions. Generate
${trigger_1.subject}from a field picker. - Handle missing connections in your UI by opening the ConnectDialog right there.
- Grow the catalog on purpose. Start with the few actions your users really need.
Who owns what
- You own the authoring experience: catalog, forms, branching UI, connection picker, wording, and the state-to-JSON function.
- ByteChef owns everything after the JSON: validation, storage, versioning, per-user projects, connection storage and OAuth refresh, trigger registration, and execution.
Even at maximum control, you're not building an iPaaS. You're building a front end. The moment you save, it's a real ByteChef workflow, indistinguishable from one built in ByteChef's editor, copied from a template or generated from a prompt.
Try it
Run the embedded sample app, connect Gmail under Integrations, then open Automations → New from Custom Workflow Builder. Add a step or two, watch the JSON update, save, and click View workflow.
This is part 7 of the ByteChef Embedded series. It was originally published on the ByteChef blog. ByteChef is open source (Apache 2.0) on GitHub.







Top comments (0)