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- Research Prospect -- An LLM step calls
search_companyandsearch_persontools to gather background on the prospect. Returns structured JSON with company size, industry, recent news, and a personal angle. - 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.
- 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.
- Branch on Decision -- A condition step checks the approval result. If approved, execution continues to sending. If rejected, the prospect is marked as skipped.
- Send Email -- A task step calls your email provider (SendGrid, Resend, etc.) to deliver the message.
- Wait for Reply -- A wait step pauses the workflow for 3 days, giving the prospect time to respond.
- Check for Reply -- An external event step listens for a
prospect_repliedsignal. Your email system sends this signal when a reply arrives. - 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
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.
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.
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
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.tsUsing 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
| Pattern | Where | Description |
|---|---|---|
| LLM with tools | research-prospect | The model calls search_company and search_person during generation. Every tool call is traced. |
| Model fallback | research-prospect, draft-email | If the primary model fails, the engine automatically retries with the fallback model. |
| Guardrails | draft-email | Schema validation, content safety, and confidence thresholds are checked after each LLM response. |
| Human-in-the-loop | review-email | The workflow pauses until a human approves, edits, or rejects the AI-generated content. |
| Conditional branching | check-approval | The workflow takes different paths based on the approval decision. |
| Durable wait | wait-for-reply | The workflow sleeps for 3 days. If the server restarts, it resumes on schedule. |
| External events | check-reply | The workflow waits for an external signal from your email system. |
| Structured output | All LLM steps | responseFormat: 'json' with outputSchema ensures the model returns parseable, typed data. |
| Automatic retries | research-prospect, send-email | Failed 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.shThe script uses real LLM calls (costs approximately $0.01 per run) and demonstrates every step of the workflow in your terminal.
Next Steps
- Workflows & Steps -- Understand the execution model and state machine
- SDK Reference -- Full TypeScript SDK documentation
- API Reference -- REST API endpoints for runs, approvals, and signals
- Docker Deployment -- Deploy this workflow to production