DEV Community

Cover image for Let Your Users Talk a Workflow Into Existence (ByteChef Embedded, Part 9)
ByteChef
ByteChef

Posted on Originally published at blog.bytechef.io

Let Your Users Talk a Workflow Into Existence (ByteChef Embedded, Part 9)

Your users rarely know their automation up front. They discover it while they describe it: "when a deal closes... actually only enterprise deals... and loop in the account manager... and skip weekends."

A template can't handle that. A drag-and-drop canvas scares half of them off. So in part nine of the ByteChef Embedded series, we give them a chat: they describe what they want, and ByteChef's workflow agent builds it in their account while they watch.

On your side, it's one React component, one callback and a button.

TL;DR

  • EmbeddedWorkflowChat takes the connected user's JWT, starter suggestions, an optional system prompt, and an onWorkflowReady callback.
  • On mount it creates an empty draft workflow. The agent edits that draft directly, turn by turn.
  • Every turn streams over AG-UI from POST /automation/workflows/{uuid}/copilot/chat.
  • When the agent needs something, it renders a card in the thread: a question, an option picker, a connection picker, or a Connect button.
  • After the first turn, onWorkflowReady(uuid) fires and you can send the user to the editor.

Where chat fits

In the embedded sample app, open Automations. The split button lists every way to start a workflow:

Sample app Workflows page with the create menu open, listing New from Template, New from Embedded Workflow Builder, New from Prompt, New from Chat and New from Custom Workflow Builder

Option The user... Best when
New from Template (part 8) Picks a workflow you built You know the common automations
New from Embedded Workflow Builder Drags nodes on ByteChef's canvas The user likes visual builders
New from Prompt Writes one description, gets one result The request is short and clear
New from Chat (this part) Talks it through with an agent The request is fuzzy, or the user doesn't know the apps' vocabulary
New from Custom Workflow Builder (part 7) Fills in a UI you designed You want full control

Chat is the most forgiving option. Your users don't need to know Slack's action is called Send Channel Message or that a schedule trigger has a dayOfWeek property. The agent knows. When it isn't sure what they mean, it asks.

The page

Click New from Chat and you land on /automations/chat:

New from Chat page with a collapsed System prompt (optional) field, the heading How can I help you today?, a message box and four suggestion chips

Three parts:

  1. A collapsed System prompt (optional) field where you, the integrator, add house rules.
  2. The chat, centered while empty.
  3. Suggestion chips. Clicking one sends it as the first message, so a blank chat isn't intimidating.

Once the conversation starts, the composer docks to the bottom. After the first turn, an Open workflow button appears top right.

Mounting the component

Here's app/automations/chat/page.tsx, trimmed:

export default function GenerateFromChatPage() {
    const [jwtToken, setJwtToken] = useState<string | null>(null);
    const [systemPrompt, setSystemPrompt] = useState('');
    const [workflowUuid, setWorkflowUuid] = useState<string | null>(null);

    const router = useRouter();

    useEffect(() => {
        getToken().then(setJwtToken);
    }, []);

    return (
        <div className="flex h-screen w-full flex-col p-4">
            <div className="mb-3 flex items-center justify-between gap-4">
                <details className="flex-1 text-sm">
                    <summary>System prompt (optional)</summary>

                    <Textarea
                        onChange={(event) => setSystemPrompt(event.target.value)}
                        placeholder="e.g. Always prefer Slack over email; keep workflows under five steps."
                        value={systemPrompt}
                    />
                </details>

                {workflowUuid && (
                    <Button onClick={() => router.push(`/automations/${workflowUuid}`)}>Open workflow</Button>
                )}
            </div>

            <div className="min-h-0 flex-1">
                {jwtToken && (
                    <EmbeddedWorkflowChat
                        baseUrl={BYTECHEF_APP_BASE_URL}
                        environment={BYTECHEF_ENVIRONMENT}
                        jwtToken={jwtToken}
                        onWorkflowReady={setWorkflowUuid}
                        suggestions={[
                            {
                                title: 'Search for an action',
                                label: 'that can send an email',
                                action: 'Search for an action that can send an email',
                            },
                            // ...
                        ]}
                        systemPrompt={systemPrompt}
                    />
                )}
            </div>
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

getToken() asks the sample's back-end to sign a connected-user JWT, the same as in part one. The chat waits for the token, because everything it does is on behalf of that user.

Props

Prop Type What it does
jwtToken string (required) The connected user's JWT, sent as Authorization: Bearer ...
baseUrl string Your ByteChef instance. Defaults to https://app.bytechef.io
environment `'DEVELOPMENT' \ 'STAGING' \
{% raw %}suggestions {title, label, action}[] Starter chips. action is the message that gets sent
systemPrompt string Your house rules, applied to every turn
onWorkflowReady (workflowUuid: string) => void Called once, after the first turn
className string Extra classes for the container

The component fills its parent's height, so give the parent one (the sample uses min-h-0 flex-1 inside an h-screen column).

It's source, not a package

Unlike useConnectDialog or EmbeddedWorkflowBuilder, the chat isn't an npm export. It lives in the sample app under front-end/src/components/embedded-workflow-chat/, and you copy the folder:

embedded-workflow-chat/
├── index.ts
├── EmbeddedWorkflowChat.tsx           # the public component
├── EmbeddedCopilotRuntimeProvider.tsx # AG-UI agent and event handling
├── workflow-chat-thread.tsx           # messages, composer, chips
├── dataComponents.tsx                 # interactive cards
├── store.ts                           # zustand transcript store
├── api.ts, constants.ts               # skeleton workflow, endpoints
├── chat-config-context.tsx            # JWT, base URL, environment for cards
├── EmbeddedChatBoundary.tsx           # error boundary
└── assistant-ui/                      # markdown, tool card, reasoning renderers
Enter fullscreen mode Exit fullscreen mode

That's on purpose. A chat is the most visible thing in your product, so it should look like your product, not ours. It's built on assistant-ui primitives with Tailwind, so restyling is ordinary React work.

Dependencies: @ag-ui/client, @assistant-ui/react, @assistant-ui/react-markdown, zustand, lucide-react, tailwind-merge, and @bytechef/embedded for the ConnectDialog.

On mount: a skeleton workflow

The agent doesn't generate a workflow and hand it back at the end. It edits a real one, in place, during the conversation. So before the user types anything, the component creates it:

export const SKELETON_WORKFLOW_DEFINITION = JSON.stringify({
    description: '',
    inputs: [],
    label: 'New Workflow',
    tasks: [],
    triggers: [],
});

const response = await fetch(`${baseUrl}/api/embedded/v1/automation/workflows`, {
    method: 'POST',
    headers: {
        Authorization: `Bearer ${jwtToken}`,
        'Content-Type': 'application/json',
        'X-Environment': environment,
    },
    body: JSON.stringify({definition: SKELETON_WORKFLOW_DEFINITION}),
});

const workflowUuid = (await response.text()).replace(/"/g, '').trim();
Enter fullscreen mode Exit fullscreen mode

The draft lands in the connected user's personal project for that environment. While the request runs, the component shows Starting.... If it fails (expired JWT, wrong baseUrl), the error shows in red instead of the chat.

That UUID anchors the whole conversation. A ref latch makes sure only one skeleton is created, even under React StrictMode.

One thing to plan for: every visit creates a draft, even if the user leaves without typing. More on that in Hardening.

On each turn: AG-UI streaming

When the user sends a message, the component posts the conversation with an AG-UI HttpAgent:

const agent = new HttpAgent({
    agentId: 'workflow_editor',
    url: `${baseUrl}/api/embedded/v1/automation/workflows/${workflowUuid}/copilot/chat`,
    headers: {
        Authorization: `Bearer ${jwtToken}`,
        'X-Environment': environment,
    },
    threadId: conversationId,
});

agent.setState(
    trimmedSystemPrompt
        ? {additionalSystemPrompt: trimmedSystemPrompt, mode: 'BUILD', workflowUuid}
        : {mode: 'BUILD', workflowUuid}
);

await agent.runAgent({runId: randomUUID()}, subscriber);
Enter fullscreen mode Exit fullscreen mode

Of that state, the server uses only the system prompt. It rebuilds everything else itself (see What the agent can and can't do).

The response is a text/event-stream. The subscriber turns events into UI:

AG-UI event What the user sees
TEXT_MESSAGE_START, _CONTENT, _END An assistant message, streamed token by token
TOOL_CALL_START, _ARGS, _END A tool card (e.g. searchTasks, updateWorkflow) with its arguments filling in live
TOOL_CALL_RESULT The card gets its result and a success or error icon. Interactive results add a card below
RUN_ERROR A red callout, e.g. The copilot run failed. Reference: 3f2b...
RUN_FINISHED The turn is over. The first time, onWorkflowReady fires

The server closes the open text message whenever the agent calls a tool, so a turn reads in the order it happened: "Let me find the right trigger", the tool cards, then the next message.

Errors users can act on

When a run fails for a reason the user can do something about, ByteChef sends a fixed sentence plus the run ID:

Cause What the user reads
No AI provider for the environment No AI provider is set up for this environment, so the chat can't answer.
Rate limiting The AI provider is rate limiting requests right now. Try again in a minute.
Overloaded The AI provider is overloaded right now. Try again in a minute.
Bad API key The AI provider rejected its API key. Ask an administrator to check it.
Context too long This conversation is too long for the AI model. Start a new chat.

Anything else reads The copilot run failed. Reference: .... The text is fixed, never the exception message, because exceptions can name tables, internal hosts or keys. The full cause goes to the server log under the same run ID. When a user reports a failure, ask for the reference and grep for it.

The controls users expect

  • Stop aborts the run.
  • Edit on a user message rewinds and reruns from there.
  • Refresh regenerates the last assistant message.
  • Copy and Export as Markdown under assistant messages.

Edit and Refresh rewind the agent too, not just the screen. The agent's memory lives on the server, keyed to the user, the workflow and the conversation. Before each run, ByteChef trims that memory back to the last user message both sides agree on. The server only ever removes messages this way. It never takes the client's word for what was said.

Interactive cards: when the agent needs the user

A text-only agent would ask "which channel? please type its exact name" and hope. Instead, some tools return a small JSON envelope with a kind, and the chat renders a card for it:

{
    "kind": "create-connection",
    "componentName": "slack",
    "componentLabel": "Slack"
}
Enter fullscreen mode Exit fullscreen mode

Here the agent looked up the trigger, then stopped to ask which time zone "9am" means:

Embedded chat after the first prompt: getTaskDefinition and getTaskProperties tool cards, the agent asking for two pieces of info, and a Timezone question card with America/New_York, America/Chicago, America/Denver and America/Los_Angeles

The sample maps four kinds in dataComponents.tsx:

kind Card On click
ask-user-question Multiple-choice questions, one at a time (Question 1 of 3, Previous) Answers go back as "User picked: ..."
select-property-option Searchable list of valid values, like Slack channels or Google Sheets The label and real value go back, and the agent writes the value into the workflow
select-connection Searchable list of the user's connections for an app Name and ID go back. With one connection, it's picked automatically
create-connection A Connect Slack button Opens the ConnectDialog. When done, the card shows Connected Slack

Three of them do nothing on the server except hand the question to the browser. The property picker does a bit more: it fetches the property's real options first, using a connection the user may use.

Embedded chat stopped at a searchable Slack channel picker listing random, general, testing, sales-team and more

When the agent shows a card, its turn ends. It doesn't guess and carry on. It waits. The user's pick goes back as an ordinary message, which starts the next turn with exactly what the agent asked for. Answered cards collapse to a short summary with a green check (Picked: #standup), so the thread stays readable.

Connect, right in the conversation

This is where the whole series comes together. The user asks for a Slack reminder but hasn't connected Slack. The agent calls createConnection, and the chat shows Connect Slack. Clicking it:

  1. Calls GET /api/embedded/v1/integrations with the user's JWT and finds the integration whose componentName is slack.
  2. Opens the ConnectDialog from part one via useConnectDialog.
  3. Watches the dialog's portal and marks the card connected when it closes.

The user goes through the same OAuth or API-key flow as on your Integrations page, without leaving the chat. Then the agent picks the new connection with select-connection.

createConnection is signaling only. The agent never sees or stores credentials, and the tool call creates nothing on the server. If the app isn't one of your integrations, the card says No integration is available for Slack.

Your house rules: the system prompt

The user decides what the workflow does. You decide how it's built.

New from Chat page with the System prompt open: Always notify the team in Slack, never by email. Keep workflows under five steps.

Good system prompts encode decisions your users shouldn't have to make:

  • Always notify the team in Slack, never by email.
  • Keep workflows under five steps.
  • Use the Europe/Berlin time zone for every schedule unless the user names another.
  • Never delete CRM records; update a status field instead.

How ByteChef treats it:

  • Read every turn. Change it mid-conversation and the next turn picks it up.
  • Trimmed and capped at 4,000 characters on the server.
  • Can't override the core rules. It's appended under a heading that marks it as additional instructions from the integrating application, applied only where it doesn't conflict with the build rules, the workflow-definition contract or safety constraints. Your rules steer the agent. They can't turn it into something else.

In the sample it's a text box so you can experiment. In production it belongs in your code: build it from your product's conventions and what you know about the user (plan, time zone, approved apps).

What the agent can and can't do

The chat is a public surface. It runs with the connected user's JWT, and the browser controls the request body. So the copilot endpoint trusts none of the AG-UI state it receives. On every turn, ByteChef:

  • Checks the workflow belongs to the user. The {workflowUuid} must be in the user's own project for that environment.
  • Rebuilds the agent state from scratch. It keeps only the additional system prompt, then sets the rest itself: BUILD mode, non-autonomous, the verified workflow ID, the user's project, the environment from X-Environment (PRODUCTION if missing), and a conversation key built from tenant, user and workflow. Editing the request can't switch modes, change workspaces or read someone else's conversation.
  • Keeps the agent inside the user's project. Every workflow tool checks ownership, and project-level changes are off limits. Talk the model into opening another workflow and the tool refuses: "This chat can only work with the projects and workflows of its own project."
  • Allows only the user's own connections. The agent sees only connections the user owns or was given, and every connection ID it binds or queries is checked against that list: "This chat can only use connections its user is entitled to use." No borrowing another customer's Slack.
  • Limits the agent to your catalog. It can use only enabled integrations for that environment, plus connection-free components (schedules, conditions, loops, data mappers). Anything else doesn't show up in search, can't be looked up, and is rejected anywhere in a saved workflow, including inside conditions, loops and AI agent nodes.

That last one is easy to miss. If you offer HubSpot and Slack but not Salesforce, a user asking for "Salesforce to Airtable" gets a conversation about what's possible, not a workflow that could never run:

The agent explains Salesforce and Airtable aren't available, suggests a webhook or a schedule with the HTTP client instead, and asks which approach to take

Handing off the result

The chat produces a real ByteChef workflow, so it's not a dead end. After the first turn, the component calls onWorkflowReady with the UUID from mount:

<EmbeddedWorkflowChat
    // ...
    onWorkflowReady={setWorkflowUuid}
/>

{workflowUuid && (
    <Button onClick={() => router.push(`/automations/${workflowUuid}`)}>Open workflow</Button>
)}
Enter fullscreen mode Exit fullscreen mode

When the build is done, the agent says what it made:

End of the build turn: validateWorkflow, updateWorkflow and saveWorkflowTestConnection tool cards, then a summary: an Every Day trigger Monday to Friday at 9:00 AM America/New_York and a Send Channel Message action posting Standup in 5 minutes to #general

Two things about the callback:

  • It fires once, after the first turn, not when the workflow is "done". That turn may have built three steps or only asked a question. The UUID never changes and the agent keeps updating the same workflow, so opening it later always shows the latest. Read it as "there's a workflow worth linking to now".
  • It's a callback, so you decide what it means. A button, a live preview next to the chat, a Save button, an analytics event.

/automations/{uuid} renders the same EmbeddedWorkflowBuilder from part eight. Chat to draft, canvas to polish.

From draft to running

A chat-built workflow is a draft. Nothing runs until it's published and enabled, which is the right default for something an AI just wrote. The calls from part seven take it live:

// Freeze the current definition as the first version
await fetchWithAuth(`/api/embedded/v1/automation/workflows/${workflowUuid}/publish`, {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({description: 'Created from chat'}),
});

// 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

In the sample the user does this by hand: Publish in the editor, then the toggle on the Automations list. A more guided product could add an Activate button under the chat.

Prerequisites

Your ByteChef instance needs:

  1. The copilot enabled: bytechef.ai.copilot.enabled=true. Without it, /copilot/chat doesn't exist.
  2. An AI provider configured. The run's environment decides which settings apply.
  3. Integrations enabled for the environment. With none, the agent can still build schedules and logic, but nothing that talks to an app.

Embedded workflows and the copilot endpoint are Enterprise Edition features.

Hardening the demo for production

The sample is intentionally small. Before you ship:

  • Clean up abandoned drafts. Create the skeleton on the first message, or DELETE /automation/workflows/{uuid} untouched drafts when the user leaves.
  • Write suggestions for building. The sample's chips come from ByteChef's editor copilot, where a workflow already exists. On an empty one, try "Post a Slack message every Monday at 9:00" or "When a HubSpot deal closes, create a Jira ticket".
  • Own the system prompt. Generate it in code. Don't hand end users a text box.
  • Pass environment explicitly. It defaults to PRODUCTION, and a JWT signed for another environment is rejected when the skeleton is created.
  • Decide what "ready" means. For a stricter signal (a trigger exists, no open issues), fetch the workflow when the callback fires and check.
  • Support multi-select questions. The sample renders every question as single-select and ignores the multiSelect flag for now.
  • Fix the error boundary. EmbeddedChatBoundary hides a harmless assistant-ui unmount error, but renders nothing for any other crash. Replace that null with a fallback and report the error.
  • Add a New chat button. Remount the component (change its key) to get a fresh skeleton, a new conversation and an empty agent memory.
  • Surface the error reference. Make the run ID easy to copy and teach support to search logs for it.

Who owns what

  • You own where the chat appears, how it looks (it's your source code), the suggestions, the system prompt, which integrations exist, and what happens when a workflow is ready.
  • ByteChef owns the agent and its tools, the AG-UI stream, component search and property lookups, validation, per-user storage, the security boundary around every turn, the ConnectDialog and credentials, and the engine that runs the result.

Your code is one component, one callback and a button. Behind it is an agent that knows every action in your catalog and can turn "remind my team about standup every weekday" into a scheduled Slack workflow on the user's own account.

The series so far

  1. Connect your users' apps (the JWT and ConnectDialog everything rests on)
  2. Act on connected apps directly with the ComponentKit
  3. Chat with an AI assistant that uses the ComponentKit's tools
  4. Trigger workflows from your product's events
  5. Call a workflow like a synchronous API
  6. Agent over MCP: the same tools, discovered through an open protocol
  7. Author workflows in a builder you designed
  8. Start from a template you curated
  9. Talk a workflow into existence (this part)

Parts three and nine both put an AI chat in your product, but they do opposite jobs. The ComponentKit chat acts on the user's apps right now: it sends the email, creates the ticket. New from Chat builds something that acts later, on its own, every time the trigger fires. One is an assistant. The other is an automation author.

Try it: enable the copilot and an AI provider in ByteChef, run the embedded sample app, and open Automations → New from Chat. Ask for a weekday Slack reminder and watch it build.

Originally published on the ByteChef blog. ByteChef is an open-source workflow automation and AI agent platform: github.com/bytechefhq/bytechef.

Top comments (0)