Understanding the OpenAI Agents SDK
The OpenAI Agents SDK is a production-ready framework for building AI agents that can reason, use tools, and coordinate across multiple sub-agents. Released as an open-source Python library, it provides the scaffolding to turn a language model from a simple chatbot into an autonomous actor capable of executing multi-step workflows. At its core, the SDK introduces a few fundamental primitives: Agents (configured LLM instances with instructions and tools), Runners (the execution loop that manages agent invocations), Tools (callable functions the agent can invoke), Handoffs (delegation from one agent to another), and Guardrails (validation checks on inputs and outputs).
Rather than forcing you to build orchestration logic from scratch, the SDK handles the conversation loop, tool calling, context window management, and tracing out of the box. This lets you focus on defining your agent's personality, its available actions, and the guardrails that keep it safe and on-task.
Why a Sales Outreach Agent Matters
Sales outreach is a perfect proving ground for AI agents. It combines research-intensive preparation, personalization at scale, and structured follow-up sequencing—all tasks that require both creativity and discipline. A well-built sales outreach agent can:
- Research prospects by scraping public data, analyzing company websites, or pulling from your CRM
- Generate personalized emails that reference specific pain points, recent news, or mutual connections
- Score and prioritize leads based on fit criteria you define
- Schedule follow-ups and log interactions back to your CRM
- Escalate hot leads to human reps via handoff when the conversation requires nuanced judgment
By offloading the repetitive parts of outreach—initial research, draft generation, data entry—an agent lets your sales team spend time on high-value conversations while ensuring no lead falls through the cracks. The Agents SDK gives you the structured, traceable, and extensible foundation to build this system without reinventing the orchestration layer.
Setting Up Your Environment
Before writing any agent code, you need an OpenAI API key and the SDK installed. The library requires Python 3.9 or later.
# Create a virtual environment (recommended)
python -m venv sales-agent-env
source sales-agent-env/bin/activate # On Windows: sales-agent-env\Scripts\activate
# Install the Agents SDK
pip install openai-agents
# Set your API key as an environment variable
export OPENAI_API_KEY="sk-your-key-here" # On Windows: set OPENAI_API_KEY=sk-your-key-here
You'll also want to install a few additional dependencies for the tools we'll build—like requests for HTTP calls and pydantic for structured data models (which is already a dependency of the SDK). For CRM integration examples, we'll mock those endpoints, but in production you'd swap in your actual CRM client library.
pip install requests
Verify your setup with a minimal agent run:
import asyncio
from agents import Agent, Runner
async def main():
agent = Agent(name="TestAgent", instructions="You are a helpful assistant.")
result = await Runner.run(agent, "Say hello in exactly one sentence.")
print(result.final_output)
asyncio.run(main())
If you see a clean one-sentence greeting printed to the console, you're ready to build.
Building the Sales Outreach Agent: Step by Step
Designing the Agent's Persona and Core Instructions
The agent's instructions are the most critical piece—they define its role, tone, boundaries, and decision-making process. For a sales outreach agent, you want clear directives about researching before writing, maintaining professionalism, and knowing when to escalate. Here's a well-structured instruction block:
SALES_AGENT_INSTRUCTIONS = """You are a senior sales development representative (SDR) for Acme Corp, a B2B SaaS company
that sells project management software.
Your job is to:
1. Research prospects thoroughly before reaching out. Use available tools to find company information,
recent news, job postings, or tech stack clues.
2. Write personalized, concise outreach emails (3-5 sentences max) that reference your research.
Never send a generic template.
3. Score each prospect on a 1-10 scale based on: company size (>50 employees), relevant industry
(tech, construction, agencies), and signals of active need (job postings for PM roles, recent funding).
4. For leads scored 7+, suggest a follow-up sequence and offer to schedule a call.
5. For leads scored below 4, be honest that they may not be a good fit and suggest moving on.
6. Always be polite and professional. Never make up information you haven't researched.
7. If a prospect asks technical questions beyond your scope, hand off to a technical AE agent.
8. Log every interaction to the CRM so the team has full visibility.
"""
Building the Tools Your Agent Will Use
Tools are Python functions decorated with the SDK's @tool decorator. They can be async or sync, and they receive structured inputs defined via Pydantic models or docstrings. Let's build the essential tool set for sales outreach.
Tool 1: Company Research
import httpx
from agents import tool
from pydantic import BaseModel, Field
class CompanyResearchInput(BaseModel):
company_name: str = Field(description="Full company name to research")
domain: str = Field(description="Company website domain, e.g., acme.com")
class CompanyResearchOutput(BaseModel):
company_name: str
industry: str
estimated_size: str
recent_news_summary: str
tech_stack_indicators: list[str]
@tool
async def research_company(input: CompanyResearchInput) -> CompanyResearchOutput:
"""Research a prospect company using their website and public data sources.
Use this BEFORE writing any outreach email. Returns industry, size estimate,
recent news, and tech stack indicators gleaned from the company's web presence.
"""
# In production, you'd call Clearbit, Apollo, or scrape the website
# For this example, we simulate research with an HTTP request to a mock API
# Replace with your actual data enrichment provider
async with httpx.AsyncClient() as client:
# Simulated: in reality you'd use enrichment APIs or scraping
# This is a placeholder that demonstrates the pattern
response = await client.get(
f"https://your-data-service.example.com/api/company",
params={"domain": input.domain}
)
if response.status_code == 200:
data = response.json()
return CompanyResearchOutput(
company_name=data.get("name", input.company_name),
industry=data.get("industry", "Unknown"),
estimated_size=data.get("size", "Unknown"),
recent_news_summary=data.get("news", "No recent news found"),
tech_stack_indicators=data.get("tech_stack", [])
)
# Fallback: return structured default indicating research was limited
return CompanyResearchOutput(
company_name=input.company_name,
industry="Unknown — consider manual research",
estimated_size="Unknown",
recent_news_summary="Automated research unavailable. Check LinkedIn/news manually.",
tech_stack_indicators=[]
)
Tool 2: CRM Lookup
class CRMLookupInput(BaseModel):
email: str | None = Field(default=None, description="Email address to look up in CRM")
domain: str | None = Field(default=None, description="Company domain to look up")
class CRMLookupOutput(BaseModel):
found: bool
contact_name: str | None
company: str | None
previous_interactions: list[str]
current_deal_stage: str | None
@tool
async def lookup_crm(input: CRMLookupInput) -> CRMLookupOutput:
"""Check CRM for existing contact records or company history.
Always call this before reaching out to avoid duplicate outreach or
to reference previous interactions naturally in your email.
"""
# Replace with your actual CRM API call (Salesforce, HubSpot, Pipedrive, etc.)
# Example: await hubspot_client.get_contact_by_email(input.email)
# Simulated response for demonstration
# In production, integrate your CRM SDK here
return CRMLookupOutput(
found=True,
contact_name="Jane Smith",
company="BuildRight Construction",
previous_interactions=[
"2024-01: Downloaded whitepaper",
"2024-03: Attended webinar on project tracking"
],
current_deal_stage="awareness"
)
Tool 3: Email Drafting Helper
class DraftEmailInput(BaseModel):
recipient_name: str = Field(description="Prospect's full name")
company_name: str = Field(description="Company name")
research_highlights: list[str] = Field(description="2-3 key findings to personalize with")
previous_interaction_note: str | None = Field(default=None,
description="Reference to prior interaction if any")
goal: str = Field(default="Introduction and value proposition",
description="The desired outcome of the email")
class DraftEmailOutput(BaseModel):
subject_line: str
body: str
personalization_score: int # 1-10
@tool
async def draft_outreach_email(input: DraftEmailInput) -> DraftEmailOutput:
"""Draft a personalized outreach email based on research findings.
This tool generates a draft that the agent can then refine. It ensures
emails are grounded in actual research rather than hallucinated details.
"""
# In production, you might use a specialized prompt or template engine
# The agent will further refine this draft in its final output
subject = f"Quick thought on {input.company_name}'s project workflow"
body_lines = [f"Hi {input.recipient_name},"]
if input.previous_interaction_note:
body_lines.append(f"I noticed you {input.previous_interaction_note} — great to see your interest.")
for highlight in input.research_highlights[:3]:
body_lines.append(f"I saw that {highlight} — thought that might resonate with some of the challenges our customers face.")
body_lines.append("Would you be open to a 15-minute chat this week to explore if there's a fit?")
body_lines.append("Best,\nAlex from Acme Corp")
return DraftEmailOutput(
subject_line=subject,
body="\n\n".join(body_lines),
personalization_score=7
)
Tool 4: Lead Scoring
class ScoreLeadInput(BaseModel):
company_name: str
industry: str
estimated_size: str
signals_of_need: list[str] # e.g., ["Hiring PM", "Recent funding", "Using competitor"]
class ScoreLeadOutput(BaseModel):
score: int # 1-10
justification: str
recommended_action: str # "pursue", "nurture", "deprioritize"
@tool
async def score_lead(input: ScoreLeadInput) -> ScoreLeadOutput:
"""Score a lead based on fit and intent signals.
Returns a score from 1-10 and a recommended action.
Use this after research to prioritize your outreach queue.
"""
score = 5 # Start neutral
# Industry fit
high_fit_industries = ["technology", "construction", "agency", "software", "architecture"]
if any(ind.lower() in input.industry.lower() for ind in high_fit_industries):
score += 2
# Size fit (>50 employees ideal for B2B PM software)
size_str = input.estimated_size.lower()
if "50" in size_str or "100" in size_str or "enterprise" in size_str or "mid-size" in size_str:
score += 1
elif "small" in size_str or "1-10" in size_str or "startup" in size_str and "funding" not in str(input.signals_of_need).lower():
score -= 2
# Intent signals
for signal in input.signals_of_need:
if any(term in signal.lower() for term in ["hiring pm", "project manager", "funding", "growth", "scaling"]):
score += 1
# Clamp
score = max(1, min(10, score))
if score >= 7:
action = "pursue"
elif score >= 4:
action = "nurture"
else:
action = "deprioritize"
return ScoreLeadOutput(
score=score,
justification=f"Industry: {input.industry}, Size: {input.estimated_size}, Signals: {len(input.signals_of_need)} found.",
recommended_action=action
)
Tool 5: Log to CRM
class LogInteractionInput(BaseModel):
contact_email: str
activity_type: str # "email_sent", "call_scheduled", "research_completed", "lead_scored"
summary: str
agent_name: str = "SalesOutreachAgent"
@tool
async def log_to_crm(input: LogInteractionInput) -> str:
"""Log an activity to the CRM for team visibility.
Call this after every significant action: research completed, email sent,
lead scored, or call scheduled. Creates an immutable activity record.
"""
# In production: await crm_client.create_activity(...)
# Simulated success
return f"Logged {input.activity_type} for {input.contact_email}: {input.summary[:80]}..."
Assembling the Agent
With tools defined, we create the agent by passing instructions, tools, and optional configuration like the model choice and output type. The SDK supports both chat-based agents (default) and structured-output agents.
from agents import Agent, Runner, trace
# Create the main sales outreach agent
sales_agent = Agent(
name="SalesOutreachAgent",
instructions=SALES_AGENT_INSTRUCTIONS,
tools=[
research_company,
lookup_crm,
draft_outreach_email,
score_lead,
log_to_crm,
],
model="gpt-4o", # Recommended for complex tool-use workflows
# Optional: set max turns to prevent infinite loops
# max_turns=10,
)
Running the Agent
The Runner is the execution engine. You call Runner.run() with an agent and a user input (which can be a string or a list of messages). The runner handles the conversation loop: it sends the model's response, detects tool calls, executes them, feeds results back, and repeats until the model produces a final text response or you hit a turn limit.
async def run_sales_workflow(prospect_email: str, prospect_name: str, company_domain: str):
"""Execute the full sales outreach research-and-draft workflow."""
# Build a detailed prompt that triggers the full workflow
user_prompt = f"""
Please execute the complete sales outreach workflow for this prospect:
- Contact: {prospect_name} ({prospect_email})
- Company domain: {company_domain}
Steps to follow in order:
1. Look up this contact and domain in the CRM
2. Research the company thoroughly
3. Score the lead based on your research
4. If score is 4+, draft a personalized outreach email
5. Log all activities to CRM
Report your findings, the score with justification, and the draft email.
"""
# Use trace for observability — logs the entire agent run
with trace("Sales Outreach Workflow"):
result = await Runner.run(
agent=sales_agent,
input=user_prompt,
# Optional: pass context that tools can access
# context={"crm_api_key": os.getenv("CRM_API_KEY")},
)
return result
# Execute
async def main():
result = await run_sales_workflow(
prospect_email="jane@buildright.com",
prospect_name="Jane Smith",
company_domain="buildright.com"
)
print("FINAL OUTPUT:")
print(result.final_output)
print("\n--- Tool Calls Made ---")
for i, tool_call in enumerate(result.tool_calls or []):
print(f" {i+1}. {tool_call.tool_name}")
asyncio.run(main())
Understanding the Runner's Response Object
The result object returned by Runner.run() contains rich information beyond just the final text:
result.final_output— the final text response from the agentresult.tool_calls— list of all tool invocations made during the run, each with tool name, arguments, and outputresult.messages— the full conversation history including tool messagesresult.last_agent— which agent produced the final output (useful in multi-agent handoff scenarios)
This structure makes it easy to audit what the agent did, extract structured data from tool outputs, and build post-processing pipelines.
Advanced Patterns: Handoffs and Multi-Agent Orchestration
When to Use Handoffs
Sales outreach often requires escalation. A prospect might ask a deeply technical question, request a custom demo, or raise an objection that requires human nuance. Instead of cramming all capabilities into one agent (which bloats instructions and increases error rates), the SDK lets you define specialized sub-agents and hand off to them.
Creating a Technical AE Agent for Handoff
technical_ae_agent = Agent(
name="TechnicalAE",
instructions="""You are a technical account executive at Acme Corp. You handle detailed product questions,
API capabilities, security compliance, and custom integration discussions.
When you receive a handoff, review the conversation history provided and
answer the prospect's technical questions thoroughly. If you need to check
documentation, use the knowledge_base_search tool available to you.
At the end of your response, suggest next steps (e.g., scheduling a technical
discovery call).
""",
tools=[
# knowledge_base_search, # Would be defined similar to other tools
],
model="gpt-4o",
)
# Add the technical AE as a handoff target on the main sales agent
sales_agent_with_handoff = Agent(
name="SalesOutreachAgent",
instructions=SALES_AGENT_INSTRUCTIONS + """
HANDOFF RULES:
- If a prospect asks a technical question about APIs, integrations, security,
or product architecture, IMMEDIATELY hand off to TechnicalAE using the handoff tool.
- Do not attempt to answer deep technical questions yourself — you are an SDR, not an AE.
- When handing off, provide a brief summary of the conversation so far.
""",
tools=[
research_company,
lookup_crm,
draft_outreach_email,
score_lead,
log_to_crm,
],
handoffs=[technical_ae_agent], # Register handoff targets
model="gpt-4o",
)
When the main agent encounters a technical question, it will call the handoff tool automatically. The SDK passes the conversation history to the TechnicalAE agent, which picks up where the SDR left off. The result.last_agent field tells you which agent finished the interaction.
Handling the Handoff in Your Application Code
async def handle_prospect_conversation(user_message: str, conversation_history: list | None = None):
"""Process a prospect message, potentially involving handoffs."""
with trace("Prospect Conversation"):
result = await Runner.run(
agent=sales_agent_with_handoff,
input=user_message,
# Pass conversation history if continuing an existing thread
# previous_messages=conversation_history,
)
# Determine if a handoff occurred
if result.last_agent and result.last_agent.name == "TechnicalAE":
print("Handoff occurred — Technical AE responded.")
# You might trigger a notification to the real AE team here
else:
print("SDR agent handled the response directly.")
return {
"response": result.final_output,
"handled_by": result.last_agent.name if result.last_agent else "SalesOutreachAgent",
"tool_calls": [tc.tool_name for tc in (result.tool_calls or [])],
}
Adding Guardrails for Safety and Quality
Guardrails validate inputs and outputs, preventing the agent from going off-script or producing harmful content. The SDK supports both input guardrails (which run before the agent processes a message) and output guardrails (which check the agent's response before it's returned).
from agents import Guardrail, InputGuardrailResult, OutputGuardrailResult
from pydantic import BaseModel
class SalesEmailOutputCheck(BaseModel):
"""Output guardrail model for sales email quality."""
has_personalization: bool
mentions_research: bool
appropriate_tone: bool
contains_hallucination_risk: bool
@Guardrail # Output guardrail
async def sales_email_quality_guard(output: str, agent: Agent, context: dict) -> OutputGuardrailResult:
"""Ensure the sales email meets quality standards before delivery."""
# Check for key quality indicators
checks = {
"has_personalization": any(marker in output.lower() for marker in
["i saw", "noticed your", "congratulations on", "your team"]),
"mentions_research": any(marker in output.lower() for marker in
["according to", "research shows", "i researched", "i found"]),
"appropriate_tone": len(output.split()) > 10 and "dear sir" not in output.lower(),
"contains_hallucination_risk": "guaranteed" in output.lower() or "promise" in output.lower()
}
# If hallucination risk detected, trigger the guardrail
if checks["contains_hallucination_risk"]:
return OutputGuardrailResult(
triggered=True,
message="Output contains language that implies guarantees — revise to be more measured.",
remediation="Remove absolute promises and replace with value-proposition language."
)
# If missing personalization, trigger a softer guardrail
if not checks["has_personalization"]:
return OutputGuardrailResult(
triggered=True,
message="Email lacks personalization markers. Add specific research findings.",
remediation="Reference the prospect's company, recent news, or role explicitly."
)
return OutputGuardrailResult(triggered=False)
# Attach guardrail to the agent
sales_agent_with_guardrails = Agent(
name="SalesOutreachAgent",
instructions=SALES_AGENT_INSTRUCTIONS,
tools=[research_company, lookup_crm, draft_outreach_email, score_lead, log_to_crm],
output_guardrails=[sales_email_quality_guard],
model="gpt-4o",
)
When a guardrail triggers, the SDK can either block the output, route it for revision, or flag it for human review. You configure this behavior in your application's runner logic.
Building a Complete Pipeline: From Lead List to Outreach
Here's a production-ready pipeline that processes a batch of leads, researches each, scores them, drafts emails for qualified prospects, and logs everything:
import asyncio
import json
from dataclasses import dataclass
from agents import Agent, Runner, trace
@dataclass
class Lead:
email: str
name: str
domain: str
async def process_single_lead(lead: Lead, agent: Agent) -> dict:
"""Process one lead: research, score, draft, log."""
prompt = f"""
Execute the full outreach workflow for:
Contact: {lead.name} ({lead.email})
Domain: {lead.domain}
1. CRM lookup
2. Company research
3. Lead scoring
4. Draft email if score >= 4
5. Log all activities
At the end, output a JSON summary with keys: score, recommended_action,
email_subject, email_body, and activity_log.
"""
with trace(f"Lead: {lead.email}"):
result = await Runner.run(agent=agent, input=prompt)
return {
"lead_email": lead.email,
"output": result.final_output,
"tool_calls_made": len(result.tool_calls or []),
}
async def process_lead_batch(leads: list[Lead]):
"""Process multiple leads concurrently."""
agent = Agent(
name="SalesOutreachAgent",
instructions=SALES_AGENT_INSTRUCTIONS,
tools=[research_company, lookup_crm, draft_outreach_email, score_lead, log_to_crm],
model="gpt-4o",
)
# Process leads with controlled concurrency (max 5 at a time)
semaphore = asyncio.Semaphore(5)
async def bounded_process(lead):
async with semaphore:
return await process_single_lead(lead, agent)
tasks = [bounded_process(lead) for lead in leads]
results = await asyncio.gather(*tasks)
# Save results for review
with open("outreach_results.json", "w") as f:
json.dump(results, f, indent=2, default=str)
return results
# Example usage
sample_leads = [
Lead(email="jane@buildright.com", name="Jane Smith", domain="buildright.com"),
Lead(email="mike@techstart.io", name="Mike Chen", domain="techstart.io"),
Lead(email="sarah@agencyprime.co", name="Sarah Johnson", domain="agencyprime.co"),
]
asyncio.run(process_lead_batch(sample_leads))
Best Practices for Production Sales Agents
- Keep instructions focused and layered. Put core personality and rules in the main instructions. Add context-specific rules (like handoff criteria) as appendices. Avoid monolithic instruction blocks that confuse the model.
- Design tools with clear single responsibilities. Each tool should do one thing well. A tool that both researches a company AND scores it is harder to debug and less flexible. Compose multiple tools in sequence instead.
- Use structured outputs (Pydantic models) for all tool inputs and outputs. This gives the model clear contracts and makes your tool implementations robust against malformed arguments.
- Implement guardrails at both input and output levels. Input guardrails can filter out inappropriate requests before they consume tokens. Output guardrails catch hallucinations, off-brand messaging, or compliance violations before the message reaches a prospect.
- Always use tracing. The
trace()context manager logs every step the agent takes. In production, feed these traces to your observability stack (the SDK integrates with OpenTelemetry). When an agent produces a bad email, you need to see exactly which tool calls led to it. - Set a reasonable
max_turns. For sales workflows, 8-12 turns is usually sufficient. This prevents runaway loops where the agent keeps refining an email indefinitely. - Human-in-the-loop for high-stakes actions. Don't let the agent send emails autonomously to prospects scored 8+ without human approval. Build a review step where a human SDR can edit the draft before it goes out. The agent should propose, not decide unilaterally on high-value outreach.
- Version your agent configurations. Treat agent instructions and tool sets like code. Store them in version control, test changes against a golden set of test prospects, and roll out incrementally.
- Monitor token usage and latency. Each research-heavy run can consume significant tokens. Track cost per lead processed and optimize tool implementations (e.g., caching research results for the same domain within a session).
- Test handoff paths explicitly. Write integration tests that simulate a prospect asking technical questions and verify that the handoff to the TechnicalAE agent fires correctly. Handoff failures in production lead to poor prospect experiences.
Conclusion
Building a sales outreach agent with the OpenAI Agents SDK transforms a traditionally manual, repetitive workflow into an intelligent, scalable pipeline. The SDK gives you the primitives—agents, tools, handoffs, and guardrails—to construct a system that researches prospects, drafts personalized communications, scores leads, and escalates appropriately, all while maintaining full traceability. The key to success lies in thoughtful instruction design, clean tool boundaries, and a commitment to human oversight for high-stakes decisions. Start with the patterns shown here, integrate your real CRM and data enrichment APIs, add guardrails tailored to your brand voice, and iterate based on trace data. The result is an outreach engine that amplifies your sales team's effectiveness without sacrificing the personal touch that wins deals.