cd /news/ai-agents/qualify-real-estate-leads-with-an-em… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-53526] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Qualify real estate leads with an email agent

Nylas introduces Agent Accounts, which provide dedicated email addresses and calendars for autonomous real estate lead qualification. The system uses webhooks to trigger immediate responses to property inquiries, enabling agents to book showings without human intervention. A developer demonstrates how to create an agent account via the Nylas CLI and API.

read11 min views1 publishedJul 10, 2026

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.

Strip the AI out and a real estate lead loop is four moves:

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.

You need:

POST /v3/connect/custom

with "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"

grant_id

, and a Nylas API key exported as NYLAS_API_KEY

.message.created

webhook so the agent reacts within seconds of an inquiry β€” that responsiveness is the whole selling point.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.

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:

id

.id

is constant across all retries of one event, so that's your delivery dedup key. Drop it in a seen_notifications

set with a TTL and bail early on repeats. (You can additionally guard on the inner data.object.id

, the message id, so you never act twice on the same message even across distinct events.)GET /v3/grants/{grant_id}/messages/{message_id}

, and branch on message.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
});

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.

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.

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:

thread_id

to 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.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?"

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&notify_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.

A few things I'd build in before pointing this at real leads:

id

, and make booking idempotent on thread_id

so two near-simultaneous "yes" replies don't create two showings.thread_id

-keyed record as the system of record, not the inbox.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.

nylas email

and nylas calendar

flag used aboveWhen this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com

:

── more in #ai-agents 4 stories Β· sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/qualify-real-estate-…] indexed:0 read:11min 2026-07-10 Β· β€”