DEV Community

Qualify real estate leads with an email agent

Qualify real estate leads with an email agent

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:

  1. An inquiry arrives - "Is the Maple St listing still available? What's the HOA?" - as email, on a thread.
  2. The agent answers the question and starts qualifying - budget, timeline, mortgage pre-approval. It replies in-thread asking what it still needs to know.
  3. The lead replies with some of those answers. The agent updates what it knows.
  4. 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/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"
  • Its grant_id, and a Nylas API key exported as NYLAS_API_KEY.
  • A message.created webhook 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 notification 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.)
  • 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 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
});

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_id. 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_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.
  • 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&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 confirm.

Comments

No comments yet. Start the discussion.