Paths / Systems Thinking / Compound Workflows
Lesson 1 of 7
45 min

Compound Workflows

Orchestrate multi-step workflows: Meeting → Notion + Slack + Email + CRM.

Learning Objectives

By the end of this lesson, you will be able to:

  • Design compound workflows that orchestrate 4+ services in a single execution
  • Map dependencies between workflow steps (sequential vs parallel)
  • Use Promise.allSettled() for parallel execution with independent error handling
  • Implement partial success patterns where optional steps can fail gracefully
  • Calculate the multiplicative value of compound workflows vs simple integrations

Single automations move data A → B. Compound workflows orchestrate complete outcomes across multiple services, creating results greater than the sum of their parts.

WORKWAY vs Traditional Automation

Aspect Zapier/Make WORKWAY Compound
Architecture Step-by-step chains Parallel + sequential orchestration
Failure handling Entire chain fails Partial success with graceful degradation
AI integration Separate AI zaps Native Workers AI, same execution
State management Stateless Persistent storage across runs
Customization Visual builder limits Full TypeScript flexibility
Execution model Per-step pricing Single workflow execution

Step-by-Step: Design a Compound Workflow

Step 1: Map the Complete Outcome

Write down everything that should happen when your trigger fires:

Trigger: Zoom meeting ends

Outcomes needed:
1. Meeting notes saved to Notion
2. AI summary generated
3. Team notified in Slack
4. Follow-up email drafted
5. CRM record updated

Step 2: Identify Dependencies

Determine which steps depend on others:

Meeting Data
                         │
                    ┌────┴────┐
                    │         │
               Transcript  Metadata
                    │         │
                    ▼         │
               AI Summary     │
                    │         │
                    ├─────────┤
                    │         │
              ┌─────┼─────┐   │
              ▼     ▼     ▼   ▼
           Notion Slack  Email CRM
           (sequential)  (parallel)

Step 3: Implement Sequential Steps

Add dependent steps that must run in order:

async execute({ trigger, inputs, integrations }) {
  const { zoom, ai, notion } = integrations;

  // Sequential: Each step needs the previous result
  const meetingResult = await zoom.getMeeting(trigger.data.object.id);

  const transcriptResult = await zoom.getTranscript({
    meetingId: meetingResult.data?.id,
  });

  const summaryResult = await ai.generateText({
    system: 'Summarize meeting highlights in 3-5 bullets.',
    prompt: transcriptResult.data?.transcript_text || '',
  });

  const page = await notion.pages.create({
    parent: { database_id: inputs.notionDatabase },
    properties: {
      Name: { title: [{ text: { content: meetingResult.data?.topic || 'Meeting' } }] },
    },
    children: [{
      type: 'paragraph',
      paragraph: { rich_text: [{ text: { content: summaryResult.data?.response || '' } }] },
    }],
  });

  return { success: true, pageId: page.data?.id };
}

Step 4: Add Parallel Steps

Use Promise.all() for independent operations:

async execute({ trigger, inputs, integrations }) {
  const { zoom, ai, notion, slack, gmail } = integrations;

  // Get data (sequential)
  const meeting = await zoom.getMeeting(trigger.data.object.id);
  const summary = await ai.generateText({ /* ... */ });

  // Dispatch to multiple services (parallel)
  const [notionResult, slackResult, emailResult] = await Promise.all([
    notion.pages.create({ /* ... */ }),
    slack.chat.postMessage({
      channel: inputs.slackChannel,
      text: `Meeting "${meeting.data?.topic}" complete`,
    }),
    gmail.sendEmail({
      to: meeting.data?.host_email,
      subject: `Follow-up: ${meeting.data?.topic}`,
      body: `Draft follow-up for your meeting...\n\n${summary.data?.response}`,
    }),
  ]);

  return {
    success: true,
    pageId: notionResult.data?.id,
    slackSent: slackResult.success,
    emailDrafted: emailResult.success,
  };
}

Step 5: Isolate Failures

Prevent one failure from breaking everything:

async execute({ trigger, inputs, integrations }) {
  const { notion, slack, gmail } = integrations;
  const results = { notion: null, slack: null, email: null, errors: [] };

  // Required step
  const notionResult = await notion.pages.create({ /* ... */ });
  if (!notionResult.success) {
    return { success: false, error: 'Failed to save meeting notes' };
  }
  results.notion = notionResult.data?.id;

  // Optional steps - don't fail workflow
  try {
    const slackResult = await slack.chat.postMessage({ /* ... */ });
    results.slack = slackResult.success ? 'sent' : 'failed';
  } catch (e) {
    results.errors.push('Slack notification failed');
  }

  try {
    const emailResult = await gmail.sendEmail({ /* ... */ });
    results.email = emailResult.success ? 'sent' : 'failed';
  } catch (e) {
    results.errors.push('Email draft failed');
  }

  return { success: true, ...results };
}

Step 6: Test the Complete Flow

workway dev

# Test with all steps
curl localhost:8787/execute \
  -H "Content-Type: application/json" \
  -d '{"object": {"id": "123", "topic": "Sprint Planning", "host_email": "[email protected]"}}'

# Check logs for all service calls
workway logs --tail

Strategic Context

Compound workflows are WORKWAY's primary competitive differentiator. Understanding why matters for building workflows that deliver genuine value.

The Automation Gap

Most automation tools (Zapier, Make, n8n) excel at point-to-point connections:

A → B  (one service to another)

But real work doesn't happen in isolation. A meeting ends and you need:

  • Notes saved to Notion
  • Summary posted to Slack
  • Follow-up email drafted
  • CRM record updated
  • Next meeting scheduled

Traditional tools require five separate automations, each with its own failure modes, no shared context, and separate billing.

WORKWAY's Compound Approach

WORKWAY orchestrates the complete outcome in a single execution:

Trigger → Sequential Processing → Parallel Fanout → Result
            (AI summary)         (Notion + Slack + Email + CRM)
Aspect Point-to-Point Compound
Executions 5 separate 1 unified
Error handling Each fails independently Partial success patterns
AI context Lost between steps Shared across all outputs
Debugging 5 different logs Single execution trace
Cost 5× trigger fees 1× execution fee

Why This Matters for Developers

Building compound workflows creates defensible value:

  1. Higher perceived value - Users pay for complete outcomes, not individual connections
  2. Stickier usage - Multi-service workflows are harder to replace
  3. AI leverage - Process once, distribute to many outputs

From docs/PLATFORM_THESIS.md:

"WORKWAY doesn't just move data A → B. We orchestrate the full workflow... This is what competitors (Transkriptor, Zapier) don't do."

The Zuhandenheit Test

A well-designed compound workflow achieves true ready-to-hand invisibility:

  • Wrong: "It syncs my Zoom to Notion, posts to Slack, drafts an email, and updates HubSpot"
  • Right: "My meetings handle their own follow-up"

The user describes the outcome, not the mechanism. All five services recede into a single experience.


The Compound Advantage

Simple workflow:

Meeting ends → Create Notion page

Compound workflow:

Meeting ends →
  ├── Notion page with AI summary
  ├── Slack notification with highlights
  ├── Email draft for follow-up
  ├── CRM updated with meeting notes
  └── Calendar event for next meeting

Five services. One trigger. Complete outcome.

Orchestration Patterns

Pattern When to Use Trade-offs
Sequential Each step needs previous result Slower, but data flows naturally
Parallel Independent outputs Faster, but requires error isolation
Mixed Sequential core + parallel fanout Best of both, more complex
Fan-out One input → multiple outputs Scales well, needs aggregation
Saga All-or-nothing transactions Safe rollback, more code

Sequential Steps

Each step depends on the previous:

async execute({ trigger, inputs, integrations }) {
  const { zoom, ai, notion, slack } = integrations;

  // Step 1: Get meeting data
  const meetingResult = await zoom.getMeeting(trigger.data.object.id);
  const transcriptResult = await zoom.getTranscript({ meetingId: meetingResult.data.id });

  // Step 2: AI processing (needs transcript)
  const summaryResult = await ai.generateText({
    prompt: `Summarize: ${transcriptResult.data.transcript_text}`,
  });
  const actionItems = await extractActionItems(transcriptResult.data.transcript_text, ai);

  // Step 3: Save to Notion (needs summary + actions)
  const page = await notion.pages.create({
    parent: { database_id: inputs.notionDatabase },
    properties: { Name: { title: [{ text: { content: meetingResult.data.topic } }] } },
    children: formatContent(summaryResult.data?.response, actionItems),
  });

  // Step 4: Notify (needs page URL)
  await slack.chat.postMessage({
    channel: inputs.slackChannel,
    text: `Meeting notes ready: ${page.data?.url}`,
  });

  return { success: true, pageId: page.data?.id };
}

Parallel Steps

Independent operations run concurrently:

async execute({ trigger, inputs, integrations }) {
  const { zoom, notion, slack, gmail } = integrations;

  const meetingResult = await zoom.getMeeting(trigger.data.object.id);
  const meeting = meetingResult.data;

  // Run independent steps in parallel
  const [notionResult, slackResult, emailResult] = await Promise.all([
    // Save to Notion
    notion.pages.create({
      parent: { database_id: inputs.notionDatabase },
      properties: { Name: { title: [{ text: { content: meeting.topic } }] } },
    }),

    // Post to Slack
    slack.chat.postMessage({
      channel: inputs.slackChannel,
      text: `Meeting "${meeting.topic}" ended`,
    }),

    // Draft email
    gmail.createDraft({
      to: meeting.host_email,
      subject: `Follow-up: ${meeting.topic}`,
      body: `Thanks for meeting today...`,
    }),
  ]);

  return {
    success: true,
    pageId: notionResult.data?.id,
    messageTs: slackResult.data?.ts,
    draftId: emailResult.data?.id,
  };
}

Mixed Pattern

Combine sequential and parallel:

async execute({ trigger, inputs, integrations }) {
  const { zoom, ai, notion, slack, gmail, crm } = integrations;

  // Sequential: must happen in order
  const meetingResult = await zoom.getMeeting(trigger.data.object.id);
  const transcriptResult = await zoom.getTranscript({ meetingId: meetingResult.data.id });
  const summary = await summarize(transcriptResult.data.transcript_text, ai);

  // Parallel: independent outputs
  const [page, message, draft] = await Promise.all([
    notion.pages.create({ /* uses summary */ }),
    slack.chat.postMessage({ /* uses summary */ }),
    gmail.createDraft({ /* uses summary */ }),
  ]);

  // Sequential: depends on parallel results
  await updateCRM(meetingResult.data, page.data?.url, crm);

  return { success: true };
}

Fan-Out Pattern

One trigger produces multiple outputs:

async execute({ trigger, inputs, integrations }) {
  const { notion, slack } = integrations;

  const data = trigger.data;

  // Fan out to multiple Notion databases
  const pagePromises = inputs.databases.map(dbId =>
    notion.pages.create({
      parent: { database_id: dbId },
      properties: formatForDatabase(data, dbId),
    })
  );

  // Fan out to multiple Slack channels
  const messagePromises = inputs.channels.map(channel =>
    slack.chat.postMessage({
      channel,
      text: formatForChannel(data, channel),
    })
  );

  const pages = await Promise.all(pagePromises);
  const messages = await Promise.all(messagePromises);

  return {
    success: true,
    pagesCreated: pages.length,
    messagesSent: messages.length,
  };
}

Saga Pattern (With Rollback)

For operations that must all succeed or all fail:

async execute({ inputs, integrations }) {
  const { stripe, notion, slack } = integrations;
  const completed: string[] = [];
  let charge: any, page: any;

  try {
    // Step 1
    const chargeResult = await stripe.createCharge({ amount: 1000 });
    charge = chargeResult.data;
    completed.push('charge');

    // Step 2
    const pageResult = await notion.pages.create({
      parent: { database_id: inputs.notionDatabase },
      properties: { /* invoice record */ },
    });
    page = pageResult.data;
    completed.push('notion');

    // Step 3
    await slack.chat.postMessage({
      channel: inputs.slackChannel,
      text: 'Payment received!',
    });
    completed.push('slack');

    return { success: true };
  } catch (error) {
    // Rollback completed steps
    for (const step of completed.reverse()) {
      try {
        switch (step) {
          case 'charge':
            await stripe.refundCharge(charge.id);
            break;
          case 'notion':
            await notion.pages.update({ page_id: page.id, archived: true });
            break;
          // Slack message doesn't need rollback
        }
      } catch (rollbackError) {
        console.error('Rollback failed', { step, error: rollbackError });
      }
    }

    return { success: false, error: (error as Error).message };
  }
}

Event-Driven Chaining

Multiple workflows in a single file (from packages/workflows/src/standup-bot/index.ts):

// Real example: packages/workflows/src/standup-bot/index.ts
import { defineWorkflow, schedule, webhook } from '@workwayco/sdk';

// Workflow 1: Post standup prompt in the morning
export default defineWorkflow({
  name: 'Standup Reminder Bot',
  description: 'Collect and share daily standups in Slack',

  integrations: [
    { service: 'slack', scopes: ['send_messages', 'read_messages'] },
    { service: 'notion', scopes: ['write_pages', 'read_databases'] },
  ],

  inputs: {
    standupChannel: {
      type: 'text',
      label: 'Standup Channel',
      required: true,
    },
    standupTime: {
      type: 'time',
      label: 'Standup Prompt Time',
      default: '09:00',
    },
    timezone: {
      type: 'timezone',
      label: 'Timezone',
      default: 'America/New_York',
    },
  },

  trigger: schedule({
    cron: '0 {{inputs.standupTime.hour}} * * 1-5',  // Weekdays
    timezone: '{{inputs.timezone}}',
  }),

  async execute({ inputs, integrations }) {
    const promptMessage = await integrations.slack.chat.postMessage({
      channel: inputs.standupChannel,
      text: `Good morning! Time for standup.`,
    });

    return {
      success: true,
      threadTs: promptMessage.data?.ts,
    };
  },
});

// Workflow 2: Collect responses and post summary
export const standupSummary = defineWorkflow({
  name: 'Standup Summary',
  description: 'Collect standup responses and post summary',

  integrations: [
    { service: 'slack', scopes: ['read_messages', 'send_messages'] },
  ],

  inputs: {
    standupChannel: { type: 'text', label: 'Standup Channel', required: true },
    summaryTime: { type: 'time', label: 'Summary Time', default: '10:00' },
    timezone: { type: 'timezone', label: 'Timezone', default: 'America/New_York' },
  },

  trigger: schedule({
    cron: '0 {{inputs.summaryTime.hour}} * * 1-5',
    timezone: '{{inputs.timezone}}',
  }),

  async execute({ inputs, integrations }) {
    // Get thread replies and post summary
    const history = await integrations.slack.conversations.history({
      channel: inputs.standupChannel,
      limit: 10,
    });

    // ... process and post summary

    return { success: true };
  },
});

Conditional Branches

Different paths based on data:

async execute({ trigger, inputs, integrations }) {
  const { ai, notion, slack, gmail } = integrations;

  const email = trigger.data;

  // AI classifies the email
  const classificationResult = await ai.generateText({
    prompt: `Classify: urgent, follow-up, or informational\n\n${email.subject}\n${email.body}`,
  });

  const category = classificationResult.data?.response?.trim().toLowerCase();

  // Branch based on classification
  switch (category) {
    case 'urgent':
      await slack.chat.postMessage({
        channel: inputs.urgentChannel,
        text: `🚨 Urgent email from ${email.from}: ${email.subject}`,
      });
      break;

    case 'follow-up':
      await notion.pages.create({
        parent: { database_id: inputs.followUpDatabase },
        properties: {
          Name: { title: [{ text: { content: email.subject } }] },
          Status: { select: { name: 'Needs Follow-up' } },
        },
      });
      break;

    case 'informational':
      // Archive with label
      await gmail.addLabel(email.id, inputs.infoLabelId);
      break;
  }

  return { success: true, classification: category };
}

State Machines

Complex workflows with multiple states:

type OrderState = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';

async execute({ trigger, inputs, integrations, storage }) {
  const orderId = trigger.data.orderId;
  const event = trigger.data.event;

  // Get current state
  const currentState = await storage.get(`order:${orderId}:state`) || 'pending';

  // State transition logic
  const transitions: Record<OrderState, Record<string, OrderState>> = {
    pending: { payment_received: 'paid', cancel: 'cancelled' },
    paid: { ship: 'shipped', refund: 'cancelled' },
    shipped: { deliver: 'delivered' },
    delivered: {},
    cancelled: {},
  };

  const nextState = transitions[currentState as OrderState]?.[event];

  if (!nextState) {
    return { success: false, error: `Invalid transition: ${currentState} -> ${event}` };
  }

  // Execute state-specific actions
  await executeStateActions(nextState, orderId, inputs, integrations);

  // Save new state
  await storage.put(`order:${orderId}:state`, nextState);

  return { success: true, previousState: currentState, newState: nextState };
}

async function executeStateActions(
  state: OrderState,
  orderId: string,
  inputs: any,
  integrations: any
) {
  switch (state) {
    case 'paid':
      await integrations.slack.chat.postMessage({
        channel: inputs.slackChannel,
        text: `Order ${orderId} paid!`,
      });
      break;
    case 'shipped':
      await integrations.gmail.sendEmail({
        to: inputs.customerEmail,
        subject: `Your order shipped!`,
        body: `Order ${orderId} is on its way.`,
      });
      break;
    case 'delivered':
      await integrations.notion.pages.update({
        page_id: orderId,
        properties: { Status: { select: { name: 'Complete' } } },
      });
      break;
  }
}

Best Practices

1. Fail Fast

Check prerequisites early:

async execute({ trigger, inputs }) {
  // Validate everything before doing anything
  if (!trigger.data?.object?.id) {
    return { success: false, error: 'Missing meeting ID' };
  }
  if (!inputs.notionDatabase) {
    return { success: false, error: 'Notion database not configured' };
  }

  // Now proceed with workflow
}

2. Idempotency

Handle duplicate triggers:

async execute({ trigger, storage }) {
  const eventId = trigger.data.id;
  const processedKey = `processed:${eventId}`;

  if (await storage.get(processedKey)) {
    return { success: true, skipped: true, reason: 'Already processed' };
  }

  // Process...

  await storage.put(processedKey, Date.now());
  return { success: true };
}

3. Observability

Log at key points:

async execute({ trigger, inputs, integrations }) {
  const start = Date.now();
  const { zoom, ai, notion } = integrations;

  console.log('Starting compound workflow', { triggerType: trigger.type });

  console.log('Step 1: Fetching meeting');
  const meetingResult = await zoom.getMeeting(trigger.data.object.id);

  console.log('Step 2: Generating summary');
  const summaryResult = await ai.generateText({ prompt: '...' });

  console.log('Step 3: Saving to Notion');
  const page = await notion.pages.create({ /* ... */ });

  console.log('Workflow complete', { pageId: page.data?.id, duration: Date.now() - start });
}

4. Graceful Degradation

Continue when non-critical steps fail:

async execute({ inputs, integrations }) {
  const { notion, slack, gmail } = integrations;

  const results: {
    notion: any;
    slack: any;
    email: any;
    errors: Array<{ step: string; error: string }>;
  } = {
    notion: null,
    slack: null,
    email: null,
    errors: [],
  };

  // Critical step
  results.notion = await notion.pages.create({ /* ... */ });

  // Non-critical steps
  try {
    results.slack = await slack.chat.postMessage({ /* ... */ });
  } catch (e) {
    results.errors.push({ step: 'slack', error: (e as Error).message });
  }

  try {
    results.email = await gmail.createDraft({ /* ... */ });
  } catch (e) {
    results.errors.push({ step: 'email', error: (e as Error).message });
  }

  return { success: true, ...results };
}

Praxis

Study real compound workflows in the WORKWAY codebase:

Praxis: Ask Claude Code: "Show me the meeting-intelligence workflow in packages/workflows/src/meeting-intelligence/index.ts"

This workflow demonstrates compound patterns:

  • Multiple triggers (cron + webhook)
  • Sequential data fetching → AI processing → multi-service output
  • Graceful degradation for optional integrations (HubSpot CRM)
  • Idempotency via Notion database queries

Map out the workflow:

  1. Trigger: What event starts everything?
  2. Sequential steps: What must happen in order?
  3. Parallel steps: What can happen simultaneously?
  4. Critical vs optional: Which steps can fail gracefully?

Implement the pattern:

async execute({ trigger, inputs, integrations }) {
  const start = Date.now();
  const { zoom, notion, slack, gmail } = integrations;

  // Critical: Get source data
  const meetingResult = await zoom.getMeeting(trigger.data.object.id);

  // Critical: Save to primary destination
  const page = await notion.pages.create({
    parent: { database_id: inputs.notionDatabase },
    properties: { /* ... */ },
  });

  // Parallel optional steps
  const [slackResult, emailResult] = await Promise.allSettled([
    slack.chat.postMessage({
      channel: inputs.slackChannel,
      text: `Notes ready: ${page.data?.url}`,
    }),
    gmail.createDraft({
      to: inputs.followUpEmail,
      subject: 'Meeting follow-up',
    }),
  ]);

  console.log('Compound workflow complete', {
    duration: Date.now() - start,
    slackSuccess: slackResult.status === 'fulfilled',
    emailSuccess: emailResult.status === 'fulfilled',
  });

  return { success: true, pageId: page.data?.id };
}

Reflection

  • What complete outcomes could compound workflows create for you?
  • How do you decide between sequential vs. parallel execution?
  • When is eventual consistency acceptable vs. requiring all-or-nothing?

Praxis — Hands-on Exercise

Design a compound workflow using Zoom, Notion, Slack, and email. Map sequential vs parallel steps and critical vs optional paths.

Try: Meeting Intelligence