Agent Playbook, build tutorial
A walkthrough of how to build an agent that catches every lead the moment it arrives, from a missed call, WhatsApp, a web form, or an ad, acknowledges in seconds, has a real conversation to qualify it, scores it against your rules, and routes a hot lead to a human while it lands. Every code step is shown in three frameworks, Google ADK, the Claude SDK, and LangGraph, so you can build it on the stack your team already knows. The code here is the agent's core logic; standing it up in production also means adding the setup, integrations, and hardening around it.
You pay to generate leads, then lose them in the gap before anyone replies. A Harvard Business Review audit of 2,241 US companies found the average first response to an online lead was about 42 hours, and the MIT Sloan Lead Response Management study found that contacting a lead within five minutes rather than thirty makes you roughly 21 times more likely to qualify it and 100 times more likely to reach them at all. Interest does not wait. The lead who filled your form is filling a competitor's too.
A plain autoresponder sends one canned message and stops. An agent is different: it acknowledges instantly, then actually talks, understands Hindi or English, figures out what the person needs and how ready they are, and gets the good ones in front of a human while they are still paying attention. The rest it nurtures instead of dropping.
You do not build the agent loop from scratch. A framework gives you the moving parts: a step is a model plus an instruction plus a few typed tools, and a tool is just a Python function. The code tabs on the build steps below show the same capture, qualify, score, and route flow three ways, so you can choose by what your team already runs.
Google ADK composes the tools under an agent and runs it with a Runner. The Claude SDK drives a tool-use loop directly. LangGraph builds an explicit graph that branches to route or nurture on the score. The deterministic scoring, the schema, the WhatsApp calls, and the CRM integration are the same code in all three.
Every lead flows through one loop: captured, acknowledged, qualified, scored, then either routed to a human or sent to nurture. A reply during nurture pulls the lead back to a human, and guardrails for consent, deduplication, and audit wrap the whole thing.
A WhatsApp Business API number through a BSP, a telephony provider for missed calls (Exotel, Knowlarity, MyOperator), your web forms, and any Meta lead ads.
A model API key (Gemini, Claude, or both), your CRM (Zoho, HubSpot, Salesforce), the rep notification path (Slack, call, WhatsApp), and a store for conversations and scores.
Opt-in capture and records, DPDP Act 2023 consent, TRAI DND handling for SMS, your scoring rules, and an audit log.
Install the packages and set your Gemini key. That is enough to run the qualify, score, and route agent against a sample message, which is what the runnable file further down does. The WhatsApp Cloud API setup is only needed once you want to receive and reply to real leads.
# Python 3.10 or newer
python -m venv venv && source venv/bin/activate
# The runnable path below uses Google ADK + Gemini:
pip install google-adk google-genai pydantic requests
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here"
# For live WhatsApp (production only; not needed to run the demo below):
export WHATSAPP_TOKEN="your-meta-access-token"
export PHONE_NUMBER_ID="your-whatsapp-phone-number-id" For live messaging, the WhatsApp Cloud API client below sends the acknowledgement template and receives replies through the webhook you register in Meta's dashboard.
import os, requests
# One-time: in Meta's WhatsApp Cloud API dashboard, add a number, get a permanent
# access token and the phone-number id, and register a webhook for inbound messages.
TOKEN = os.environ["WHATSAPP_TOKEN"]
PHONE_ID = os.environ["PHONE_NUMBER_ID"]
GRAPH = f"https://graph.facebook.com/v20.0/{PHONE_ID}/messages"
# Send a pre-approved template (the instant acknowledgement in step 2).
def send_template(to: str, template: str, variables: dict):
requests.post(GRAPH, headers={"Authorization": f"Bearer {TOKEN}"}, json={
"messaging_product": "whatsapp", "to": to, "type": "template",
"template": {"name": template, "language": {"code": "en"},
"components": [{"type": "body", "parameters":
[{"type": "text", "text": v} for v in variables.values()]}]},
}).raise_for_status()
# Inbound replies hit the webhook you registered; point it at an HTTPS route
# (Flask or FastAPI) that parses the payload and calls your handler. | Layer | Pick | Why |
|---|---|---|
| Capture | Telephony webhook (Exotel, Knowlarity, MyOperator), web form, WhatsApp Cloud API, Meta Lead Ads | Catch every channel a lead arrives on. In India, missed calls and WhatsApp outpull email, so they cannot be the channel you handle last. |
| Messaging | WhatsApp Cloud API via a BSP (Gupshup, Interakt, AiSensy) | WhatsApp is where Indian buyers actually reply. The API lets you acknowledge within seconds and hold a two-way conversation. |
| Model | Gemini 2.5 Flash, Claude, or any model behind LangChain | Holds a natural back-and-forth and fills your qualification fields from free text, in Hindi or English. The code tabs show the same call in all three. |
| Agent framework | Google ADK, the Claude SDK, or LangGraph | Drives each lead through capture, qualify, score, route, and the nurture branch. Pick the one your team already runs; the logic is identical. |
| Data + CRM | Your CRM API (Zoho, HubSpot, Salesforce) + Postgres | Dedupe against existing contacts, create the deal, and store every conversation, score, and decision. |
| Routing + notify | Slack or WhatsApp ping plus a call to the rep, with CRM assignment | A hot lead has to reach a human in seconds. The whole advantage is speed, so queuing it defeats the point. |
The model and orchestration steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The channel and deterministic steps are plain Python and identical everywhere. These are the building blocks, and they call your own systems (the CRM, the WhatsApp client, the queue) by name; the complete file you can run today, with those stubbed, is in "Put it together" below.
Leads arrive as missed calls, web forms, WhatsApp messages, and Meta lead ads, and each one is a separate webhook. Point a telephony provider (Exotel, Knowlarity, or MyOperator) at a missed-call webhook, the web form at a form webhook, and the WhatsApp Cloud API and Meta Lead Ads at theirs. Every handler normalises to the same Lead object and drops it on a queue. The phone number in E.164 form is the join key that later stitches the same person together across channels. This is the same in every framework, so there is one version.
from pydantic import BaseModel
from datetime import datetime
class Lead(BaseModel):
name: str | None = None
phone: str # E.164, the join key across channels
email: str | None = None
source: str # missed_call | web_form | whatsapp | meta_ad
message: str | None = None
created_at: datetime
@app.post("/webhook/missed-call") # Exotel / Knowlarity / MyOperator
def missed_call(evt: dict):
lead = Lead(phone=evt["from"], source="missed_call", created_at=now())
jobs.enqueue("handle_lead", lead.dict()) # return fast; work in the queue This is the step that wins the deal. The instant a lead lands, fire a pre-approved WhatsApp template so the person hears back in seconds, not the industry-average 42 hours. Templates are approved by Meta in advance and only go to numbers that have opted in, which keeps the account healthy. Contacting a lead within five minutes rather than thirty makes you about 21 times more likely to qualify it. This call to the WhatsApp Cloud API is the same whichever agent framework you pick.
# send_template is the WhatsApp Cloud API helper from the Setup section above.
def acknowledge(lead: Lead):
send_template(
to=lead.phone,
template="lead_ack", # pre-approved by Meta, opt-in only
variables={"name": lead.name or "there"})
crm.touch(lead.phone, status="acknowledged") # crm = your CRM client Now the agent has a short, helpful conversation instead of an interrogation. The model runs the back-and-forth over WhatsApp, asks one question at a time, understands Hindi and English, and maps the free-text answers into a typed Qualification schema. Forcing structured output is what makes the result usable downstream: you get clean fields, not a transcript. The schema is defined once (in the Gemini tab); the Claude and LangGraph tabs show the same call through their own SDK.
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client()
class Qualification(BaseModel):
intent: str
budget: int | None = None # rupees, if stated
timeline: str | None = None # now | this_month | later
location: str | None = None
ready_to_talk: bool = False
def qualify(history: list[str]) -> Qualification:
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=[QUALIFY_PROMPT, *history], # ask one question at a time
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Qualification))
return resp.parsed from anthropic import Anthropic
client = Anthropic()
# Qualification is the same Pydantic schema as the Gemini tab.
def qualify(history: list[dict]) -> Qualification:
msg = client.messages.parse(
model="claude-opus-4-8", max_tokens=512,
system=QUALIFY_PROMPT, # ask one question at a time
messages=history, # [{role, content}, ...]
output_config={"format": Qualification})
return msg.parsed_output from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.messages import SystemMessage, HumanMessage
# Qualification is the same Pydantic schema as the Gemini tab.
qualifier = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Qualification)
def qualify(history: list[str]) -> Qualification:
msgs = [SystemMessage(QUALIFY_PROMPT)] + [HumanMessage(t) for t in history]
return qualifier.invoke(msgs) Before scoring, check whether you already know this person. Look them up in the CRM by phone and email, and if they exist, merge rather than create, so one human does not become three records that two reps both call. Where you have an email, a domain lookup can add the company and rough size for B2B routing. This is plain, testable code talking to your CRM API, not a model decision.
def enrich(lead: Lead) -> Lead:
existing = crm.find_by_phone(lead.phone) or crm.find_by_email(lead.email)
if existing:
crm.merge(existing.id, lead) # one lead, not three across channels
if lead.email:
lead = lead.copy(update=domain_lookup(lead.email)) # company, size
return lead Turn your definition of a good lead into points you control, not a vibe the model invents. Readiness to talk, budget over your floor, a near-term timeline, and a high-intent channel each add weight, and the total maps to a band: hot, warm, or cold. Keeping the weights in config means sales can retune them without a deploy, and every score is logged with the inputs that produced it.
def score(lead: Lead, q: Qualification) -> tuple[int, str]:
pts = 0
pts += 30 if q.ready_to_talk else 0
pts += 25 if q.budget and q.budget >= policy.min_budget else 0
pts += 20 if q.timeline in ("now", "this_month") else 0
pts += 15 if lead.source in ("missed_call", "whatsapp") else 5
band = "hot" if pts >= 60 else "warm" if pts >= 35 else "cold"
return pts, band # thresholds live in config, not code A hot lead is worthless sitting in a queue. The agent assigns it round-robin to an available rep, creates the deal in the CRM with the conversation and score attached, and pings that rep on Slack and by call right away. Warm and cold leads are not binned, they enter a nurture sequence so they are worked later. Both routes are plain functions exposed to the agent as tools.
def route_lead(lead: Lead, band: str) -> dict:
"""Hot leads reach a human now; warm/cold leads enter nurture."""
if band == "hot":
rep = assign.round_robin(team="sales")
crm.create_deal(lead, owner=rep, priority="high")
notify(rep, channel="slack+call", # ping now, do not queue
text=f"HOT lead {lead.name} {lead.phone}")
return {"routed": "rep", "owner": rep}
nurture.enroll(lead, sequence=band) # warm/cold drip sequence
return {"routed": "nurture"} Most leads are not ready today, and that is fine. A nurture sequence sends spaced, useful WhatsApp messages, paced and capped so it never feels like spam. The important part is the loop back: when a nurtured lead replies, the WhatsApp Cloud API posts the inbound message to your webhook, you pull them out of the drip and treat them as hot again, because intent just spiked. Around all of this sit the guardrails: WhatsApp and the DPDP Act 2023 both require opt-in, SMS fallback must respect TRAI DND, and every contact is rate-limited and logged.
# WhatsApp Cloud API posts inbound messages to your webhook.
@app.post("/webhook/whatsapp")
def on_whatsapp(payload: dict):
msg = parse_whatsapp(payload) # sender + text
lead = crm.find_by_phone(msg.sender)
if nurture.is_enrolled(lead):
nurture.pause(lead)
route_lead(lead, band="hot") # a reply is fresh intent
def may_message(phone: str, channel: str) -> bool:
if not consent.has_optin(phone): # WhatsApp + DPDP need opt-in
consent.request_optin(phone); return False
if channel == "sms" and dnd.is_registered(phone):
return False # respect TRAI DND
return not rate_limit.exceeded(phone) # never spam a contact Now wire qualify, score, and route into one runnable agent. ADK composes the tools under an LlmAgent and runs it with a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds an explicit graph that branches to route or nurture on the score. The tools and the schema are the same in all three; only the orchestration differs.
from google.adk.agents import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
lead_agent = LlmAgent(
name="lead_agent", model="gemini-2.5-flash",
instruction="Chat briefly to qualify the lead, then call score, then "
"route_lead for hot leads or enroll_nurture otherwise.",
tools=[qualify, score, route_lead, nurture.enroll])
runner = Runner(agent=lead_agent, app_name="leads",
session_service=InMemorySessionService()) from anthropic import Anthropic
client = Anthropic()
TOOLS = [qualify_tool, score_tool, route_tool, nurture_tool] # JSON tool schemas
def run_lead(messages: list):
while True:
resp = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
tools=TOOLS, messages=messages)
if resp.stop_reason != "tool_use":
return resp
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input) # your handlers
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": str(out)}]}) from langgraph.graph import StateGraph, START, END
g = StateGraph(dict)
g.add_node("qualify", lambda s: {"q": qualify(s["history"])})
g.add_node("score", lambda s: {"band": score(s["lead"], s["q"])[1]})
g.add_node("route", lambda s: route_lead(s["lead"], s["band"]))
g.add_node("nurture", lambda s: {"enrolled": nurture.enroll(s["lead"], s["band"])})
g.add_edge(START, "qualify"); g.add_edge("qualify", "score")
g.add_conditional_edges("score", lambda s: "route" if s["band"] == "hot" else "nurture")
g.add_edge("route", END); g.add_edge("nurture", END)
lead_agent = g.compile()
Here is the whole thing as one runnable file. It qualifies a single inbound
message, scores it, and routes it, then prints the result. Replace the
stubs (the budget floor, assign_rep, notify, and enroll_nurture) with your
CRM and notifier. Save it as main.py and run
python main.py "Need 50 units this week, budget about 2 lakh".
# main.py -- run: python main.py "Need 50 units this week, budget about 2 lakh"
# A runnable reference: qualifies one inbound message, scores it, and routes it.
# Replace the stubs (MIN_BUDGET, assign_rep, notify, enroll_nurture) with your CRM.
import sys, asyncio
from pydantic import BaseModel
from google import genai
from google.genai import types
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
client = genai.Client() # reads GEMINI_API_KEY
class Qualification(BaseModel):
intent: str
budget: int | None = None # rupees, if stated
timeline: str | None = None # now | this_month | later
ready_to_talk: bool = False
# ---- stubs: replace with your CRM and notifier ----
MIN_BUDGET = 50000
def assign_rep(): return "rep-1"
def notify(rep, text): print(f"[notify {rep}] {text}")
def enroll_nurture(phone, band): print(f"[nurture] {phone} -> {band}")
# ---------------------------------------------------
def qualify(message: str) -> dict:
"""Pull the qualification fields from a lead's message."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Extract the lead's qualification from this message.", message],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Qualification),
).parsed.model_dump()
def score_and_route(qualification: dict, phone: str = "+910000000000") -> dict:
"""Score the lead; route a hot one to a human, otherwise nurture it."""
q = qualification
pts = (30 if q["ready_to_talk"] else 0) \
+ (25 if q.get("budget") and q["budget"] >= MIN_BUDGET else 0) \
+ (20 if q.get("timeline") in ("now", "this_month") else 0)
band = "hot" if pts >= 50 else "warm" if pts >= 25 else "cold"
if band == "hot":
rep = assign_rep()
notify(rep, f"HOT lead {phone}: {q['intent']}")
return {"band": band, "score": pts, "routed_to": rep}
enroll_nurture(phone, band)
return {"band": band, "score": pts, "routed_to": "nurture"}
agent = LlmAgent(
name="lead_agent", model="gemini-2.5-flash",
instruction=("Call qualify on the user's message, then call score_and_route "
"with the result, then report the band and where it was routed."),
tools=[qualify, score_and_route])
async def main(message: str):
runner = InMemoryRunner(agent=agent, app_name="leads")
session = await runner.session_service.create_session(app_name="leads", user_id="demo")
msg = types.Content(role="user", parts=[types.Part(text=message)])
async for event in runner.run_async(user_id="demo", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit('usage: python main.py "<a lead message>"')
asyncio.run(main(sys.argv[1])) You should see the agent report the band, the score, and where it went, for example routed to rep-1 for a hot lead or to nurture for a warm or cold one. The per-step tabs above are the same logic split into stages across the three frameworks; one agent with two tools, as here, is the simplest version that runs. A real deployment runs qualification as a multi-turn WhatsApp conversation rather than a single message.
The rep sees a ranked inbox: who is hot, the conversation so far, and the qualification already filled in, with one action to take.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want your own scoring, tight CRM fit, and control of the data | High | Build cost + model and WhatsApp usage | Full |
| Conversational platform (Verloop, Yellow.ai, Interakt flows) | Standard WhatsApp bots, fast start, less engineering | Low | Per-conversation or seat subscription | Medium |
| No-code (n8n, Make, Zapier) | Low volume, a quick pilot, simple rules | Low | Cheap, but brittle once logic grows | Low |
Outbound messaging in India has rules, and ignoring them gets your number banned. Send WhatsApp only to numbers that have opted in, use Meta-approved templates for the first touch, and stay inside the 24-hour session window for free-form replies. For any SMS fallback, respect the TRAI DND registry and the DLT registration your provider enforces.
Under the DPDP Act 2023, the contact details a lead gives you are personal data, so capture consent, store it, and honour a request to stop. Build these in as guardrails the agent checks before every message, keep an audit log of consent and contact, and your sender reputation and your legal position both stay clean.
Benchmarks vary by source and should be treated as ranges, not promises. These are from neutral research, not vendor marketing.
| Metric | Manual | Agent |
|---|---|---|
| Average first response | ~42 hours across B2B (HBR 2011 audit of 2,241 firms) | Seconds: an instant WhatsApp acknowledgement on every lead |
| Odds of qualifying the lead | Falls fast with delay | Replying in 5 minutes vs 30 makes you ~21x more likely to qualify (MIT Sloan / Lead Response Management) |
| Leads contacted within 5 minutes | Roughly a quarter of companies manage it (industry studies) | Every lead, because acknowledgement is automatic |
| Leads that get no response at all | A large share slip through on busy days | None: every channel feeds one queue that cannot be missed |
Sources: Harvard Business Review, "The Short Life of Online Sales Leads" (2011); MIT Sloan / Lead Response Management study (Oldroyd). Figures are industry ranges.
Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com
Galific designs, builds, and runs lead agents like this on ADK, the Claude SDK, or LangGraph, wired into your CRM, your telephony, and WhatsApp. Or explore the ready-made versions, from Missed Call Lead Capture to the WhatsApp Qualification Bot, in our agent suite.