DEV Community

ByteChef
ByteChef

Posted on Originally published at blog.bytechef.io

ByteChef Embedded, Part 7: New from Custom Workflow Builder

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/workflows creates 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.

Sample app create menu listing New from Template, New from Embedded Workflow Builder, New from Prompt, New from Chat and New from Custom Workflow Builder

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.

Custom Workflow Builder with a Gmail trigger, a condition and a Slack action next to the live workflow-definition JSON

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 */],
  },
];
Enter fullscreen mode Exit fullscreen mode

Three things make this more than a list:

  • name values are ByteChef's. googleMail, newEmail, sendEmail end up in each node's type. Labels can say anything. Names must match.
  • key values are ByteChef property names. to, subject, body are Send Email's real inputs.
  • kind controls serialization. stringArray turns "a@x.com, b@x.com" into an array. hidden never shows in the form and always sends a fixed value, like bodyType: "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.

Condition card checking if the subject contains invoice, with a Slack action under If true and a Gmail action under If false

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)
}
Enter fullscreen mode Exit fullscreen mode

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."
            }
          }
        ]
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

That's the whole contract. Read it once and you can generate it from any UI:

  • triggers and tasks are arrays of nodes with a unique name, a label, a type and parameters.
  • type is component/version/operation. Flow controls drop the operation: condition/v1.
  • Flow controls nest. caseTrue and caseFalse are task arrays, so the JSON mirrors your UI's branches. conditions is a list of OR groups, each a list of AND comparisons.
  • Parameters are typed. to is an array because the field was stringArray. 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}, ...]
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
  • definition is 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.

Custom Workflow Builder showing Workflow saved successfully with a View workflow link

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.

ByteChef embedded editor showing the saved workflow with the condition, TRUE and FALSE branches, marked V1 DRAFT

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.

Slack node in ByteChef editor with a required connection, a Create Connection button and one issue flagged

From draft to running

Saving creates a DRAFT. Two more calls make it run:

Workflows list with Route invoice emails marked DRAFT and its enable toggle disabled

// 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'});
Enter fullscreen mode Exit fullscreen mode

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} and PUT /automation/workflows/{uuid} for editing. Re-bind connections after structural edits.
  • DELETE /automation/workflows/{uuid} to remove.
  • GET /automation/workflows to 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:

  1. Validate before saving, including required fields the catalog skips.
  2. Store node names on steps so bindings and edits stay stable.
  3. Offer data pills, not expressions. Generate ${trigger_1.subject} from a field picker.
  4. Handle missing connections in your UI by opening the ConnectDialog right there.
  5. 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)