Lead generation
Turning website visitors into qualified meetings
A lead-generation agent talks to the people visiting your website, answers what they came to ask, and works out whether they are a fit. When they are, it books them a meeting with the right person, right there in the chat, and hands your sales team everything it learned. This playbook covers what works in lead-generation chat, and how each idea maps to a Parlant building block. Parlant's lead-gen scaffold contains the complete agent, ready for your coding agent to fit to your business.
- What works in lead-generation chat, and what drives buyers away
- Which Parlant building block carries each idea
- A complete example: page-aware openers, a short qualification procedure, scoring in code, and the agent booking the meeting itself, with clickable days and times in the visitor's own timezone
- How to label the funnel, keep each conversation's cost in check, and tune the agent after launch
Your coding agent creates the agent from Parlant's lead-gen scaffold, shows it to you working, interviews you about your buyers and sales process, and fits it to your business.
What works in lead-generation chat
Teams that do this well agree on a handful of ideas:
Tailor the conversation to why they came
A visitor on your pricing page is asking a different question than one reading a blog post. Use the page, the campaign that brought them, and anything you already know about them, rather than one flow for everyone. The pricing page is usually the best place to start, and accounts your sales team is already targeting can skip qualification entirely.
Open with a question, not a form
Offer two to four clear choices, or an open "What brings you here today?". Never open by asking for an email address.
Ease in before you qualify
A visitor who is just looking isn't ready for questions. React to what they said, in their words, then offer one small next thing: "That's cool. Want a quick look at how a project usually goes?" The agent already reacts first and keeps answers short; the scaffold adds a warm, unhurried voice on top, the Warm host from Agent personalities. Qualify once they show interest, and when they ask something, give the short answer first and offer more.
Ask three to five questions, at most
Ask one question per message, and let the visitor set the depth. Ask open questions before narrow ones, ask about their role rather than "are you the decision-maker?", and never ask about budget directly. When a visitor wants to stop, stop.
Give something before asking for contact details
Ask for contact details only in exchange for something specific: a call with someone who can look at their project properly, or an estimate. "Would you like to set up a call with our team to talk through your project in more depth? What's the best email or phone number for the invite?" Accept a phone number just as gladly as an email.
Score the lead during the chat
Score as you go, and act on it right away. A strong fit gets a call with the trade team, a good one a call or a measuring visit, and a small project the call it agreed to plus the self-serve resources, and no measuring visit.
Let the agent book the meeting
Make the meeting the goal. Don't hand the visitor a booking link, or make them wait for someone to reply. The agent offers a few days, then a few times on the day they pick, in their own timezone, books the one they pick, and sends the invite while they are still in the chat. Even "can I talk to someone?" is best answered with the soonest open time.
Give sales structured facts, not a transcript
Each answer goes into its own CRM field, with the score, where the visitor came from, and a recommended next step. A last open question, such as "Anything you'd like our team to know?", is where buyers name competitors and deadlines.
Measure lead quality, not chat volume
Count qualified leads, meetings, and won deals, not chat sessions. Read your first fifty conversations by hand, watch where visitors drop off, and keep the agent in step with your current campaigns.
Avoid the common failures
A form dressed up as a chat, too many questions, a robotic tone, promises nobody can keep, hot leads that wait until morning, and an agent nobody looks at again after launch.
Where each idea lives in Parlant
Classic chatbot builders implement these ideas as decision trees. In Parlant, your agent holds a natural conversation and answers questions nobody scripted. Policies steer that conversation, and your code makes the decisions that must come out the same every time: who is a good fit, which contact details are real, and whether a meeting was booked.
| Idea | Parlant building block |
|---|---|
| Page-aware openers and choices | The chat widget's launcherHint and suggestions, set per page |
| Knowing the page and the campaign | A page view event your site adds to the session as the visitor browses, which the agent sees and page policies check in code |
| A short, conversational qualification | A procedure policy: numbered steps the agent tracks as the conversation moves |
| The short answer first, then your question | A steering guideline, and a retriever over your product content |
| A warm, unhurried voice | A personality: a block in the instructions and agent-wide canned responses, from Agent personalities |
| A curious visitor who keeps asking | A policy decided in code from the message count, which entails the qualification procedure |
| Scoring and routing | A tool that scores in code and saves the lead, and one policy per band, each applying exactly when the lead is in its band |
| Meetings booked in the chat | Two scheduling tools that show days and times as buttons in the visitor's timezone, and a consequential booking tool |
| Turning "can I talk to someone?" into a meeting | A steering guideline that offers the soonest open time |
| A hot visitor no time suits | A tool that asks your team to get in touch, and a policy that confirms only what happened |
| Never inventing prices or discounts | A policy, and product facts the agent quotes |
| Your brand's tone, and no form-speak | Canned responses with notes |
| Funnel analytics | Labels, set by the rule or tool that knows the fact |
| A ceiling on what a conversation costs | A session budget and an observation that hands the conversation to your team |
| Testing before launch | Personas and Dashboard simulations |
Building it
The example below is the lead-gen scaffold's agent, for Hearth & Drape, an imaginary maker of made-to-measure curtains and blinds. It sells to homeowners and to trade buyers such as interior designers, hotels and landlords, and its meeting is a consultation call or a free measuring visit. The scaffold has the complete code, with sample CRM, calendar, email and sales-inbox services that your coding agent replaces with your own. Its team is in the UK and takes calls on weekdays between 9:30 and 17:00 UK time.
Each part below starts with what it does and why. Your coding agent writes the code; it's under Doing this yourself? if you want to read it or write it yourself.
The agent
The agent's instructions say who it is and what it is for. Keep them short: the behavior lives in the rules below.
While the agent works, the visitor always sees something. Before its first tool call, the agent says one short line, such as "Let me check that." Parlant asks for that preamble, trims it to a sentence and allows at most one every 15 seconds. Each tool also has a narration, such as "Checking which days are free", which the chat shows as the tool's status while it runs.
Doing this yourself?
import parlant.sdk as p
agent = await server.create_agent(
id="website",
name="Hearth & Drape",
instructions=(
"You are the assistant on Hearth & Drape's website. Hearth & Drape makes "
"made-to-measure curtains and blinds for homeowners and for trade clients such as "
"interior designers, hotels and property developers. Help visitors understand "
"whether we fit what they need, and when we do, help them take the next step "
"with our team."
),
preamble_config=p.PreambleConfiguration.encourage(),
)
Product answers come from a retriever over your catalog, prices and services, so the agent quotes your content rather than its general knowledge. The retriever also gives the agent today's date, the page the visitor is on, and their timezone, from the latest page view your site posted. Visitors say "before Christmas" or "next week", and the agent needs the date to turn that into a timeline or a range of days to search. Today's date is the visitor's, so late on a Monday evening in Los Angeles, "tomorrow" still means Tuesday.
Doing this yourself?
import datetime
async def product_knowledge(ctx: p.RetrieverContext) -> p.RetrieverResult:
# The page views your site posts; see "Telling the agent where the visitor is".
page = page_state(ctx.session.interaction.events)
tz, assumed = visitor_zone(page.timezone) # the team's zone (UK time) when there's none
today = datetime.datetime.now(tz).date()
visitor = {
"today": f"{today:%A %d %B %Y} ({today.isoformat()})",
"current_page": page.current_page,
"timezone": "unknown (times are shown in UK time)" if assumed else tz.key,
}
return p.RetrieverResult(data={"visitor": visitor, **catalog.facts()})
await agent.attach_retriever(product_knowledge, id="catalog")
Openers that fit the page
The opener lives in your website, not in the agent. In bar mode, the chat widget sits at the bottom of the page as a message field, with conversation starters the visitor can click. Set them per page, so the pricing page asks about cost and the trade page asks about projects.
A clicked starter arrives as the visitor's first message, so the agent already knows what they came for. If you keep the default floating chat button instead, set its hint per page the same way, for example "Questions about cost?" on the pricing page. The small print under the input is a good place to say how you use what visitors share.
Doing this yourself?
The starters are the widget's suggestions, the floating button's hint is its launcherHint, and the small print is its disclaimer:
import {ChatWidget} from '@/parlant-chat';
const OPENERS: Record<string, string[]> = {
'/pricing': ['What would my windows cost?', 'Is measuring free?', 'Talk to someone'],
'/trade': ["We're refitting a hotel", 'Do you have FR fabrics?', 'Talk to someone'],
};
const DEFAULT_OPENER = ['Curtains or blinds?', 'How long does it take?', 'Book a measuring visit'];
export function SiteChat({pathname}: {pathname: string}) {
return (
<ChatWidget
serverUrl="https://chat.hearthanddrape.example"
agentId="website"
mode="bar"
suggestions={OPENERS[pathname] ?? DEFAULT_OPENER}
disclaimer={<>We use this chat to follow up on your request. <a href="/privacy">Privacy policy</a></>}
/>
);
}
Telling the agent where the visitor is
The agent only knows what's in the conversation. Your site adds the rest as a page view event: when the chat opens, and each time the visitor moves to another page, it adds a custom event to the session with the page, the visitor's timezone and language, and the campaign that brought them, if any:
| Field | Where it comes from | What uses it |
|---|---|---|
page, title | The page the visitor is on | The page policies and the retriever. The first page view is the lead's landing page in your CRM |
timezone | The browser, Intl.DateTimeFormat().resolvedOptions().timeZone | The days and times offered, the confirmation, and today's date |
locale | The browser, navigator.language | The language of the day and time labels |
utm_campaign | The page's address, when a campaign brought the visitor | The lead's source in your CRM |
The event is part of the conversation, so the agent sees it in the history: it knows which page the visitor is on, and notices when they move. A page view doesn't make the agent reply. It's there on the visitor's next message.
The scaffold's code reads the same events. A small helper finds the latest page view, for the current page, timezone and language, and the first one, for the landing page and campaign. The page policies, the retriever, scheduling, the CRM and the conversation cap all use it, so they work from the same facts the agent sees.
Your backend posts the event, not the browser. A visitor's own chat token can't add custom events to a session, so the browser tells your backend, and your backend checks that the session is one this visitor started before posting with the admin key. The widget's code is copied into your project, so your coding agent can add this to it.
Doing this yourself?
Add this where the widget knows its session id. It runs when the chat opens, and again on every page view:
useEffect(() => {
if (!sessionId) return;
fetch('/api/chat/page-view', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
sessionId,
page: pathname,
title: document.title,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: navigator.language,
campaign: new URLSearchParams(window.location.search).get('utm_campaign'),
}),
});
}, [sessionId, pathname]);
Your backend first checks that the session is one this visitor started, then posts the event with the admin key, leaving out utm_campaign when there is none:
POST /sessions/{session_id}/events
Authorization: Bearer <admin key>
Content-Type: application/json
{"kind": "custom", "source": "customer_ui", "data": {
"type": "page_view",
"page": "/lp/hotel-refit",
"title": "Hotel refits",
"timezone": "America/New_York",
"locale": "en-US",
"utm_campaign": "hotel-refit"
}}
In the scaffold's pages.py, page_state reads the page views from a session's events:
@dataclass(frozen=True)
class PageState:
current_page: str | None = None # the latest page_view
landing_page: str | None = None # the first page_view
timezone: str | None = None # from the browser; None means UK time
locale: str | None = None # the browser's language, e.g. "de-DE"
utm_campaign: str | None = None # from the first page_view, if the visit had one
def page_views(events: Iterable[Any]) -> list[Mapping[str, Any]]:
"""The data of each page_view event, oldest first."""
return [
e.data
for e in events
if e.kind == p.EventKind.CUSTOM
and e.source == p.EventSource.CUSTOMER_UI
and isinstance(e.data, Mapping)
and e.data.get("type") == "page_view"
]
def page_state(events: Iterable[Any]) -> PageState:
"""The visitor's current page, timezone and locale (from the latest page_view that
reports each), and where they landed (from the first)."""
views = page_views(events)
if not views:
return PageState()
def latest(key: str) -> str | None:
return next((value for view in reversed(views) if (value := _text(view, key))), None)
return PageState(
current_page=_text(views[-1], "page"),
landing_page=_text(views[0], "page"),
timezone=latest("timezone"),
locale=latest("locale"),
utm_campaign=_text(views[0], "utm_campaign"),
)
def visitor_page() -> PageState:
"""The page state of the session a tool is running in."""
return page_state(p.Session.current.interaction.events)
_text returns a field's value when it's a non-empty string. Checks and retrievers pass ctx.session.interaction.events; tools call visitor_page().
The page views are what make page rules exact. A policy can come with a check written in code, which Parlant runs instead of the model to decide whether the policy applies. A check that reads the current page applies its policy on precisely the right pages: on the pricing page, the agent leads with what the "from" prices cover before asking how many windows the visitor is dressing, and on the trade page it adds in one short sentence that business projects get our trade programme.
Since the agent already sees the page view, each page policy's content is what to do on that page, not where the visitor is. The agent tends to reuse the words its policies use, so a policy that opened with "The visitor is on the pricing page" would have it telling visitors which page they're on. The "Keep the scoring internal" policy below says the same from the other side: use the page to choose what to say, without telling the visitor which page it is.
Doing this yourself?
The check is the policy's evaluation_func:
def current_page_is(*pages: str):
async def evaluate(ctx: p.RuleEvaluationContext, policy: p.Policy) -> p.RuleEvaluation:
page = page_state(ctx.session.interaction.events).current_page
return p.RuleEvaluation(id=policy.id, is_relevant=page in pages, reasoning=f"current page is {page!r}")
return evaluate
await agent.create_policy(
title="Pricing page visitors",
content=(
"Lead with what our prices cover: a 'from' price is for a small window, with "
"linings, motorization and fitting added per window, and the exact price comes in "
"a written quote after measuring. When they ask what it would cost them, give the "
"relevant 'from' prices, then ask how many windows they're dressing before "
"narrowing it down"
),
evaluation_func=current_page_is("/pricing"),
)
await agent.create_policy(
title="Trade page visitors",
content=(
"Treat them as a likely trade buyer, buying for a business or a client. In your next "
"answer, add in one short sentence that for business projects we offer a trade "
"programme (a dedicated project manager, FR-certified fabrics, phased installs)"
),
evaluation_func=current_page_is("/trade", "/lp/hotel-refit"),
)
For visitors who are signed in, such as existing customers in your app, use customer variables instead: their plan and account owner are in front of the agent on every turn, with no tool call.
Answer first, then qualify
An agent that interrogates loses buyers, and so does one that answers with an essay. A steering guideline keeps it helpful: when the visitor asks a question, the agent gives the short answer first, the one or two things that matter for them, offers more if they want it, and never holds an answer back to get their details. Only then does it ask anything of its own.
Doing this yourself?
await agent.create_policy(
title="Answer before qualifying",
content=(
"When the visitor asks a question, give them the short answer first: the one or "
"two things that matter for them, and an offer of more if they want it. Never "
"hold an answer back to get their details. Only then ask anything of your own."
),
)
The qualification procedure
This is the heart of the agent. The questions keep it a conversation: an open question first, role instead of authority, budget never asked directly, and a last open question for the rep.
A procedure gives the agent the whole flow at once, so it can skip a step the visitor already answered and come back to one they dodged. The step that offers a call carries the "give before you take" idea: contact details are asked for only for the invite, and a canned response shows how: "Would you like to set up a call with our team to talk through your project in more depth? What's the best email or phone number for the invite?"
The procedure also labels the session lead-intent when it applies, which is the top of your funnel in the Dashboard. Its first sentence defines buying interest, which keeps product questions, job seekers and vendors out of it.
A visitor who keeps asking general questions is curious enough to qualify, even without buying interest. "Visitor keeps asking" is decided in code from the message count, applies while no lead is recorded, and entails the procedure, so the agent answers what was asked and carries on from the first step the visitor hasn't answered.
Doing this yourself?
call_offer = await agent.create_canned_response(
template=(
"Would you like to set up a call with our team to talk through your project in "
"more depth? What's the best email or phone number for the invite?"
),
note=(
"Offer the call for what it gives them, time with someone who can look at their "
"project properly. Ask for contact details only for the invite."
),
)
qualify = await agent.create_policy(
title="Qualify a potential buyer",
content="""\
When the visitor shows buying interest (they ask about pricing for their own
windows, a quote, a measuring visit, or how Hearth & Drape would work for their
home or project; questions about how a product works are not buying interest),
follow this procedure, tracking which step the conversation is at. Ask one
question per message. Skip anything the visitor has already told you.
1. Ask what they're working on, in their own words (which rooms, what look or problem).
2. Ask whether it's for their own home or for a client or business, and their role.
3. Ask roughly how many windows are involved.
4. Ask when they'd like everything fitted.
5. Offer a call with our team to talk through their project in more depth, and
ask for the best email or phone number for the invite. If they'd rather not
talk, don't push.
6. Ask whether there is anything they would like our team to know, such as
other suppliers they're comparing or a deadline.
7. Record everything with `record_lead`. If you learn something new afterwards
(more windows, a business project, a timeline), call `record_lead` again.
If the visitor wants to stop at any point, or says they don't want questions,
respect it: answer what they asked, ask nothing more (not even for contact details), and
mention the self-serve resources (the pricing page, free samples, the measuring
guide). Never ask more than five questions before step 7.
""",
tools=[record_lead],
canned_responses=[call_offer],
labels=["lead-intent"],
)
KEEPS_ASKING_AFTER = 3
def keeps_asking(visitor_messages: int, lead_recorded: bool) -> bool:
return visitor_messages >= KEEPS_ASKING_AFTER and not lead_recorded
def visitor_keeps_asking():
async def evaluate(ctx: p.RuleEvaluationContext, policy: p.Policy) -> p.RuleEvaluation:
sent = sum(1 for m in ctx.session.interaction.messages if m.source == p.EventSource.CUSTOMER)
recorded = await ctx.session.metadata.get("lead_id") is not None
return p.RuleEvaluation(
id=policy.id,
is_relevant=keeps_asking(sent, recorded),
reasoning=f"{sent} visitor messages, lead recorded: {recorded}",
)
return evaluate
curious = await agent.create_policy(
title="Visitor keeps asking",
content=(
"The visitor keeps asking about us, which counts as interest. Answer what they "
"asked first, then carry on with the 'Qualify a potential buyer' steps from the "
"first one they haven't answered, and offer the call when you reach it"
),
evaluation_func=visitor_keeps_asking(),
labels=["lead-curious"],
)
await curious.entail(qualify)
await curious.reevaluate_after(record_lead)
Scoring in code, not in the model
The score decides who gets a salesperson's time, so it should be exact, the same every time, and easy for your team to change. score_lead is plain code, not a model's judgment: buying for a business, a bigger project, a timeline within three months, and naming a competitor add points, while a very small job or a student role takes points away. An answer the visitor hasn't given yet scores nothing either way, so a lead recorded early isn't marked down for it.
Doing this yourself?
import enum
class BuyingFor(enum.Enum):
OWN_HOME = "own home"
BUSINESS = "business or client"
def score_lead(buying_for: BuyingFor, role: str, window_count: int | None,
timeline_days: int | None, named_competitor: bool) -> int:
score = 0
if buying_for == BuyingFor.BUSINESS: # trade buyers are where the volume is
score += 25
if window_count is not None:
if window_count >= 50:
score += 30
elif window_count >= 15: # a whole house, or a small trade job
score += 25
elif window_count >= 6:
score += 10
elif window_count <= 2:
score -= 10
if timeline_days is not None and timeline_days <= 90:
score += 15
if named_competitor:
score += 10
if any(t in role.lower() for t in ("student", "intern")):
score -= 20
return score
record_lead is the tool the procedure calls. It does five things:
- It accepts an email or a phone number, and checks that the visitor actually typed it in this conversation.
- It scores the lead and saves it to your CRM, with every answer in its own field, and the landing page and campaign from the first page view.
- It writes the band to the session's metadata, where the band policies read it.
- It labels the session with the band, replacing any earlier band label, so a lead that's re-scored is counted once.
- It tells the agent what's still unknown, so the agent can ask one more question and record again.
The thresholds are this example's. A budget in range, a pricing question, or arriving from the pricing page are common additions. The CRM check also lets your CRM steer the conversation: an existing customer, an open deal, or a do-not-contact flag changes what happens next.
Doing this yourself?
source="customer" tells the model the contact details must come from the visitor; the check in the code makes sure of it. buying_for is an Enum: a tool parameter with a fixed set of values is declared that way, and the agent sees the choices.
import re
from typing import Annotated
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
def visitor_text() -> str:
"""Everything the visitor has typed in this conversation."""
return " ".join(
m.content for m in p.Session.current.interaction.messages if m.source == p.EventSource.CUSTOMER
).lower()
@p.tool(narration="Noting your details")
async def record_lead(
goal: str,
buying_for: Annotated[BuyingFor, p.ToolParameterOptions(
description="'business or client' for designers, hotels, landlords, developers; "
"'own home' for homeowners and renters",
)],
role: str,
email: Annotated[str, p.ToolParameterOptions(
description="The visitor's email address, exactly as they typed it. Empty if they gave a phone number.",
source="customer",
)] = "",
phone: Annotated[str, p.ToolParameterOptions(
description="The visitor's phone number, exactly as typed, if they prefer a call",
source="customer",
)] = "",
window_count: Annotated[int | None, p.ToolParameterOptions(
description="Roughly how many windows, only if the visitor said. Never guess.",
)] = None,
timeline_days: Annotated[int | None, p.ToolParameterOptions(
description="Days until they want everything fitted, if they said",
)] = None,
notes_for_rep: str = "",
named_competitor: bool = False,
) -> p.ToolResult:
session = p.Session.current
typed = visitor_text()
email = email.strip().rstrip(".").lower()
digits = re.sub(r"\D", "", phone)
email_ok = bool(EMAIL.fullmatch(email)) and email in typed
phone_ok = len(digits) >= 7 and digits in re.sub(r"\D", "", typed)
if not (email_ok or phone_ok):
return p.ToolResult(data={
"recorded": False,
"reason": "The visitor hasn't typed an email address or phone number in this chat. "
"Don't guess one: offer a call with our team and ask for an email or phone "
"number for the invite, or carry on helping.",
})
score = score_lead(buying_for, role, window_count, timeline_days, named_competitor)
band = "hot" if score >= 45 else "warm" if score >= 20 else "cold"
page = visitor_page() # where they landed, from your site's page_view events
lead = await crm.upsert_lead(
email=email if email_ok else None,
phone=phone if phone_ok else None,
fields={"goal": goal, "buying_for": buying_for.value, "role": role,
"window_count": window_count, "timeline_days": timeline_days},
notes=notes_for_rep,
score=score,
source={"landing_page": page.landing_page, "campaign": page.utm_campaign},
chat_session_id=session.id,
)
# A do-not-contact flag holds for the rest of the conversation, whatever address comes next.
if lead.do_not_contact or await session.metadata.get("do_not_contact"):
await session.metadata.set("do_not_contact", True)
band = "cold"
await session.metadata.set("lead_id", lead.id)
await session.metadata.set("lead_band", band)
await session.metadata.set("lead_email", email if email_ok else None)
await session.metadata.set("lead_phone", phone if phone_ok else None)
for other in ("hot", "warm", "cold"):
if other != band and f"lead-{other}" in session.labels:
await session.labels.remove(f"lead-{other}")
await session.labels.add(f"lead-{band}")
data = {"recorded": True, "band": band, "existing_customer": lead.is_customer}
still_unknown = [name for name, value in (("window_count", window_count),
("timeline_days", timeline_days)) if value is None]
if still_unknown:
data["still_unknown"] = still_unknown
data["note"] = "Ask about one of these naturally, and call record_lead again with the answer."
return p.ToolResult(data=data)
Routing on the band
Each band gets its own short policy, and every band books the call the visitor agreed to, in the chat. A strong fit gets the trade team. A good fit gets the team, or a free measure-and-quote visit if they'd rather. A small project gets the call too, with the pricing page and free samples in the meantime, and no measuring visit; if they no longer want a call, the agent thanks them and points to the self-serve resources.
A check in code reads the band that record_lead stored, so each policy applies in exactly the conversations with that band, and never before the lead is scored. Reevaluation checks them again right after record_lead runs, so the agent acts on the band in the same reply.
The policies describe the visitor's project, not the visitor: "a strong fit for our trade team", never "a hot lead". The agent tends to reuse the words its policies use, so this keeps sales vocabulary out of its replies.
Spell out every step you expect, such as searching again when no time works, or asking the team to get in touch when nothing suits. The agent follows what the policy says, and these details are what turn a good conversation into a booked meeting.
Doing this yourself?
def band_is(band: str):
async def evaluate(ctx: p.RuleEvaluationContext, policy: p.Policy) -> p.RuleEvaluation:
current = await ctx.session.metadata.get("lead_band")
return p.RuleEvaluation(id=policy.id, is_relevant=current == band, reasoning=f"lead band is {current!r}")
return evaluate
SCHEDULING = [find_meeting_dates, find_meeting_times, book_meeting, request_team_follow_up]
hot = await agent.create_policy(
title="Hot lead: book a call",
content=(
"`record_lead` found this visitor's project a strong fit for our trade team "
"(internal: never call them a lead or say how they were scored). Book the call they "
"agreed to with our trade team: find open days with `find_meeting_dates`, then the "
"times on the day they pick with `find_meeting_times`, and book the time they pick "
"with `book_meeting`. If none of them work, ask what would, and search again with "
"that. If nothing we have suits them, ask the team to contact them with "
"`request_team_follow_up`."
),
tools=SCHEDULING,
evaluation_func=band_is("hot"),
)
warm = await agent.create_policy(
title="Warm lead: book a call",
content=(
"`record_lead` found this visitor's project a good fit (internal: never call them a "
"lead or say how they were scored). Book the call they agreed to with our team, or a "
"free measure-and-quote visit if they'd rather (`find_meeting_dates`, then "
"`find_meeting_times`, then `book_meeting`). If none of the times work, ask what "
"would, and search again with that. If nothing we have suits them, ask the team to "
"contact them with `request_team_follow_up`."
),
tools=SCHEDULING,
evaluation_func=band_is("warm"),
)
cold = await agent.create_policy(
title="Small project: book a call",
content=(
"`record_lead` found this visitor's project a small one (internal: never say so or "
"call them a lead). Book the call they agreed to with `find_meeting_dates` and "
"`find_meeting_times` (visitor_asked=true once they've agreed) and `book_meeting`, "
"and mention the pricing page and free fabric samples in the meantime. Don't offer "
"a measuring visit. If they no longer want a call, thank them and point them to the "
"self-serve resources."
),
tools=SCHEDULING,
evaluation_func=band_is("cold"),
)
for policy in (hot, warm, cold):
await policy.reevaluate_after(record_lead)
Booking inside the chat
This is where the conversation pays off. The agent schedules the meeting itself: no booking link, no waiting for someone to reply. A meeting booked while the visitor is still in the chat is far more likely to happen.
Booking takes two steps, a day and then a time, so the visitor makes one small choice at a time:
find_meeting_datesreturns up to five days with open times, from the right calendar (the account owner's, or the next salesperson in a round-robin).find_meeting_timesreturns up to four times on the day the visitor picked.book_meetingbooks the time they picked.
Each finding tool also sends its options to the chat as buttons. Both start with the same checks: no times before a lead is recorded, and for a poor-fit lead, times only when they asked for a meeting themselves and haven't asked not to be contacted.
Doing this yourself?
# Offer only times in this window of the visitor's own day (None offers every time).
WAKING_HOURS = (7, 21)
scheduler = Scheduler(calendar, waking_hours=WAKING_HOURS) # the rules, from scheduling.py
VisitorAsked = Annotated[bool, p.ToolParameterOptions(
description="True only if the visitor themselves asked to talk to someone, for a call, "
"or for a visit, in this conversation",
)]
@p.tool(narration="Checking which days are free")
async def find_meeting_dates(
start_date: Annotated[str, p.ToolParameterOptions(
description="First day to look at, as YYYY-MM-DD, worked out from what the visitor "
"said ('next week', 'from the 10th'). Empty means from tomorrow.",
)] = "",
end_date: Annotated[str, p.ToolParameterOptions(
description="Last day to look at, as YYYY-MM-DD. Empty means two weeks after start_date.",
)] = "",
preference: Annotated[str, p.ToolParameterOptions(
description="Time of day or weekdays the visitor asked for, in English: 'morning', "
"'afternoon', 'thursday', 'thursday afternoon'. Empty if none.",
)] = "",
visitor_asked: VisitorAsked = False,
) -> p.ToolResult:
"""The days with open times with this lead's rep, offered as choices."""
result = await scheduler.dates(
await session_state(), # lead_id, lead_band, do_not_contact, offered_dates, timezone, locale, ...
start_date=start_date, end_date=end_date,
preference=preference, visitor_asked=visitor_asked,
)
await save_state(result.state) # offered_dates and schedule_preference, for the next step
return p.ToolResult(data=result.data, metadata=result.metadata or None)
@p.tool(narration="Checking the times that day")
async def find_meeting_times(
date: Annotated[str, p.ToolParameterOptions(
description="The day the visitor picked: one of the offered days, as offered, or YYYY-MM-DD",
)],
preference: str = "",
visitor_asked: VisitorAsked = False,
) -> p.ToolResult:
"""The open times on the day the visitor picked, offered as choices."""
result = await scheduler.times(
await session_state(), date_text=date, preference=preference, visitor_asked=visitor_asked
)
await save_state(result.state) # offered_times, which book_meeting checks the pick against
return p.ToolResult(data=result.data, metadata=result.metadata or None)
The scheduling rules themselves, such as timezones, labels, and matching what the visitor typed, live in the scaffold's scheduling.py, as plain Python with no Parlant in it, so they're tested without a model. The tools read the session's metadata, with the visitor's timezone and locale from the latest page view (session_state() here), call the scheduler, and write back what was offered (save_state()), so the next step can check the visitor's pick against it.
Days and times as buttons
A tool result has two parts. One is what the agent reads: the day labels, the visitor's timezone, and the team's hours. The other goes only to the chat, and here it holds a set of choices, which the bundled chat widget shows as buttons, up to eight.
Clicking a button sends its label as the visitor's own message, so the agent reads a click exactly like a typed answer. Once the visitor answers, by clicking or by typing, the buttons switch off. Visitors who type "Thursday" or "4:30pm" are understood either way.
Doing this yourself?
The agent reads the result's data. Its metadata goes only to the chat, and here it holds a choices widget:
{
"widget": {
"type": "choices",
"id": "meeting-dates",
"prompt": "Which day suits you?",
"options": [
{"id": "2026-10-27", "label": "Tuesday 27 Oct"},
{"id": "2026-10-28", "label": "Wednesday 28 Oct"},
{"id": "2026-10-29", "label": "Thursday 29 Oct"}
]
}
}
A click sends the option's label, or its reply if the option has one. The times come back the same way, as meeting-times, with each option's id the slot's start in UTC. If you build your own chat UI, render this payload yourself.
In the visitor's own timezone
Times are shown in the visitor's timezone, from the timezone in the latest page view. A visitor in New York looking at Monday 26 October sees 7:00–7:30 AM, 10:00–10:30 AM and 12:30–1:00 PM, with the prompt "Which time suits you? Times are in ET." Clock changes are handled: that week the UK has changed its clocks and the US hasn't yet, so New York is four hours behind London instead of five.
- Without a timezone, the times are in the team's zone, and the agent always names it: "UK time".
- Only waking hours are offered. The scaffold offers times between 07:00 and 21:00 in the visitor's own day, so the 9:30 UK slot, 5:30 AM in New York, isn't offered there.
WAKING_HOURSintools.pysets the window. - The agent can explain the hours. The result tells it the team's hours in the visitor's zone, such as "Our team takes calls between 9:30 and 17:00 UK time, which is 5:30 AM to 1:00 PM ET", so it can say why there are no evening times.
- Labels follow the visitor's language. With the page view's
localeset tode-DE, the first day reads "Montag, 26. Okt." and the prompt "Welcher Tag passt Ihnen?". The scaffold covers English, German, French, Spanish and Hebrew, and your coding agent can add more.
Booking the time they picked
book_meeting accepts the clicked label or what the visitor typed, and matches it against the times that were offered.
Booking is consequential, which makes the agent confirm before booking. Picking one of the offered times is that confirmation, and the tool's description says so, so the visitor isn't asked "Shall I book it?" after they've just clicked. The confirmation gives the time with its zone, such as "Monday, Oct 26 at 12:30 PM ET", so nobody has to guess.
When someone else has just taken that time, the booking fails and returns the remaining times that day as a fresh set of buttons, so the agent can offer them in the same reply. The invite carries the lead's brief, so whoever takes the call already knows the visitor's goal, size, timeline, and anything they asked the team to know. The meeting-booked label is added only after the booking succeeds, so it counts real meetings.
Doing this yourself?
@p.tool(consequential=True, narration="Booking your call")
async def book_meeting(
slot: Annotated[str, p.ToolParameterOptions(
description="The time the visitor chose, exactly as offered (or as they wrote it)",
source="customer",
)],
) -> p.ToolResult:
"""Book the offered time the visitor picked. Picking one of the offered times (clicking or
typing it) is the visitor's confirmation, so book it without asking them to confirm again."""
session = p.Session.current
state = await session_state()
if state["lead_id"] is None:
return p.ToolResult(data={"booked": False, "reason": "Record the lead with record_lead first."})
brief = await crm.lead_brief(state["lead_id"]) # their answers, score, and notes
result = await scheduler.book(state, slot_text=slot, description=brief)
await save_state(result.state)
if result.booked is None: # not an offered time, or someone just took it
return p.ToolResult(data=result.data, metadata=result.metadata or None)
await crm.set_stage(state["lead_id"], "Meeting booked")
await session.labels.add("meeting-booked")
email = await session.metadata.get("lead_email")
phone = await session.metadata.get("lead_phone")
return p.ToolResult(
data={**result.data, "invite": "emailed" if email else f"no email on file; the rep will call {phone}"},
canned_response_fields={
**result.canned_response_fields, # rep, and the time in the visitor's zone
"next_step": "The invite is on its way to your inbox." if email
else f"{result.booked.rep_name.split()[0]} will call you on {phone}.",
},
)
When no time works
Sometimes nothing suits a visitor worth a call: they're travelling, or only free in the evening. request_team_follow_up passes them to your sales inbox, with a note on what would suit them, and labels the session follow-up-requested.
A short policy pairs with these results, so the agent's words always match what happened. The agent says a call is booked or something was sent only when the tool says so, and promises that someone will get in touch only once a meeting is booked or the team has been told.
Doing this yourself?
@p.tool(narration="Passing this to our team")
async def request_team_follow_up(
note: Annotated[str, p.ToolParameterOptions(
description="Why the team should get in touch, and anything that helps them, such as "
"the times that would suit the visitor",
)],
) -> p.ToolResult:
"""Ask the sales team to contact the visitor themselves, when none of the offered times work."""
session = p.Session.current
lead_id = await session.metadata.get("lead_id")
if lead_id is None:
return p.ToolResult(data={"requested": False, "reason": "Record the lead with record_lead first."})
if await session.metadata.get("do_not_contact"):
return p.ToolResult(data={"requested": False, "reason": "This visitor asked not to be contacted."})
contact = await session.metadata.get("lead_email") or await session.metadata.get("lead_phone")
await sales_inbox.notify(
session_id=session.id,
reason="Visitor asked to be contacted",
details={"lead_id": lead_id, "contact": contact, "note": note},
)
await session.labels.add("follow-up-requested")
return p.ToolResult(data={
"requested": True,
"contact": contact,
"tell_visitor": "Someone from the team will contact them to find a time.",
})
await agent.create_policy(
title="Only confirm what really happened",
content=(
"Only tell the visitor a call or visit is booked when `book_meeting` returned "
"booked: true. If a booking didn't go through, say that time just went, and offer the "
"other open times it returned. Never promise that someone will get in touch unless "
"a meeting was booked or `request_team_follow_up` returned requested: true."
),
)
"Can I talk to someone?"
When a visitor asks for a person, they usually want a real conversation with your team, soon. The agent answers with something better than a wait: the soonest open time, booked on the spot. If the lead isn't recorded yet, it first asks only what it needs to record it, in as few messages as it can.
Doing this yourself?
await agent.create_policy(
"When the visitor asks to talk to someone from the team, offer the soonest open days "
"with `find_meeting_dates` and times with `find_meeting_times` (visitor_asked=true), "
"and book the one they pick with `book_meeting`. If the lead isn't recorded yet, first "
"ask only what `record_lead` needs, in as few messages as you can, and record it",
tools=[record_lead, *SCHEDULING],
labels=["asked-for-team"],
)
Visitors who are not buyers
Not everyone who opens the chat is a buyer. Existing customers with a problem, job seekers, and vendors trying to sell to you need a quick, friendly redirect, and no qualification. Say plainly what the agent can't do, so it points them to the right place instead of promising a callback.
Doing this yourself?
await agent.create_policy(
"When an existing customer needs help with an order, a delivery, or an installation, "
"don't qualify them. Point them to support at [email protected], or answer "
"if it's a quick question. You can't see orders or pass messages on, so don't take "
"their contact details and don't promise that support will contact them or when"
)
await agent.create_policy(
"When the visitor is looking for a job, or is selling something to Hearth & Drape, "
"thank them and point them to hearthanddrape.example/careers or [email protected]"
)
Prices, promises, and what stays internal
Two more policies keep the agent's answers within what your team stands behind. The first covers prices: the agent quotes the "from" prices in your product knowledge, leaves exact prices to a written quote, and keeps the conversation moving towards one. The second keeps the scoring behind the scenes. Visitors should experience a helpful conversation, not see how they were rated.
Leave their weight at the default. If your testing shows a visitor talking the agent out of one of them, that conversation is the evidence for raising it.
Doing this yourself?
await agent.create_policy(
title="No invented prices or promises",
content=(
"When the visitor asks for a discount, custom or trade pricing, an exact quote, a "
"delivery date guarantee, or a product we don't list, don't agree to or invent "
"anything. Say our team gives exact prices in a written quote, and keep helping: "
"carry on with the qualification questions so we can get them one."
),
)
await agent.create_policy(
title="Keep the scoring internal",
content=(
"Never mention lead scores, bands, fit, or tool names to the visitor, and never "
"explain an offer or a refusal by what kind of lead they are. Use the page they're "
"on to choose what to say, but don't tell them which page it is."
),
)
Sounding like your team, not a form
Tone comes from canned responses: examples of how your team would say it, which the agent imitates while adapting to the conversation. A good test: read each message out loud, and if it sounds like a form-validation message, rewrite it.
The scaffold ships with a personality, the Warm host from Agent personalities: a short block of habits at the end of the agent's instructions in main.py, and six agent-wide canned responses next to it that carry its register at the moments every conversation has (the opening, a visitor who is just looking, a direct question, a problem, a goodbye). It is kept apart from the business so you can swap in another personality from that page, or your own, in one place. The examples below are the business's own: how we answer a price question, and the booking confirmation.
The agent speaks as one of your team: "we make", "our prices", "our fitters". Show it with an example in that voice, ideally one for a question your product knowledge doesn't answer exactly, such as an exact price. The example shows how we'd answer, so the agent says "We work out the exact price once we've measured" rather than describing the business from outside. Keep these examples for questions they actually answer: a general line about the business tends to become the agent's stock reply to every question about trust or competitors.
The booking confirmation names the rep and the time, and is filled in from the booking's result, so it's only used in a turn where the booking happened. Its time is already in the visitor's zone and language, such as "Montag, 26. Okt. um 10:30 CET", and its last sentence says whether an invite is on its way or the rep will call.
Doing this yourself?
await agent.create_canned_response(
template="Got it. What are you hoping to change about those rooms?",
note="Warm and short. One question per message. Never list several questions at once.",
)
await agent.create_canned_response(
template=(
"We work out the exact price once we've measured, so it comes in your written "
"quote. Our made-to-measure curtains start from $180 per window."
),
note=(
"Speak for Hearth & Drape as one of the team: we make, we fit, our prices. When "
"the catalog has no exact answer, tell them how we'll work it out for them."
),
)
await agent.create_canned_response(
template="You're booked with {{ rep }} for {{ time }}. {{ next_step }}",
note=(
"Confirm the booking only after `book_meeting` succeeds, in the visitor's language. "
"If they're writing in another language, translate the whole sentence into it; never "
"send this in English to someone writing in German, Spanish, French, Hebrew or any "
"other language."
),
field_dependencies=["rep", "time", "next_step"],
)
The confirmation uses the fields from book_meeting's result, and field_dependencies keeps it out of any turn where the booking didn't happen.
Keeping each conversation's cost in check
Most lead conversations are short. A few run long: a visitor chatting for an hour, or someone testing the agent. A session budget puts a ceiling on what one conversation can cost, $1.00 by default in the scaffold. When a conversation crosses it, an observation hands it to your team, which is also the right move for anyone who has talked that long.
The cap is checked in code, so no model decides when it applies, and its message is exactly what the visitor sees. It asks for their name and email so the team can get back to them, or, when they've already given an email, asks them to confirm it. The visitor's reply is saved in the conversation and waits there for your team: from that point, nothing answers automatically. The sales inbox gets the lead, the days or times the visitor was choosing from, their timezone, and their last message, so whoever picks it up can finish the booking.
Budgets measure money: OpenRouter, Parlant's default provider, reports what each call cost, and for other providers you add prices for your models. Human handoff covers how your team picks the conversation up from the needs-human queue and hands it back.
Doing this yourself?
In the scaffold this is its own domain, domains/handoff/, and main.py installs it with one call:
await handoff.install_conversation_cap(server, agent, cap=conversation_cap())
conversation_cap() in config.py reads CONVERSATION_CAP_USD from the environment, and defaults to 1.00. Here is what the call installs:
from decimal import Decimal
CAP_MESSAGE = (
"We've reached the limit of what I can cover in this chat. If you share your name and "
"email here, someone from our team will get back to you."
)
CAP_MESSAGE_KNOWN_EMAIL = (
"We've reached the limit of what I can cover in this chat. Someone from our team can get "
"back to you: just share your name, and confirm your email ({email}) or give us a better one."
)
def cap_message(email: str | None) -> str:
"""Don't ask for an email we already have; ask the visitor to confirm it instead."""
return CAP_MESSAGE_KNOWN_EMAIL.format(email=email) if email else CAP_MESSAGE
async def install_conversation_cap(server: p.Server, agent: p.Agent, *, cap: Decimal = Decimal("1.00")):
budget = await server.create_session_budget(name="conversation-cap", value=cap)
async def hand_to_team(context: p.EngineContext, match: p.RuleEvaluation) -> None:
session = p.Session.current
spent = await budget.measure()
await budget.reset() # so the same spend can't trigger this again if the team hands it back
await session.emit_message(cap_message(await session.metadata.get("lead_email")))
await session.update(mode="manual") # the agent stops replying on its own
await session.labels.add("needs-human") # your team's queue
# The cap can land mid-booking, so pass on what the visitor was choosing from.
last = context.interaction.last_customer_message
await sales_inbox.notify(
session_id=session.id,
reason="Conversation cap reached",
details={
"spent": str(spent),
"lead_id": await session.metadata.get("lead_id"),
"lead_band": await session.metadata.get("lead_band"),
"pending_dates": await session.metadata.get("offered_dates") or [],
"pending_times": await session.metadata.get("offered_times") or [],
"last_visitor_message": last.content if last else None,
"timezone": page_state(context.interaction.events).timezone,
},
)
await agent.create_observation(
condition="The conversation has reached its spending cap", # for people; the code decides
evaluation_func=budget.exceeded,
on_matched=hand_to_team,
)
return budget
budget.exceeded decides when the observation applies, and the handler puts the session in manual mode, so the agent stops replying on its own. Add prices for your models before creating the budget. The scaffold installs the cap only when every model it uses can be priced, which can_measure_spend() in config.py checks.
Already have a Parlant project? Your coding agent ports the scaffold's parts into it, step by step, and tests them with personas before anything reaches a real buyer.
Testing before launch
Start with the rules that live in code. The scaffold's tests call no model and finish in seconds, so your coding agent runs them after every change. They cover the scoring, the contact-detail checks, the cap message, reading the page views, and most of all the scheduling:
- The widget. The days and times come back as a valid
choicespayload, and the agent'sdatanever contains it. - Timezones and clock changes. New York, Berlin and Sydney each get the right times and labels, including the weeks when the UK and US clocks have changed on different dates.
- Waking hours. A visitor in Sydney isn't offered a call at 1:00 AM.
- The page views. The current page comes from the latest page view and the landing page from the first, a page view without a timezone keeps the last one reported, and with none at all the times are in UK time.
- The guards. No times before a lead is recorded, none for a poor-fit lead unless they asked, and nothing booked that wasn't offered. A typed "7am works" books the offered 7:00 AM time, and a time someone else just took returns the remaining times.
When you change your team's hours, the waking hours, or the languages, ask your coding agent to add a test for the case you care about.
Doing this yourself?
Run the tests with python -m pytest. Each test reads like the rule it checks:
def test_that_berlin_gets_24_hour_times_in_cet(calendar: FakeCalendar) -> None:
s = Scheduler(calendar, now=NOW)
st = state("Europe/Berlin")
apply(st, run(s.dates(st)))
result = run(s.times(st, date_text=st["offered_dates"][0]["label"]))
assert st["offered_dates"][0]["label"] == "Monday 26 Oct"
assert result.data["times"] == ["10:30–11:00", "12:00–12:30", "15:00–15:30", "17:30–18:00"]
assert result.data["timezone_short"] == "CET"
Then see how the agent behaves in conversation. Before real visitors see the agent, run it against personas: test customers that can never reach a real person's data. Create one per kind of visitor you expect, and run Dashboard simulations with each. A good first set:
- A hot trade buyer, such as a hotel refitting 40 rooms by spring, who should end up with
lead-hotandmeeting-booked. - A homeowner with a few windows, who should be offered a call, or a measuring visit if they'd rather.
- A student researching curtains, who should get answers and no band.
- An existing customer asking where their order is, who should be pointed to support.
- A visitor who first sounds small and then mentions a big project, whose band should change once.
- A visitor who prefers a phone call to giving an email.
- A hot buyer who can't make any of the offered times, who should end up with
follow-up-requested. - A designer in Los Angeles, and a hotel manager in Berlin writing in German. Post a page view for each session the way your site would, with its
timezoneandlocale(America/Los_Angelesanden-US,Europe/Berlinandde-DE), and check that the times are in their zone and the confirmation is in their language.
For each one, check the labels and the tool calls, not only the replies. Persona sessions are also how you keep tests out of your CRM and calendars: the tools know when the visitor is a test, and write to a sandbox instead.
Doing this yourself?
Inside a tool, p.Customer.current.is_persona tells you whether the visitor is a test:
def crm_for_this_visitor():
return sandbox_crm if p.Customer.current.is_persona else crm
Measuring and tuning
The labels give you the funnel without any extra pipeline. Each one is set at the moment its fact becomes true, by the part of the agent that knows it: a rule when it recognizes a situation in the conversation, and a tool when it has established a fact in code.
| Label | Set by | What it counts |
|---|---|---|
lead-intent | The qualification procedure, when it applies | Conversations with buying interest |
lead-hot, lead-warm, lead-cold | record_lead, one at a time | Leads by their latest band |
meeting-booked | book_meeting, after the booking succeeded | Meetings booked in the chat |
follow-up-requested | request_team_follow_up, after the team was told | Visitors no time suited, waiting for your team to get in touch |
asked-for-team | The "talk to someone" policy | Visitors who asked for a person |
needs-human | The conversation cap's handler | Conversations handed to your team |
When you open a single conversation, its metadata shows what the tools established:
| Metadata key | Set by | What it holds |
|---|---|---|
lead_id | record_lead | The lead's id in your CRM |
lead_band | record_lead | hot, warm or cold, read by the band policies |
lead_email, lead_phone | record_lead | The contact details the visitor typed |
do_not_contact | record_lead | Set when your CRM flags the visitor's address; holds for the rest of the conversation |
offered_dates, offered_times | find_meeting_dates, find_meeting_times, book_meeting | The days and times last offered, which a pick is checked against |
schedule_preference | find_meeting_dates | The time of day or weekdays the visitor asked for |
booked_slot | book_meeting | The booked time's start, in UTC |
In the Dashboard, filter sessions by label to see each step of the funnel. Your coding agent reads the same data with the Dashboard CLI:
parlant dashboard analytics labels --range last-7-days
parlant dashboard session list --label meeting-booked --sort desc
parlant dashboard session view SESSION_ID
analytics labels counts each label over the range, session list finds the conversations behind a label, and session view reads one turn by turn.
From there, a weekly rhythm works well:
- Track quality, not volume. Leads that become meetings, and meetings that become deals, compared with leads from your forms. Chat counts and response times tell you little.
- Read conversations. Read the first fifty by hand after launch, then a sample every week. Look for questions the agent couldn't answer, which feed your product knowledge, and competitors visitors bring up.
- Find the drop-off. If visitors leave at the same question, reword it or cut it.
- Check the meetings themselves. Compare booked meetings with the ones that took place, and ask your team which were worth it. If almost no one is offered a meeting, the score is too strict. If sales complains about lead quality, it's too loose. Change the thresholds in
score_lead, not the agent's wording. - Follow your campaigns. When marketing launches a webinar or a new offer, add an opener for its landing page, and a page policy if the agent should mention it.
Session analytics covers labels and the queries that read them in more depth.
Your coding agent reads the numbers and the conversations with you, and proposes a few changes backed by evidence.