Stevora

AI SDR Outreach

Build an AI-powered sales development workflow

AI SDR Outreach

This example builds a complete AI sales development representative (SDR) that researches a prospect, drafts a personalized cold email, pauses for human approval, sends the email, waits for a reply, and analyzes the response -- all as a single durable workflow.

It demonstrates every major Stevora capability in one workflow: LLM steps with tool calling, human-in-the-loop approvals, conditional branching, durable waits, external event signals, and cost tracking.

What the Workflow Does

Research Prospect    Draft Email     Human Review     Send Email
   (LLM + tools) ──> (LLM) ──────> (Approval) ──┬──> (Task)
                                                  │       │
                                                  │   Wait 3 days
                                                  │       │
                                              Rejected  Check reply
                                                  │    (External Event)
                                              Mark       │
                                              Skipped  Analyze Reply
                                                       (LLM)

                                                      Complete
  1. Research Prospect -- An LLM step calls search_company and search_person tools to gather background on the prospect. Returns structured JSON with company size, industry, recent news, and a personal angle.
  2. Draft Email -- A second LLM step uses the research to write a personalized cold email. Guardrails enforce schema validation, content safety, and a confidence threshold.
  3. Human Review -- An approval step pauses the workflow and waits (up to 24 hours) for a human to approve, edit, or reject the drafted email.
  4. Branch on Decision -- A condition step checks the approval result. If approved, execution continues to sending. If rejected, the prospect is marked as skipped.
  5. Send Email -- A task step calls your email provider (SendGrid, Resend, etc.) to deliver the message.
  6. Wait for Reply -- A wait step pauses the workflow for 3 days, giving the prospect time to respond.
  7. Check for Reply -- An external event step listens for a prospect_replied signal. Your email system sends this signal when a reply arrives.
  8. Analyze Reply -- A final LLM step reads the prospect's reply and classifies their interest level, sentiment, and suggested next action (book meeting, send more info, follow up, or close).

Workflow Definition

The full workflow definition uses all six Stevora step types. Register it by calling defineWorkflow() in your server-side code or by submitting it through the REST API.

Server-side registration

src/workflows/ai-sdr-outreach.ts
import { defineWorkflow } from '../modules/workflow-definitions/workflow-definition.registry.js';

defineWorkflow({
  name: 'ai-sdr-outreach',
  version: '1',
  description: 'AI SDR: research prospect, draft email, get approval, send, wait, follow up',
  steps: [
    // Step 1: AI researches the prospect using tools
    {
      type: 'llm',
      name: 'research-prospect',
      model: 'gpt-4o',
      fallbackModels: ['claude-sonnet-4-20250514'],
      systemPrompt:
        'You are a sales research assistant. Research the prospect and return structured data. Use the provided tools to gather information.',
      messages: [
        {
          role: 'user',
          content:
            'Research this prospect:\nName: {{input.prospectName}}\nCompany: {{input.company}}\nEmail: {{input.email}}\n\nUse the search_company and search_person tools, then return a JSON summary.',
        },
      ],
      responseFormat: 'json',
      outputSchema: {
        prospectName: 'string',
        company: 'string',
        companySize: 'string',
        industry: 'string',
        role: 'string',
        recentNews: 'string',
        personalNote: 'string',
      },
      tools: [
        {
          name: 'search_company',
          description: 'Search for company information',
          parameters: {
            type: 'object',
            properties: {
              company: { type: 'string', description: 'Company name' },
            },
            required: ['company'],
          },
        },
        {
          name: 'search_person',
          description: 'Search for person information on LinkedIn',
          parameters: {
            type: 'object',
            properties: {
              name: { type: 'string', description: 'Person name' },
              company: { type: 'string', description: 'Company name' },
            },
            required: ['name'],
          },
        },
      ],
      guardrails: {
        postChecks: [{ type: 'schema_validation' }, { type: 'content_safety' }],
        onFailure: 'retry_with_feedback',
      },
      retry: { maxAttempts: 3, backoffMs: 2000, backoffMultiplier: 2 },
    },

    // Step 2: AI drafts a personalized email based on research
    {
      type: 'llm',
      name: 'draft-email',
      model: 'claude-sonnet-4-20250514',
      fallbackModels: ['gpt-4o'],
      systemPrompt:
        'You are a top-performing SDR. Write personalized, concise cold emails. No generic templates. Reference specific details from the research. Keep it under 150 words. Return JSON with subject and body fields.',
      messages: [
        {
          role: 'user',
          content:
            'Write a cold email to {{state.prospectName}} ({{state.role}} at {{state.company}}).\n\nResearch notes:\n- Industry: {{state.industry}}\n- Company size: {{state.companySize}}\n- Recent news: {{state.recentNews}}\n- Personal note: {{state.personalNote}}\n\nOur product: {{input.productDescription}}\n\nReturn JSON: {"subject": "...", "body": "..."}',
        },
      ],
      responseFormat: 'json',
      outputSchema: { subject: 'string', body: 'string' },
      guardrails: {
        postChecks: [
          { type: 'schema_validation' },
          { type: 'content_safety' },
          { type: 'confidence_threshold' },
        ],
        onFailure: 'retry_with_feedback',
      },
      retry: { maxAttempts: 2, backoffMs: 1000, backoffMultiplier: 2 },
    },

    // Step 3: Human reviews the email before sending
    {
      type: 'approval',
      name: 'review-email',
      contentKey: 'subject',
      prompt:
        'Review this AI-generated cold email. Approve to send, edit to modify, or reject to skip this prospect.',
      timeoutMs: 86400000, // 24 hours
    },

    // Step 4: Branch on approval decision
    {
      type: 'condition',
      name: 'check-approval',
      expression: '__approval_decision',
      trueStep: 'send-email',
      falseStep: 'mark-skipped',
    },

    // Step 5: Send the email
    {
      type: 'task',
      name: 'send-email',
      handler: 'send-prospect-email',
      retry: { maxAttempts: 3, backoffMs: 2000, backoffMultiplier: 2 },
    },

    // Step 6: Wait 3 days for reply
    {
      type: 'wait',
      name: 'wait-for-reply',
      durationMs: 259200000, // 3 days
    },

    // Step 7: Check if they replied
    {
      type: 'external_event',
      name: 'check-reply',
      signalType: 'prospect_replied',
      timeoutMs: 3600000, // 1 hour timeout
    },

    // Step 8: AI analyzes the reply sentiment
    {
      type: 'llm',
      name: 'analyze-reply',
      model: 'gpt-4o-mini',
      systemPrompt:
        'Analyze the prospect reply. Return JSON with: interested (boolean), sentiment (positive/neutral/negative), suggestedAction (book_meeting/send_info/follow_up/close).',
      messages: [
        {
          role: 'user',
          content:
            'Prospect {{state.prospectName}} replied:\n\n{{state.__signal_prospect_replied.replyText}}\n\nAnalyze their interest level.',
        },
      ],
      responseFormat: 'json',
      outputSchema: {
        interested: 'boolean',
        sentiment: 'string',
        suggestedAction: 'string',
      },
    },

    // Step 9: Complete
    {
      type: 'task',
      name: 'complete-outreach',
      handler: 'complete-outreach',
    },

    // Step for skipped prospects (branched from condition)
    {
      type: 'task',
      name: 'mark-skipped',
      handler: 'mark-skipped',
    },
  ],
});

The {{input.*}} placeholders reference data you pass when starting a run. The {{state.*}} placeholders reference accumulated output from previous steps. The __approval_decision and __signal_prospect_replied keys are set automatically by the engine for approval and external event steps.

Registering Tool Handlers

LLM steps can call tools during execution. You must register a handler for each tool so the engine knows how to execute them. In production, replace the stub implementations with real API calls to LinkedIn, Clearbit, or your data provider.

src/workflows/ai-sdr-outreach.ts
import { registerTool } from '../modules/tool-tracing/tool-registry.js';

registerTool('search_company', async (input) => {
  const company = input['company'] as string;

  // In production: call Clearbit, LinkedIn, or a web scraper
  const response = await fetch(`https://api.clearbit.com/v2/companies/find?name=${company}`, {
    headers: { Authorization: `Bearer ${process.env.CLEARBIT_API_KEY}` },
  });
  const data = await response.json();

  return {
    name: data.name,
    size: data.metrics?.employeesRange ?? 'unknown',
    industry: data.category?.industry ?? 'unknown',
    founded: data.foundedYear?.toString() ?? 'unknown',
    recentNews: data.description ?? '',
    website: data.domain ?? '',
  };
});

registerTool('search_person', async (input) => {
  const name = input['name'] as string;
  const company = (input['company'] as string) ?? 'Unknown';

  // In production: call People Data Labs, LinkedIn API, etc.
  return {
    name,
    company,
    role: 'VP of Engineering',
    bio: `${name} has been leading engineering at ${company}.`,
    interests: ['distributed systems', 'developer tools'],
  };
});

Every tool call is automatically traced by Stevora. You can inspect tool inputs, outputs, duration, and status through the traces API after the run completes.

Registering Task Handlers

Task steps need registered handlers. These execute your custom business logic -- sending emails, updating CRM records, or logging outcomes.

src/workflows/ai-sdr-outreach.ts
import { registerTaskHandler } from '../modules/execution-engine/step-handlers.js';

registerTaskHandler('send-prospect-email', async (ctx) => {
  const subject = ctx.workflowState['subject'] as string;
  const body = ctx.workflowState['body'] as string;
  const email = ctx.workflowInput['email'] as string;

  // Call your email provider (SendGrid, Resend, AWS SES, etc.)
  await sendEmail({ to: email, subject, body });

  return {
    status: 'completed',
    output: {
      emailSent: true,
      to: email,
      subject,
      sentAt: new Date().toISOString(),
    },
    stateUpdates: { emailSent: true, emailSentAt: new Date().toISOString() },
  };
});

registerTaskHandler('complete-outreach', async (ctx) => {
  return {
    status: 'completed',
    output: {
      prospectName: ctx.workflowState['prospectName'],
      result: ctx.workflowState['suggestedAction'] ?? 'completed',
      sentiment: ctx.workflowState['sentiment'] ?? 'unknown',
    },
  };
});

registerTaskHandler('mark-skipped', async (ctx) => {
  return {
    status: 'completed',
    output: { skipped: true, reason: 'email_rejected_by_reviewer' },
  };
});

The stateUpdates field in a task handler's return value merges into the workflow state. This is how you pass data between steps -- for example, setting emailSent: true so later steps can check whether the email was delivered.

Running the Workflow

Using the SDK

run-sdr.ts
import { AgentRuntime } from '@stevora/sdk';

const runtime = new AgentRuntime({
  apiKey: process.env.STEVORA_API_KEY!,
  baseUrl: 'http://localhost:3000',
});

async function main() {
  // Start the outreach workflow
  const run = await runtime.workflows.create({
    definitionId: 'YOUR_DEFINITION_ID',
    input: {
      prospectName: 'Sarah Chen',
      company: 'TechFlow AI',
      email: 'sarah@techflow.ai',
      productDescription:
        'Stevora -- durable execution platform for AI agent workflows. ' +
        'Automatic retries, cost tracking, human-in-the-loop approvals.',
    },
  });

  console.log(`Run created: ${run.id}`);
  console.log(`Status: ${run.status}`);

  // Poll until the workflow needs approval or finishes
  let current = run;
  while (!['WAITING', 'COMPLETED', 'FAILED', 'CANCELLED'].includes(current.status)) {
    await new Promise((r) => setTimeout(r, 2000));
    current = await runtime.workflows.get(run.id);
  }

  console.log(`Status: ${current.status}`);
  console.log(`Current step: ${current.currentStepName}`);

  // Check for pending approval
  const approvals = await runtime.approvals.list({ pending: true });
  const myApproval = approvals.find((a) => a.workflowRunId === run.id);

  if (myApproval) {
    console.log('Approval requested!');
    console.log(`Content: ${JSON.stringify(myApproval.content)}`);

    // Approve the email (or reject with runtime.approvals.reject())
    await runtime.approvals.approve(myApproval.id, 'sales-manager');

    // Wait for the workflow to finish
    const final = await runtime.workflows.waitForCompletion(run.id, {
      pollIntervalMs: 1000,
      timeoutMs: 30000,
    });
    console.log(`Final status: ${final.status}`);
  }

  // Check cost
  const cost = await runtime.workflows.getCost(run.id);
  console.log(`Total cost: $${cost.totalCostDollars}`);
  for (const call of cost.calls) {
    console.log(
      `  ${call.model} -- ${call.inputTokens}in/${call.outputTokens}out -- ${call.costCents} cents`
    );
  }

  // Inspect tool traces
  const traces = await runtime.workflows.getTraces(run.id);
  for (const trace of traces) {
    console.log(`LLM: ${trace.model} (${trace.status})`);
    for (const tool of trace.toolTraces ?? []) {
      console.log(`  -> ${tool.toolName} (${tool.status}, ${tool.durationMs}ms)`);
    }
  }
}

main().catch(console.error);

Run it:

STEVORA_API_KEY=stv_k1_your_key npx tsx run-sdr.ts

Using the REST API

You can also drive the workflow entirely through HTTP calls.

Start the run:

curl -X POST http://localhost:3000/v1/workflow-runs \
  -H "x-api-key: stv_k1_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "definitionId": "YOUR_DEFINITION_ID",
    "input": {
      "prospectName": "Sarah Chen",
      "company": "TechFlow AI",
      "email": "sarah@techflow.ai",
      "productDescription": "Stevora -- durable execution for AI agent workflows"
    }
  }'

Check status:

curl http://localhost:3000/v1/workflow-runs/RUN_ID \
  -H "x-api-key: stv_k1_your_key"

List pending approvals:

curl "http://localhost:3000/v1/approval-requests?pending=true" \
  -H "x-api-key: stv_k1_your_key"

Approve the email:

curl -X POST http://localhost:3000/v1/approval-requests/APPROVAL_ID/decide \
  -H "x-api-key: stv_k1_your_key" \
  -H "Content-Type: application/json" \
  -d '{"decision": "approved", "decidedBy": "sales-manager"}'

Send a reply signal (when the prospect responds to your email):

curl -X POST http://localhost:3000/v1/workflow-runs/RUN_ID/resume \
  -H "x-api-key: stv_k1_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "signalType": "prospect_replied",
    "payload": {
      "replyText": "Thanks for reaching out! I would love to learn more about Stevora. Can we schedule a call next week?"
    }
  }'

Check cost and traces:

curl http://localhost:3000/v1/workflow-runs/RUN_ID/cost \
  -H "x-api-key: stv_k1_your_key"

curl http://localhost:3000/v1/workflow-runs/RUN_ID/traces \
  -H "x-api-key: stv_k1_your_key"

Key Patterns Demonstrated

PatternWhereDescription
LLM with toolsresearch-prospectThe model calls search_company and search_person during generation. Every tool call is traced.
Model fallbackresearch-prospect, draft-emailIf the primary model fails, the engine automatically retries with the fallback model.
Guardrailsdraft-emailSchema validation, content safety, and confidence thresholds are checked after each LLM response.
Human-in-the-loopreview-emailThe workflow pauses until a human approves, edits, or rejects the AI-generated content.
Conditional branchingcheck-approvalThe workflow takes different paths based on the approval decision.
Durable waitwait-for-replyThe workflow sleeps for 3 days. If the server restarts, it resumes on schedule.
External eventscheck-replyThe workflow waits for an external signal from your email system.
Structured outputAll LLM stepsresponseFormat: 'json' with outputSchema ensures the model returns parseable, typed data.
Automatic retriesresearch-prospect, send-emailFailed steps retry with exponential backoff before marking the workflow as failed.

Running the Interactive Test Script

Stevora ships with an interactive bash script that walks through the entire workflow end-to-end. It starts the run, waits for the AI to research and draft, shows you the cost and traces, then asks you to approve or reject the email.

# Prerequisites: API server + worker running, database seeded
API_KEY=stv_k1_your_key DEF_ID=your_definition_id bash scripts/test-ai-sdr.sh

The script uses real LLM calls (costs approximately $0.01 per run) and demonstrates every step of the workflow in your terminal.

Next Steps