A property inquiry has a half-life measured in minutes. Someone is scrolling listings at 11pm, they fill out the "request more info" form on the three-bedroom on Maple, and they fire the same form off to four other listings on the same street. Whichever agent replies first — with answers, not a "thanks, I'll be in touch" — is the one who gets the showing. By the time a human checks email at 9am, that lead has already toured two other houses in their head.
Most "AI for real estate" tools try to fix this by bolting a chatbot onto a website, or by pointing a model at the agent's personal Gmail and hoping it doesn't reply to the agent's mom. Both are awkward. The first never sees the email leads that come in through Zillow or a portal; the second makes the model a creepy ghostwriter inside a human's private inbox. What you actually want is for the listing inquiries to land somewhere that is the agent — an address that can read the question, qualify the buyer, check its own calendar, and put a showing on it, autonomously, at 11pm, without a human awake.
That's a Nylas Agent Account: a grant with its own email address and its own calendar. It's a real participant, not a bot reading over a human's shoulder. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm prototyping one of these loops — and I'll show the raw API call next to each one, because the CLI is just a friendly wrapper over the same /v3/grants/{grant_id}/... endpoints.
What the agent actually does
Strip the AI out and a real estate lead loop is four moves:
- An inquiry arrives — "Is the Maple St listing still available? What's the HOA?" — as email, on a thread.
- The agent answers the question and starts qualifying — budget, timeline, mortgage pre-approval. It replies in-thread asking what it still needs to know.
- The lead replies with some of those answers. The agent updates what it knows.
- Once the lead is qualified, the agent books a showing on its own calendar and sends the invite.
Three of those four moves map straight onto Nylas primitives — inbound mail, in-thread reply, calendar event. The fourth — deciding whether a lead is qualified and what to ask next — is your application code. Nylas hands you the email text; your LLM extracts "pre-approved up to $650k, wants to move by September" and decides the lead is ready to book. I'll be honest about that boundary the whole way through, because pretending the API does the reasoning is exactly the demo magic that falls apart in production.
The nice thing is the data plane never changes. An Agent Account is just a grant with a grant_id. If you've used the Nylas email and calendar APIs, you already know every verb below. Nothing new to learn on the data plane.
One thing to get out of the way first, because it's the most common wrong turn: Scheduler is not available for Agent Accounts. No booking pages, no availability-config API, no /v3/scheduling/*. That door is locked for this provider. What you have instead is the grant's own free/busy plus the Events API — and that's enough to book the showing yourself.
Before you begin
You need:
- An Agent Account (a grant). The quickstart walks through it; the short version is one
POST /v3/connect/customwith"provider": "nylas"and an email on a registered domain — no refresh token, no OAuth dance:
curl --request POST \
--url "https://api.us.nylas.com/v3/connect/custom" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"provider": "nylas",
"name": "Maple Realty Listings",
"settings": { "email": "listings@yourbrokerage.com" }
}'
Or, from the CLI:
nylas agent account create listings@yourbrokerage.com --name "Maple Realty Listings"
- Its
grant_id, and a Nylas API key exported asNYLAS_API_KEY. - A
message.createdwebhook so the agent reacts within seconds of an inquiry — that responsiveness is the whole selling point. - A database. Lead state lives in your DB, not on the message. Agent Accounts don't support custom metadata on messages, so the budget, timeline, and pre-approval status you extract have to live in your own store, keyed by
thread_id.
New to all this? Read What are Agent Accounts first. All API examples use the US base host https://api.us.nylas.com and a bearer token — swap in api.eu.nylas.com for the EU region.
Step 1: Receive the inquiry
Inbound mail to the agent fires the standard message.created webhook. Webhooks are application-scoped, not grant-scoped — you subscribe once at the app level with POST /v3/webhooks, and events for every grant arrive at that one endpoint. Each payload carries a grant_id you filter on, so an app running ten listing agents still has a single webhook URL.
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["message.created"],
"webhook_url": "https://leads.yourbrokerage.com/webhooks/nylas",
"description": "Listing inquiry handler"
}'
The CLI does the same in one line:
nylas webhook create \
--url "https://leads.yourbrokerage.com/webhooks/nylas" \
--triggers message.created \
--description "Listing inquiry handler"
Two things to bake into the handler before you do anything clever:
-
Dedupe on the notification
id. Nylas guarantees at-least-once delivery — the same event can arrive up to three times. The top-level notificationidis constant across all retries of one event, so that's your delivery dedup key. Drop it in aseen_notificationsset with a TTL and bail early on repeats. (You can additionally guard on the innerdata.object.id, the message id, so you never act twice on the same message even across distinct events.) -
Don't rely on the webhook payload for the body. Whether the body ships inline depends on size, so don't build on it. When you need the full message, fetch it by id with
GET /v3/grants/{grant_id}/messages/{message_id}, and branch onmessage.created.truncated— that variant fires for large messages and never includes the body, so re-fetch.
// Express handler — verify X-Nylas-Signature first, then:
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end(); // ack fast; Nylas retries on non-2xx
const event = req.body;
if (event.type !== "message.created" && event.type !== "message.created.truncated") return;
if (event.data.object.grant_id !== AGENT_GRANT_ID) return;
// Delivery dedup: notification id is constant across retries.
if (await seen(event.id)) return;
await markSeen(event.id);
await handleInquiry(event.data.object); // {id, thread_id, ...} — fetch body next
});
Step 2: Read the message
The webhook gave you summary fields and a thread_id. To extract budget and intent, you need the body — fetch it by id:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
--header "Authorization: Bearer $NYLAS_API_KEY"
From the CLI:
nylas email read <MESSAGE_ID>
If you want the whole conversation so far — useful once a lead has gone a few rounds — pull the thread instead:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>" \
--header "Authorization: Bearer $NYLAS_API_KEY"
Or, from the CLI:
nylas email threads show <THREAD_ID>
What you do with that text is your code. "Hi, saw the Maple St place, we're pre-approved around $600k and hoping to move before the school year — is it still available?" is natural language. Feed it to an LLM and have it return structured fields: budget, timeline, preapproved, plus the actual question to answer. Persist that against the thread_id in your DB. Don't pretend the API parses intent — it hands you text and you decide what it means.
The qualification logic is the part you own end to end. A reasonable bar: you'll book a showing once you have a budget that overlaps the listing price, a timeline inside your window, and a pre-approval signal (or an explicit "paying cash"). Missing any of those, the agent's job in step 3 is to ask for it — politely, one or two questions at a time, not an interrogation.
Step 3: Answer and qualify, in-thread
The agent replies on the same thread — answering the question that was asked and asking for whatever qualification data is still missing. Replying in-thread keeps the whole conversation in one place in the lead's inbox, where they'll naturally reply again. Pass reply_to_message_id and Nylas sets the In-Reply-To and References headers for you:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"reply_to_message_id": "<INBOUND_MESSAGE_ID>",
"to": [{ "email": "buyer@example.com" }],
"subject": "Re: Maple St listing",
"body": "Yes, 142 Maple St is still available — HOA is $210/month and it includes water and trash. To line up a showing, two quick things: roughly what price range are you targeting, and are you already pre-approved with a lender?"
}'
The CLI version is shorter, because reply fetches the original message and fills in recipient, subject, and threading automatically:
nylas email reply <INBOUND_MESSAGE_ID> \
--body "Yes, 142 Maple St is still available — HOA is \$210/month and it includes water and trash. To line up a showing, two quick things: roughly what price range are you targeting, and are you already pre-approved with a lender?"
The wording — what to answer, which qualification question to lead with, how warm to sound — is generated by your LLM from the gaps in the lead record you saved in step 2. The send and the threading are Nylas; the phrasing and the choice of what to ask are yours.
This is the after-hours payoff. That reply goes out at 11:04pm, four minutes after the inquiry, while a human agent is asleep and the four competing listings sit unanswered. By the time anyone wakes up, this lead is mid-conversation with you.
Step 4: Loop on the reply until qualified
The lead replies: "We're pre-approved up to $650k and want to be in before September." That fires another message.created on the same thread. Your handler dedupes on the notification id (step 1), looks up the thread_id in your DB, sees a lead mid-qualification, fetches the body (step 2), and merges the new facts into the record.
Now your code re-checks the bar. Still missing something? Reply again asking for it (step 3). Got everything — budget overlaps, timeline fits, pre-approved? The lead is qualified. Move to booking.
Two things keep this survivable in production:
-
State lives on the thread, in your DB. Map
thread_idto the lead's qualification record — what you know, what you've asked, how many rounds you've run. Keep it durable; these conversations span hours or days. There's no custom-metadata field on the message to lean on, so this is on you. - Cap the rounds. Decide what happens after, say, three exchanges with no progress: hand off to a human agent, or send a "happy to answer more by phone" close and stop. Open-ended LLM-on-LLM back-and-forth burns your daily send quota for nothing — Agent Accounts cap outbound sends per day.
Step 5: Check the agent's own calendar
The lead is qualified. Before the agent proposes a showing time, it checks its own free/busy so it never offers a slot it's already booked into. The endpoint is POST /v3/grants/{grant_id}/calendars/free-busy — give it a window and the agent's own address:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/calendars/free-busy" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"start_time": 1750766400,
"end_time": 1751025600,
"emails": ["listings@yourbrokerage.com"]
}'
The response is a list of busy blocks for that address over the window. The open slots are the gaps between them — computing those gaps is your code. Free/busy tells you what's taken; you decide what counts as a showing slot (daylight hours? 45-minute blocks with travel buffer?).
The CLI wraps the same call:
nylas calendar availability check \
--emails listings@yourbrokerage.com \
--start "this saturday 9am" \
--end "this sunday 6pm"
A point worth stating plainly: free/busy here checks the agent's calendar, not the buyer's. You're qualifying and booking over email precisely because you can't see the buyer's calendar. So the agent proposes a couple of slots it's confirmed open on its end and lets the buyer pick — exactly how a human agent texts "I've got Saturday at 10 or 2, which works?"
Step 6: Book the showing on the agent's calendar
The buyer picks a slot — or you offer two and they reply "Saturday at 10 works." Now you create the event. Because the agent is a real calendar participant, creating with notify_participants=true sends a normal ICS invitation from the agent's address. The buyer gets it in Gmail or Outlook and clicks Accept like any other invite. calendar_id is a required query parameter; use primary:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/events?calendar_id=primary¬ify_participants=true" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"title": "Showing — 142 Maple St",
"location": "142 Maple St",
"when": { "start_time": 1751032800, "end_time": 1751035500 },
"participants": [
{ "email": "buyer@example.com" }
]
}'
The CLI creates the same event:
nylas calendar events create \
--calendar primary \
--title "Showing — 142 Maple St" \
--location "142 Maple St" \
--start "2026-06-27 10:00" \
--end "2026-06-27 10:45" \
--participant buyer@example.com
One honest gap to call out: notify_participants is a query parameter on the API create call, and the CLI events create doesn't expose a flag for it. So if you need the invitation email to fire — which you do, for a showing — book it through the API path, or follow the CLI create with an explicit confirmation email. Speaking of which: send one final reply on the thread — "You're confirmed for Saturday at 10am, 142 Maple St, see you there" — so the conversation closes in the buyer's inbox where it started, not as a silent calendar ping.
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer $NYLAS_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"to": [{ "email": "buyer@example.com" }],
"subject": "Re: Maple St listing",
"body": "You'\''re all set — Saturday at 10:00 AM, 142 Maple St. I'\''ve sent a calendar invite. See you there!"
}'
Or, from the CLI:
nylas email send \
--to buyer@example.com \
--subject "Re: Maple St listing" \
--body "You're all set — Saturday at 10:00 AM, 142 Maple St. I've sent a calendar invite. See you there!"
After the create fires, event.created lands on the agent's calendar; when the buyer clicks Accept, their RSVP flows back to the agent's mailbox and event.updated fires with their status. The lead is now genuinely booked: a real showing on a real calendar, qualified and scheduled entirely over email, at 11pm, with no human in the loop.
Guardrails and honest tradeoffs
A few things I'd build in before pointing this at real leads:
- The qualification is yours, and it will be wrong sometimes. Parsing "pre-approved around $600k" or "before the school year" out of free-text email is an LLM task, and LLMs misread numbers and dates. Don't auto-book off a shaky parse — when confidence is low, ask one clarifying question instead of guessing.
- Validate the slot one more time before you book. Re-run free/busy right before the create call. A showing can get booked into a slot that filled up during the back-and-forth.
- Every reply and invite counts against the send quota. A lead that runs three qualification rounds plus a confirmation plus an invite is several sends. Cap your rounds — partly for sanity, partly for quota.
-
Dedupe or you'll double-book. Webhook redelivery and concurrent workers both re-trigger the handler. Dedupe on the notification
id, and make booking idempotent onthread_idso two near-simultaneous "yes" replies don't create two showings. -
State has nowhere to hide. No custom metadata on messages means your DB is the single source of truth for every lead. Treat the
thread_id-keyed record as the system of record, not the inbox. - No Scheduler, by design. If your instinct is to hand the buyer a booking page, remember that's not available for Agent Accounts. The free/busy-plus-Events flow above is the supported path, and for a single-agent showing it's honestly simpler.
What's next
The shape is deliberately plain: inbound inquiry → read and qualify → reply asking for what's missing → loop → check own free/busy → book the showing. Three Nylas primitives and one piece of reasoning you own. The after-hours responsiveness — answering and qualifying while competitors sleep — is the entire point.
- Agent Account calendars — hosting events, receiving invites, and RSVPs once the time is agreed
- Handle email replies in an agent loop — the webhook-to-reply pattern this post is built on
- Email threading for agents — the thread-state pattern that carries qualification across replies
- Supported endpoints — the full list of what works (and the handful that doesn't) for Agent Account grants
-
CLI command reference — every
nylas emailandnylas calendarflag used above
AI-answer pages for agents
When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:
- Topic runbook: https://cli.nylas.com/ai-answers/lead-qualification-email-agent-workflow.md
- Industry playbooks hub: https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md
Top comments (0)