# How to Build a Workflow Automation with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a visual workflow automation engine in Lovable where workflows are defined as a DAG of steps stored in JSONB. An Edge Function execution engine traverses the DAG, runs each step's action (HTTP call, database query, email, condition branch), and records the full run history. Webhook and cron triggers fire workflows automatically.

## Before you start

- Lovable Pro account for multi-function Edge Function generation
- Supabase project with SUPABASE_SERVICE_ROLE_KEY in Cloud tab → Secrets
- pg_cron enabled in Supabase Dashboard → Database → Extensions
- Understanding of directed acyclic graphs (a flowchart with no loops is a DAG)
- Familiarity with async JavaScript — the execution engine uses Promise chains

## Step-by-step guide

### 1. Create the workflow schema

Prompt Lovable to create all tables for workflow definitions, run history, and triggers. The schema supports the execution engine and the UI builder.

```
Create a workflow automation schema in Supabase.

Tables:

1. workflows:
   id uuid primary key default gen_random_uuid()
   name text not null
   description text
   status text default 'draft' — 'draft', 'active', 'paused', 'archived'
   steps jsonb not null default '{"nodes": [], "edges": []}'
   trigger_config jsonb default '{}' — { type: 'webhook'|'cron'|'manual', cron_expression?: string, webhook_secret?: string }
   created_by uuid references auth.users(id)
   last_run_at timestamptz
   run_count integer default 0
   created_at timestamptz default now()
   updated_at timestamptz default now()

2. workflow_runs:
   id uuid primary key default gen_random_uuid()
   workflow_id uuid references workflows(id) on delete cascade
   status text default 'running' — 'running', 'completed', 'failed', 'cancelled'
   trigger_type text — 'webhook', 'cron', 'manual'
   trigger_payload jsonb
   step_traces jsonb default '[]' — array of { node_id, status, started_at, finished_at, input, output, error }
   error_message text
   started_at timestamptz default now()
   finished_at timestamptz
   duration_ms integer

3. workflow_triggers:
   id uuid primary key default gen_random_uuid()
   workflow_id uuid references workflows(id) on delete cascade
   trigger_type text not null
   cron_job_name text
   webhook_path text unique
   is_active boolean default true
   created_at timestamptz default now()

RLS:
- workflows: authenticated users read/write their own (created_by = auth.uid())
- workflow_runs: authenticated users read runs of their own workflows
- workflow_triggers: authenticated users read/write their own workflow triggers

Indexes:
- workflow_runs(workflow_id, started_at DESC)
- workflow_runs(status)

Step node types (stored in steps.nodes[].type):
- 'http_request': { method, url, headers, body_template }
- 'supabase_query': { table, operation, filters, limit }
- 'condition': { expression } — evaluates a JS expression against run_context
- 'delay': { duration_seconds }
- 'send_email': { to_template, subject_template, body_template }
- 'transform': { expression } — transforms data between steps
```

> Pro tip: Add a steps_version integer column to workflows. Increment it on every save. Store a snapshot of the steps JSONB in workflow_runs at the time of execution so old runs always show the exact steps that were run, not the current version.

**Expected result:** All three tables created with RLS. TypeScript types generated. The app shell shows in the preview.

### 2. Build the workflow execution engine Edge Function

Create the core execution engine that traverses the DAG and runs each step type. This is the most complex piece — take time to review the generated code carefully.

```
Create a Supabase Edge Function at supabase/functions/execute-workflow/index.ts.

The function accepts POST with body: { workflow_id: string, run_id: string, trigger_payload: object }

Execution engine logic:

1. Fetch the workflow by workflow_id. Get steps.nodes and steps.edges.
2. Fetch the workflow_runs row by run_id.
3. Build a run_context object: { trigger: trigger_payload, steps: {} }
   - {{trigger.field}} resolves to trigger_payload.field
   - {{steps.nodeId.output.field}} resolves to run_context.steps[nodeId].output.field

4. Find the entry node: the node that has no incoming edges.

5. Process nodes in order. For each node:
   a. Log start: append to step_traces with status='running', started_at
   b. Interpolate all config string values by replacing {{...}} placeholders with run_context values
   c. Execute based on node.type:

   'http_request': fetch(config.url, { method, headers, body: JSON.stringify(interpolated_body) })
     - Output: { status, body: await response.json() }

   'supabase_query': use service role client to call .from(config.table).select/insert/update based on operation
     - Output: { rows, count }

   'condition': evaluate a simple expression (e.g. '{{steps.step1.output.status}} === 200')
     - Replace placeholders, then use a safe evaluator (not eval — use a simple comparison parser)
     - Output: { result: boolean }
     - Next step: follow the edge where condition matches the result

   'delay': await new Promise(resolve => setTimeout(resolve, config.duration_seconds * 1000))
     - Note: max Edge Function timeout is 150s on paid Supabase. For longer delays, schedule a new run.
     - Output: { delayed_ms }

   'send_email': call the send-email Edge Function via fetch (if it exists) or direct Resend API
     - Output: { message_id }

   d. Store output in run_context.steps[node.id] = { output }
   e. Update step_traces with status='completed', finished_at, output
   f. On error: update step_trace with status='failed', error message. Set run status='failed'.

6. Find the next node by looking up edges where from = current_node_id
   - For condition nodes: filter edges by edge.condition matching the output.result
   - For all other nodes: take the single outgoing edge

7. When no more nodes to process: update workflow_runs.status='completed', finished_at, duration_ms
8. Update workflows.last_run_at and increment run_count
```

> Pro tip: Persist the step_traces array after each step completes (not just at the end) using a partial Supabase update. This allows the run history UI to show real-time progress for long-running workflows.

**Expected result:** The execution engine Edge Function deploys. Calling it with a workflow_id and run_id processes each node in order, storing traces in workflow_runs. Simple HTTP and Supabase query steps execute correctly.

### 3. Build the webhook trigger endpoint

Create the webhook receiver Edge Function that external services call to trigger a workflow. It validates the request, creates a run, and invokes the execution engine.

```
// supabase/functions/trigger-webhook/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, content-type, x-webhook-signature',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const url = new URL(req.url)
  const workflowPath = url.searchParams.get('path')

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: trigger } = await supabase
    .from('workflow_triggers')
    .select('workflow_id, workflow:workflows(id, status, trigger_config)')
    .eq('webhook_path', workflowPath)
    .eq('is_active', true)
    .single()

  if (!trigger?.workflow_id) {
    return new Response(JSON.stringify({ error: 'Webhook not found' }), { status: 404, headers: corsHeaders })
  }

  const workflow = trigger.workflow as { id: string; status: string; trigger_config: { webhook_secret?: string } }

  if (workflow.status !== 'active') {
    return new Response(JSON.stringify({ error: 'Workflow is not active' }), { status: 400, headers: corsHeaders })
  }

  if (workflow.trigger_config?.webhook_secret) {
    const signature = req.headers.get('x-webhook-signature') ?? ''
    const body = await req.text()
    const encoder = new TextEncoder()
    const keyData = encoder.encode(workflow.trigger_config.webhook_secret)
    const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
    const sigBytes = new Uint8Array(signature.match(/.{2}/g)!.map((b) => parseInt(b, 16)))
    const valid = await crypto.subtle.verify('HMAC', key, sigBytes, encoder.encode(body))
    if (!valid) return new Response(JSON.stringify({ error: 'Invalid signature' }), { status: 401, headers: corsHeaders })
  }

  const payload = req.method === 'POST' ? await req.json().catch(() => ({})) : {}

  const { data: run } = await supabase.from('workflow_runs').insert({
    workflow_id: workflow.id,
    trigger_type: 'webhook',
    trigger_payload: payload,
    status: 'running',
  }).select().single()

  if (!run) return new Response(JSON.stringify({ error: 'Failed to create run' }), { status: 500, headers: corsHeaders })

  await fetch(`${Deno.env.get('SUPABASE_URL')}/functions/v1/execute-workflow`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')}` },
    body: JSON.stringify({ workflow_id: workflow.id, run_id: run.id, trigger_payload: payload }),
  })

  return new Response(JSON.stringify({ run_id: run.id }), { headers: corsHeaders })
})
```

> Pro tip: Return the run_id in the webhook response immediately, before the execution Engine finishes. The caller can then poll GET /workflow-runs/:run_id to check the status. This makes the webhook endpoint non-blocking.

**Expected result:** The webhook Edge Function is accessible at a public URL. Sending a POST request to it creates a workflow_runs row and triggers execution of the linked workflow.

### 4. Build the workflow builder UI

Create the workflow builder page where users define steps and their connections. Steps are shown as stacked Cards with add/remove/reorder controls. The builder writes to the steps JSONB.

```
Build a workflow builder page at src/pages/WorkflowBuilder.tsx.

Page layout:
- Top bar: workflow name Input, status Badge, Save Button, Test Run Button
- Main area: ordered list of step Cards

Each step Card shows:
- Step number and type Badge (HTTP, Query, Condition, Delay, Email)
- Step name Input
- Type-specific config fields:
  - HTTP: URL Input, method Select (GET/POST/PUT/DELETE), Headers Textarea (JSON), Body Textarea
  - Query: table Input, operation Select (select/insert/update/delete), filters Textarea
  - Condition: expression Textarea with helper text showing available {{variables}}
  - Delay: duration_seconds Input with helper 'Max 120s in Edge Functions'
  - Email: to Input, subject Input, body Textarea
- Between cards: a connector line with an 'Add Step' Button (adds after this step)
- Remove step Button (trash icon) in the card header
- Steps are reorderable with up/down arrow buttons

Trigger configuration section (above steps):
- Trigger type Select: Manual, Webhook, Cron
- Webhook trigger shows the public webhook URL (trigger-webhook Edge Function URL + ?path=webhook_path)
- Cron trigger shows a cron expression Input with a human-readable preview

When Save is clicked:
- Validate all required fields
- Build the steps JSONB: nodes array with sequential edges (each step connects to the next)
- Save to workflows table via Supabase update

Test Run Button:
- Opens a Dialog with a JSON editor for test trigger_payload
- On confirm: creates a workflow_runs row and calls execute-workflow
- Navigates to /workflows/:id/runs/:run_id after submission
```

> Pro tip: Show the {{steps.stepId.output}} variables available to each step based on the steps that come before it in the sequence. As the user builds the workflow, update a tooltip on each config Textarea showing which variables are available at that point.

**Expected result:** The workflow builder shows step cards in sequence. Adding and removing steps updates the steps JSONB. Saving persists to the database. Test Run creates a run and navigates to the run viewer.

### 5. Build the run history viewer

Create the run history page showing all past executions with step-by-step trace expansion. This is the debugging interface for when workflows fail.

```
Build a run history viewer.

1. src/pages/WorkflowRuns.tsx — run list:
   - DataTable with columns: Run ID (first 8 chars), Status Badge (green=completed, red=failed, blue=running), Trigger Type, Started At, Duration
   - Filter by status using a Select above the table
   - Clicking a row navigates to /workflows/:id/runs/:run_id
   - Auto-refresh running runs every 3 seconds using useEffect with a setInterval

2. src/pages/WorkflowRunDetail.tsx — run detail:
   - Header: status Badge, trigger type, started/finished timestamps, total duration
   - Trigger Payload Card: shows the JSON that triggered the run in a code block
   - Steps Accordion (shadcn/ui Accordion):
     - One AccordionItem per step trace
     - Trigger: step name + type Badge + status icon (checkmark, X, clock)
     - Content: two columns — Input JSON and Output JSON in monospace code blocks
     - Failed steps show the error message in a destructive Alert
   - 'Re-run Workflow' Button: creates a new run with the same trigger payload
   - For running workflows: show a progress indicator and auto-expand the currently executing step
```

> Pro tip: Add a diff view between the workflow's current steps and the steps_snapshot stored in the run. If the workflow has been modified since the run, show a banner: 'This run used an older version of the workflow steps.'

**Expected result:** The run list shows all past runs with status and duration. Clicking a run shows the step-by-step trace with input/output JSON. Failed steps show the error. Running runs auto-refresh.

## Complete code example

File: `supabase/functions/trigger-webhook/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, content-type, x-webhook-signature',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const url = new URL(req.url)
  const webhookPath = url.searchParams.get('path')

  if (!webhookPath) {
    return new Response(JSON.stringify({ error: 'Missing path parameter' }), { status: 400, headers: corsHeaders })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: trigger } = await supabase
    .from('workflow_triggers')
    .select('workflow_id')
    .eq('webhook_path', webhookPath)
    .eq('is_active', true)
    .single()

  if (!trigger) {
    return new Response(JSON.stringify({ error: 'Webhook not found' }), { status: 404, headers: corsHeaders })
  }

  const { data: workflow } = await supabase
    .from('workflows')
    .select('id, status, trigger_config')
    .eq('id', trigger.workflow_id)
    .single()

  if (!workflow || workflow.status !== 'active') {
    return new Response(JSON.stringify({ error: 'Workflow not active' }), { status: 400, headers: corsHeaders })
  }

  const bodyText = await req.text()
  let payload: Record<string, unknown> = {}
  try { payload = JSON.parse(bodyText) } catch { /* non-JSON body */ }

  const { data: run, error: runError } = await supabase
    .from('workflow_runs')
    .insert({
      workflow_id: workflow.id,
      trigger_type: 'webhook',
      trigger_payload: payload,
      status: 'running',
      started_at: new Date().toISOString(),
    })
    .select('id')
    .single()

  if (runError || !run) {
    return new Response(JSON.stringify({ error: 'Failed to create run' }), { status: 500, headers: corsHeaders })
  }

  // Fire and forget — execute in background
  fetch(`${Deno.env.get('SUPABASE_URL')}/functions/v1/execute-workflow`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')}`,
    },
    body: JSON.stringify({ workflow_id: workflow.id, run_id: run.id, trigger_payload: payload }),
  }).catch(console.error)

  return new Response(JSON.stringify({ run_id: run.id, status: 'triggered' }), { headers: corsHeaders })
})
```

## Common mistakes

- **Using eval() to evaluate condition expressions in the execution engine** — eval() runs arbitrary JavaScript with full runtime access. A malicious workflow config could execute code inside your Edge Function, compromising your Supabase service role credentials. Fix: Implement a safe expression evaluator that only supports comparisons and logical operators. Replace {{placeholders}} with their resolved values, then use a simple parser that handles ===, !==, >, <, &&, || without eval.
- **Making the webhook trigger synchronous — waiting for workflow execution before responding** — Complex workflows can take 30+ seconds. The calling service will timeout waiting for a response, retry the webhook, and trigger the same workflow twice. Fix: Create the workflow_runs row and return the run_id immediately. Fire the execute-workflow Edge Function call using fetch without await (fire-and-forget). The caller can poll run status separately.
- **Not storing a steps snapshot in workflow_runs at the time of execution** — If a workflow's steps are modified after a run, the run history will show the current steps, not the ones that actually executed. This makes debugging historical failures confusing and misleading. Fix: Copy the workflow's current steps JSONB into a steps_snapshot column in workflow_runs when the run starts. The run detail viewer uses steps_snapshot, not the live workflow steps.
- **Building infinite loops by connecting a step's output edge back to an earlier node** — The execution engine will spin in an infinite loop, consume all Edge Function CPU time, and time out after 150 seconds, creating a failed run. Fix: Validate the DAG before saving by checking for cycles. Use a depth-first search: if you encounter a node you've already visited in the current path, the graph has a cycle. Reject the save with a validation error.
- **Storing sensitive data (API keys, passwords) in workflow step configurations** — The steps JSONB is stored in plaintext in the database. Any user with database access can read all workflow configs, exposing credentials. Fix: Use secret references in workflow configs instead of values: {{secret.MY_API_KEY}}. In the execution engine, resolve secret references using Deno.env.get() at runtime. Document which secrets need to be set in Cloud tab → Secrets.

## Best practices

- Store a steps_snapshot in workflow_runs at execution time so run history always reflects what actually ran
- Validate the DAG for cycles before saving — prevent infinite loops at the UI layer before they reach the execution engine
- Never use eval() for condition expressions — implement a restricted expression parser
- Make webhook endpoints non-blocking — create the run row and return immediately, execute in background
- Add a maximum execution timeout at the engine level (separate from Edge Function timeout) and mark runs as 'timeout' after N seconds
- Log the full input and output of each step in step_traces — this is the primary debugging tool when workflows fail
- Use idempotency keys on webhook triggers to prevent duplicate runs from retry logic in calling services
- Cap the maximum number of nodes in a workflow (e.g. 50) to prevent pathologically complex workflows that exhaust Edge Function resources

## Frequently asked questions

### What's the maximum workflow execution time?

Supabase Edge Functions time out after 30 seconds on the free tier and 150 seconds on paid plans. For workflows with delay steps or many HTTP calls, you may hit this limit. The workaround is to split long workflows into sub-workflows: the first workflow creates a scheduled workflow_runs entry, and a pg_cron job resumes it later.

### Can workflows call other workflows?

Yes, by adding a 'workflow' step type whose execution fetches another workflow's definition and calls execute-workflow recursively (or in a separate Edge Function invocation). To prevent infinite recursion, add a max_depth parameter to execute-workflow that fails if the current depth exceeds your limit (recommended: 5).

### How do I handle workflow failures and retries?

The execution engine sets workflow_runs.status = 'failed' and stores the error in the failing step's trace. Add a max_retries field to workflows and a retry_count to workflow_runs. When a run fails, a pg_cron job checks for failed runs with retry_count < max_retries and re-creates a new run with the same trigger payload.

### Can I test a workflow before activating it?

Yes. The Test Run button on the workflow builder creates a run with trigger_type = 'manual' and a custom test payload. The execution engine runs the full workflow using the test payload. The run history shows the results. Workflows in 'draft' status can be manually triggered but not triggered by webhooks or cron.

### How do I pass data from one step to the next?

The run_context object accumulates each step's output keyed by node ID. In subsequent step configs, use {{steps.node_id.output.field}} placeholders. The execution engine resolves these before running each step. For example, if step 'fetch-user' returns { output: { email: 'user@example.com' } }, the next step can use {{steps.fetch-user.output.email}}.

### What's the difference between a workflow trigger and a cron job?

A workflow trigger is the mechanism that starts a workflow. Webhook triggers start workflows when an external service calls the trigger URL. Cron triggers start workflows on a schedule. Both create a workflow_runs row and call execute-workflow. The trigger type is stored in workflow_runs.trigger_type for auditing.

### Can multiple instances of the same workflow run simultaneously?

Yes by default. If a cron trigger fires while a previous run is still executing, both run concurrently. To prevent this, add a UNIQUE partial index on workflow_runs(workflow_id) WHERE status = 'running'. Attempting to create a second run while one is running will fail — you can catch this and skip or queue the new run.

### How do I monitor workflow health across all workflows?

Build a dashboard page that queries workflow_runs grouped by workflow_id, showing success rate, average duration, and last run status for each workflow. Add a global alert if any workflow has a failure rate above 10% in the last 24 hours. Query: SELECT workflow_id, COUNT(*) FILTER (WHERE status='failed') / COUNT(*)::float as failure_rate FROM workflow_runs WHERE started_at > now() - interval '1 day' GROUP BY workflow_id.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/workflow-automation
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/workflow-automation
