In-Product Workflow & Automation Builder — Chat Prompts
If your B2B SaaS hits a moment around Series A where customers ask "can you make X happen automatically when Y?" — that's the signal you should consider building an in-product workflow / automation builder. Linear's Cycle automations, Notion automations, Airtable automations, HubSpot workflows, Salesforce Process Builder — every modern B2B SaaS eventually ships some version of this. The naive shape: "we'll just integrate with Zapier; that solves it." It's a starting point but: Zapier integrations require customers to build/maintain Zaps in another tool, with a separate bill, separate auth, separate debugging surface. For deep workflows, customers want it INSIDE your product, with full type safety, no separate billing, instant runs, integrated history, and triggers/actions that know about your domain primitives.
Get this right and customers automate ~30-50% of their repetitive operations, expansion revenue compounds (more workflows = more sticky), and you reduce support volume on "can it do X?" questions. Get it wrong and you ship a broken Zapier copy that nobody trusts, customers build 200 broken workflows that fire at 3am, your support team handles "why did this run / not run?" tickets every day, and you have an undeprecateable feature.
This chat walks through implementing a real workflow automation engine: triggers, conditions, actions, the visual builder UI, the execution runtime, debugging tools, error handling, quotas, and the operational realities of running customer-defined workflows at scale.
What you're building
- A workflow data model (trigger + conditions + actions; versioned)
- A trigger system (event-driven; based on your existing event bus)
- A conditions / filtering engine (when does the workflow apply?)
- An actions library (what can the workflow do?)
- A visual workflow builder (the customer-facing UI)
- An execution runtime (durable; idempotent; observable)
- An error / retry / pause system
- A debugging UI (run history; step-through; replay)
- Quotas + abuse controls
- Customer-facing analytics (how often each workflow ran)
1. Decide the scope BEFORE designing
Help me decide what shape of automation builder I actually need.
Three architectural shapes — pick consciously:
LEVEL 1: PRESET WORKFLOWS (templates only)
- You ship 10-30 pre-built automations
- Customer enables/disables; can't edit logic
- Configurable parameters but not the steps
- Examples: "When task is overdue → send email"; "When document is shared → notify slack"
- Pros: fast to ship; bounded scope; customer can't break things
- Cons: not flexible; misses long-tail cases
- Time to ship: 4-8 weeks
LEVEL 2: SIMPLE BUILDER (linear: trigger → conditions → 1-3 actions)
- Customer picks trigger from dropdown
- Adds optional conditions
- Picks 1-3 actions
- No branches; no loops; no nested conditions
- Examples: HubSpot workflows v1; Notion automations; Linear's "When status changes"
- Pros: powerful enough for 80% of customer needs; manageable complexity
- Cons: complex flows require multiple workflows
- Time to ship: 12-20 weeks
LEVEL 3: FULL BUILDER (branches, loops, parallel paths, sub-workflows)
- DAG-shaped workflows
- Conditional branches
- Loops + parallel execution
- Sub-workflows / reusable building blocks
- Variables passed between steps
- Examples: HubSpot enterprise workflows; Salesforce Flow; n8n
- Pros: customers can build anything; sticks like cement
- Cons: months of engineering; debugging hell; support load
- Time to ship: 24-52 weeks
LEVEL 4: NO-CODE PROGRAM (with code blocks)
- Full builder + JS/Python code blocks
- Custom HTTP requests, JSON parsing, etc.
- Examples: n8n, Make.com, Zapier code steps
- Pros: ultimate flexibility
- Cons: customer-side code execution liability; security audits; 9+ months engineering
- Time to ship: 1+ year
DEFAULT FOR MOST B2B SaaS:
- v1: Level 1 (preset workflows) — ship in a quarter; learn what customers want
- v2: Level 2 (simple builder) — based on what you learned from v1 usage
- v3: Level 3 — only if customer demand justifies (most never need this)
Which to ship FIRST:
- Have <20 customers asking for it: ship Level 1 templates
- Have 20-100 customers asking + clear common pattern: ship Level 2
- Don't pre-build Level 3 without specific buyer; you'll regret it
Output: a clear scope decision; ship plan; explicit "we are NOT shipping" boundary.
Output: a scope statement that prevents over-building.
2. Design the data model
Now design the workflow data model for [Postgres / Drizzle / your stack].
Core schema:
workflows (
id uuid pk
workspace_id uuid not null -- multi-tenancy boundary
name text not null
description text
status text -- 'draft' | 'active' | 'paused' | 'archived'
trigger_type text not null -- e.g. 'task_created', 'document_updated', 'comment_added'
trigger_config jsonb -- parameters specific to trigger type
conditions jsonb -- array of condition objects (AND/OR groups)
actions jsonb -- array of action objects, ordered
version int -- bumped on each save
created_by uuid
created_at timestamptz
updated_at timestamptz
last_run_at timestamptz -- denormalized for UI
total_runs bigint default 0 -- denormalized counter
failed_runs bigint default 0
)
workflow_runs (
id uuid pk default gen_uuid_v7() -- sortable by time
workflow_id uuid not null
workflow_version int not null -- which version of the workflow ran
workspace_id uuid not null
trigger_event_id uuid -- which event triggered this
trigger_payload jsonb -- snapshot of trigger data
status text -- 'pending' | 'running' | 'completed' | 'failed' | 'skipped'
started_at timestamptz
completed_at timestamptz
error_message text
step_results jsonb -- per-step outcome
retry_count int default 0
parent_run_id uuid -- for retries; points to original
)
workflow_step_results ( -- normalized version of step_results above; pick one
run_id uuid
step_index int
step_type text -- 'condition' | 'action'
step_action_type text -- e.g. 'send_email', 'create_task', 'webhook'
status text
input jsonb
output jsonb
duration_ms int
error_message text
PRIMARY KEY (run_id, step_index)
)
workflow_versions (
id uuid pk
workflow_id uuid not null
version int not null
trigger_type text
trigger_config jsonb
conditions jsonb
actions jsonb
saved_by uuid
saved_at timestamptz
UNIQUE (workflow_id, version)
)
Indexes:
- (workspace_id, status='active') for triggering workflow lookups
- (workflow_id, started_at DESC) for run history UI
- (workspace_id, started_at DESC) for workspace-wide debugging UI
Why version workflows:
- A run started under v3; user edits to v4 mid-run → run completes with v3 logic
- Customer can roll back: "use v2 again"
- Debugging: "this run was from v3 with this exact config"
Why store actions as jsonb (not normalized rows):
- Schema evolves with new action types
- Per-action config varies wildly (email-action has 'recipient'; webhook-action has 'url')
- JSONB indexes available if you need to query specific keys
Implement:
1. The migration SQL
2. The Drizzle/Prisma model
3. RLS policies (Supabase)
4. The version-on-save trigger
5. TypeScript types for trigger + condition + action shapes
6. Validation: a workflow can be saved only if its trigger_config / conditions / actions all validate against their respective schemas
Output: a schema that supports versioning + audit + replay.
3. Design the trigger system
Now build the trigger system.
Triggers are how workflows start. They fire on events you already emit elsewhere in your product (see [Activity Feed & Timeline](./activity-feed-timeline-implementation-chat) for the events backbone).
Architecture:
1. Your product emits events (e.g. emitEvent({ verb: 'task_created', ...}))
2. Trigger router subscribes to events
3. Router queries: "are there any workflows in this workspace where trigger_type matches this event?"
4. For each matching workflow, evaluate trigger_config (does this event meet the trigger criteria?)
5. If yes, queue a workflow run
6. Workflow execution worker picks up the run
Trigger types to ship:
Built-in entity triggers:
- entity_created (e.g. task_created, document_created)
- entity_updated
- entity_field_changed (specific field)
- entity_deleted
- entity_status_changed (specific status transitions)
User triggers:
- user_added_to_workspace
- user_role_changed
- comment_added
- mention_created
Time triggers:
- scheduled (cron-like; "every Monday at 9am")
- delayed_after_event (e.g. "30 days after document_created with no edits")
External triggers:
- webhook_received (from a third party)
- email_received (if you have a parsing inbox)
Custom triggers:
- API call from customer's code (POST /api/workflows/:id/trigger)
Trigger config schema (per type):
- entity_created: { entity_type: 'task', filter: {project_id?, assignee_id?, ...} }
- entity_field_changed: { entity_type: 'task', field: 'status', from?: 'todo', to?: 'done' }
- scheduled: { cron: '0 9 * * 1', timezone: 'America/Los_Angeles' }
Architecture for triggering:
When event emitted → publish to internal queue 'workflow_triggers' (Pub/Sub or similar)
Trigger Router (worker):
loop:
msg = queue.pull()
workflows = db.query("SELECT * FROM workflows WHERE workspace_id = $1 AND status='active' AND trigger_type=$2", msg.workspace_id, msg.event_type)
for w in workflows:
if matches_trigger_config(w.trigger_config, msg.payload):
create workflow_runs row (status='pending')
publish to 'workflow_executions' queue
Performance considerations:
- Indexed lookup by (workspace_id, status, trigger_type)
- Cache hot-workspace workflow lookups (60s TTL)
- For high-volume events (e.g., page_view), don't allow them as workflow triggers (DoS risk)
Trigger config evaluation:
- JSON-schema-validate at workflow save time
- Match at trigger time using JSONLogic or your own eval
Anti-patterns:
- Don't allow direct DB triggers (Postgres triggers); workflow logic lives in app
- Don't fire workflows synchronously in the request path (always async)
- Don't allow customer to subscribe to events that contain other tenants' data
Implement:
1. The trigger router
2. The matcher (trigger_config evaluation)
3. The schema for each trigger type
4. The performance optimizations (caching, indexed lookups)
5. The customer-facing trigger picker UI (more in step 6)
Output: triggers that respond to events without DDoS-ing the DB.
4. Design conditions + actions
Now build the conditions and actions library.
CONDITIONS: filtering after trigger fires
Examples:
- entity field equals X
- entity assignee is in [list]
- date is more than N days from now
- count of related entities > N
- field changed from X to Y (inferred from snapshot)
Condition schema:
type Condition =
| { type: 'and'; children: Condition[] }
| { type: 'or'; children: Condition[] }
| { type: 'field_equals'; field: string; value: any }
| { type: 'field_in'; field: string; values: any[] }
| { type: 'field_changed'; field: string }
| { type: 'field_not_changed'; field: string }
| { type: 'date_more_than'; field: string; value: number; unit: 'minutes' | 'hours' | 'days' }
| { type: 'count_greater'; relation: string; value: number }
// ... more types
Evaluation:
- conditions evaluated against trigger payload + entity snapshot
- can fetch additional context if needed (related entities)
- short-circuit (AND stops at first false; OR stops at first true)
ACTIONS: what the workflow does
Examples:
- send_email
- create_task
- update_field
- add_comment
- assign_to_user
- send_notification
- send_webhook
- run_other_workflow (sub-workflows; advanced)
Action schema:
type Action =
| { type: 'send_email'; to: TemplateString; subject: TemplateString; body: TemplateString }
| { type: 'create_task'; project_id: string; title: TemplateString; assignee?: string }
| { type: 'update_field'; entity_id: string; field: string; value: TemplateString }
| { type: 'add_comment'; entity_id: string; body: TemplateString }
| { type: 'send_webhook'; url: string; method: 'POST'; body: jsonbTemplate; headers?: ... }
| { type: 'send_notification'; user_id: string; message: TemplateString }
| { type: 'wait'; duration: number; unit: 'minutes' | 'hours' | 'days' }
// ... more types
TemplateString supports references to trigger payload + previous step outputs:
- "{{trigger.entity.title}}"
- "{{trigger.actor.email}}"
- "{{step1.output.id}}"
Implementation:
- Use safe template engine (NOT eval; sandboxed; e.g. Handlebars with escape)
- Validate template at save time (fields exist on the schema)
- Render template at execution time
Action handlers:
- Each action type has a handler function
- Handler signature: async (input: ActionInput, context: ExecutionContext) => ActionResult
- Handler is responsible for: validation, execution, error handling, idempotency
Idempotency:
- Each action run has a unique run_id + step_index
- Dedupe by (workflow_run_id, step_index) — never run the same step twice
- For external actions (send_email, webhook): include idempotency key in API call
Error handling per action:
- Each action can mark itself: 'fatal' (whole workflow fails) or 'non-fatal' (continue to next)
- Customer can configure: "if action fails, [skip / fail workflow / retry]"
- Default: retry transient errors 3x with exponential backoff, then mark step failed
Implement:
1. The condition schema + evaluator
2. The action schema + handler interface
3. The template engine
4. Per-action handlers (start with 5-10 most common)
5. The validation layer (workflow can't save with invalid action config)
6. The execution context (passes data between steps)
Output: a flexible-but-bounded set of actions that compose.
5. Build the execution runtime
Now the execution runtime — where workflows actually run.
Architecture:
Workflow execution is a durable async job. Use [Inngest / Trigger.dev / Temporal / your queue + worker] for orchestration. DO NOT run workflows synchronously in HTTP request handlers.
Execution flow:
1. Trigger router enqueues workflow_run (status='pending')
2. Worker picks up the run; status='running'
3. For each step (in order):
a. Evaluate conditions (skip workflow if any fail)
b. Render action template with context
c. Execute action via handler
d. Capture step result
e. Update workflow_run with progress
4. Mark workflow_run completed (or failed)
5. Update workflow.last_run_at, workflow.total_runs
Idempotency:
- Step-level idempotency keys
- Worker can be killed mid-run; on restart, pick up where it left off
- Use [Inngest's step API / Trigger.dev's step API / your durable workflow lib]
Concurrency:
- Per-workflow concurrency limit (don't run 1000 instances of same workflow simultaneously)
- Per-workspace concurrency limit (DoS protection)
- Rate limit on external action calls (don't exhaust customer's email quota)
Timeouts:
- Per-step timeout (default 30s; configurable per action type)
- Whole-workflow timeout (default 5 min)
- Long-running workflows: use 'wait' action explicitly
Retries:
- Transient errors (network, 5xx): retry 3x with exponential backoff
- Permanent errors (4xx, validation): no retry; mark failed
- Customer-configurable retry policy per workflow
Pausing / waiting:
- 'wait' action puts workflow in 'paused' state with resume_at timestamp
- Cron job picks up paused workflows when resume_at <= now
- Use durable workflow runtime for this (Temporal, Inngest)
Step results capture:
- Every step's input + output captured to workflow_step_results
- Useful for debugging
- TTL: 30-90 days for completed runs (don't store forever; storage explosion)
Failure modes:
- Step fails: log; mark failed; alert customer (notification + email)
- Workflow exceeds timeout: kill; mark failed; alert
- Worker crashes mid-step: durable runtime resumes from last checkpoint
- Database unreachable: retry transparently; mark failed only after permanent
Implement:
1. The worker / execution loop
2. The step-level idempotency
3. The retry logic
4. The timeout enforcement
5. The pause/resume for 'wait' actions
6. The metrics emission (per workflow, per action, per workspace)
Output: a runtime that survives crashes and customer-induced chaos.
6. Build the visual workflow builder UI
Now build the customer-facing UI.
Key UX decisions:
A. Linear vs DAG (Level 2 vs Level 3)
- Linear: simple list of trigger → conditions → actions; ship this first
- DAG: visual flowchart with branches; advanced; defer
B. Real-time validation
- As user picks trigger: show available fields
- As user adds conditions: show available operators per field type
- As user adds actions: show template fields available
- All validated server-side on save
C. Test-run capability
- "Test this workflow" button
- Customer picks a sample trigger event (or system shows recent events to pick from)
- Workflow runs in dry-run mode (no side effects)
- Show step-by-step results
UI components:
Builder page (/automations/[id]/edit):
Header:
- Workflow name (editable inline)
- Status toggle (draft / active / paused)
- "Test" button
- "Save" button (with version bump)
- "Run history" link
Body:
1. Trigger section
- "When this happens" big card
- Trigger type dropdown (Task created / Document updated / etc.)
- Trigger config form (varies per type)
2. Conditions section
- "If these conditions are met" (optional)
- Add condition button
- Each condition: field dropdown, operator dropdown, value input
- AND/OR grouping
3. Actions section
- "Then do this" big block
- Each action: action type dropdown, action config form
- Drag to reorder
- Add action button
4. Footer
- Test button (live)
- Save button
- Last edited info + version
Field references:
- When typing in template fields, autocomplete suggests {{trigger.fieldName}}
- Show available fields based on trigger type
- Validate references at save time
Action picker:
- Categorized: Communication / Tasks / Records / External / Wait
- Search box
- Per action: short description, parameters required
Best practices:
- Save early, save often (auto-save drafts every 30s)
- Validate before "Activate"
- Inline help for each trigger / condition / action type
Implement:
1. The builder page layout
2. The trigger picker + config form
3. The condition builder
4. The action picker + config form
5. The template autocomplete
6. The test-run UI
7. The save / activate flow
8. The version history view
Output: a builder customers actually finish setting up.
7. Build the run history + debugging UI
Customers WILL ask: "did this workflow run? why didn't it run? what did it do?"
Surface a great answer.
Run history page (/automations/[id]/runs):
List view:
- Last 100 runs (cursor pagination)
- Each row: timestamp, status (✓ success / ✗ failed / ⏸ paused), trigger summary, duration
- Filter: status, date range
- Search: by trigger payload field
Click a run → detail view:
- Trigger: what fired this; full payload (JSON viewer)
- Each step:
- Step #, action type, status, duration
- Input + output (JSON)
- Error message if failed
- Total duration
- Retry button (re-run this exact trigger)
Debugging panel:
- "Why didn't this run?" explainer
- "Trigger fired at X" → "Conditions evaluated to false because [field] was [actual] not [expected]"
- Step-by-step replay (animation through the flow)
- Compare runs (run A vs B; show diffs)
Customer-facing analytics:
- Total runs (last 7/30/90 days)
- Success rate
- Average duration
- Top failure reasons
Implement:
1. The run list view
2. The run detail view (with JSON inspector)
3. The "why didn't it run" explainer logic
4. The retry button
5. The customer-facing analytics
Output: customers can self-debug; reduces support load 10x.
8. Quotas, abuse, and operational guardrails
Customer-defined workflows are a vector for misuse — accidental and malicious. Plan for it.
Quotas (per workspace, per plan tier):
Free tier:
- 5 active workflows
- 1,000 workflow runs / month
- No external HTTP actions (no send_webhook)
Standard tier:
- 25 active workflows
- 50,000 runs / month
- Webhooks to allowlisted domains only
Pro tier:
- 100 active workflows
- 500,000 runs / month
- Webhooks to any domain
- Code blocks (if Level 4)
Enterprise tier:
- Custom
Per-workflow guardrails:
- Concurrency limit: 50 parallel runs per workflow (prevents runaway loops)
- Steps per workflow: 20 max (prevents infinite chains)
- Wait duration max: 30 days (prevents zombie workflows)
- Loop detection: workflow A triggers workflow B which triggers A → block on detection
Abuse detection:
- Spike alert: workflow runs > 10x normal in 1 hour
- Failed-run spike: 90%+ failures in 1 hour → auto-pause + email
- Webhook to suspicious domain: rate limit + alert
- Email-bombing: workflow sends 100+ emails to same address in 1 hour → block
Customer-facing display:
- Quotas visible in settings: "23 / 25 active workflows"
- Usage chart: runs per day vs limit
- Approaching limit: notify before hard block
Abuse-response runbook:
- Customer's workflow is making 100 webhooks/sec to external domain → auto-pause
- Customer's workflow is in infinite loop → auto-pause + email
- Customer-facing UI shows pause reason + "Reactivate" button
Internal admin tools:
- Force-pause any workflow
- Force-disable workflow runs for a workspace
- Force-purge run history for a workspace
- View execution metrics by workspace
Implement:
1. The quota enforcement (per-tier limits)
2. The concurrency limits per workflow
3. The loop detection
4. The abuse detection alerts
5. The auto-pause flows
6. The internal admin tools
Output: a system that survives customer creativity.
9. Edge cases + operational concerns
Walk me through edge cases:
1. Workflow runs while customer is editing it
- Capture workflow_version at run start
- Run completes with that version's logic
- Customer's edits apply to NEXT run
2. Action handler fails halfway (e.g. emails 3 of 5 recipients)
- Idempotency: each recipient is its own sub-step
- On retry: skip already-sent (deduplicate by hash of recipient + run_id)
3. Customer deletes the entity that triggered a workflow
- Run already in progress: continue with snapshot from trigger_payload
- Action references entity that no longer exists: action fails gracefully
- Don't crash workflow; mark step skipped/failed
4. Trigger event volume too high
- e.g. customer sets workflow on "every page_view"; their site gets 1M views/day
- Detect: triggers per workflow per minute > threshold
- Action: block at trigger router; alert customer; require throttle config
5. Workflow A creates entity X; workflow B triggers on entity_created → recursion risk
- Add metadata to workflow-created entities ("created_by_workflow_id")
- Workflow B can opt-out: "skip if created by another workflow"
- Hard limit: max 5 levels of workflow chaining per trigger event
6. Customer workspace deleted; their workflows hanging
- Cascade-delete all workflows + runs
- GDPR: ensure run history is purged on tenant delete
7. Workspace plan downgrade past quota
- Customer was on Pro (100 workflows); downgrades to Standard (25 limit)
- Don't auto-disable workflows
- Show banner: "You have 73 workflows; 25 allowed on this plan. Disable some or upgrade."
- Block ACTIVATION of new workflows
8. Email action used to spam
- Each workflow's email actions go through email validation
- Sender reputation tracking
- Customer-bounded daily email cap (e.g. 1000/day; raise on request)
9. Customer wants to migrate workflow to another workspace
- Export-import via JSON
- Reassign user references (assignees might not exist in new workspace)
- Test before activating
10. Workflow logic so complex it's effectively code
- At some point, customers want loops, conditionals, code
- Plan for graceful escalation: "this workflow is complex; consider using our API directly"
- Or: ship Level 4 (code blocks) when demand justifies
11. Customer's webhook endpoint is down
- Retry 3x with exponential backoff
- After 3 failures: mark step failed; notify customer
- After 100 failures in 24h: pause that workflow's webhook actions
12. Workflow that runs 1M times per day (legitimate, like syncing tickets)
- Confirm pricing tier supports this volume
- Optimize: batch executions, parallelize action handlers
- Internal budget: this customer might be 30% of your workflow infra cost; price accordingly
For each, walk me through code change + customer comms.
Output: real-world operational maturity.
10. Recap
What you've built:
- Workflow data model (versioned, audit-able)
- Trigger system tied to your event bus
- Condition + action library (extensible)
- Visual workflow builder UI (linear v1; DAG later if needed)
- Durable execution runtime (Inngest / Trigger.dev / Temporal-backed)
- Run history + debugging UI
- Quotas + abuse controls
- Customer-facing analytics
- Operational runbooks for misuse
What you're explicitly NOT shipping in v1:
- Visual DAG builder (defer to Level 3)
- Code blocks (defer to Level 4)
- Sub-workflows / reusable building blocks (defer)
- Cross-workspace workflows (anti-pattern; never)
- Custom HTTP actions to arbitrary domains on Free tier (security)
- AI-suggested workflows (defer; useful but not v1)
- Cron-on-arbitrary-time-zones beyond top 30 (start with the obvious)
Ship Level 1 (templates) → see what customers want → Level 2 (simple builder) → measure → Level 3 only if explicitly needed.
The biggest mistake teams make: building Level 3 (full DAG with branches + loops) when 80% of customers needed Level 1 (templates with parameters). The complex builder costs 3-4x more to ship + maintain + debug.
The second mistake: not investing in run history + debugging UI. Customers will trust workflows only if they can SEE what they did. Without it, support tickets dominate.
The third mistake: treating workflow execution as synchronous. ALWAYS async via durable queue. Otherwise customer's bad workflow takes down your API.
See Also
- Activity Feed & Timeline — events that trigger workflows
- Outbound Webhooks — pairs for webhook actions
- Inbound Webhooks — pairs for webhook triggers
- Cron / Scheduled Tasks — scheduled workflow triggers
- Background Jobs & Queue Management — workflow execution depends on this
- Email Template Implementation — pairs for email actions
- Email Deliverability — pairs for high-volume email actions
- Notification Preferences & Unsubscribe — pairs for notification actions
- In-App Notifications — pairs for notification actions
- Quotas, Limits & Plan Enforcement — pairs for workflow quotas
- Roles & Permissions — who can edit workflows
- Audit Logs — workflow saves + executions logged
- Workflow Automation Providers (Reference) — competitive landscape (Zapier / Make / n8n)
- Public API — alternative customer surface for advanced workflows
- Multi-Tenancy — workspace boundary
- Idempotency Patterns — depended-upon discipline for action handlers