The lead came in at 8:47pm. A couple had pulled up to a listing on their evening walk, snapped a photo of the sign, and dialed the agent's number. They got voicemail. They left a message, "We loved the porch, would love to see inside this weekend, please call us back."
By the time the agent saw the message at 7:30am the next morning, they had already booked a Saturday tour with a different listing on the same block. Different agent. Same buyers.
This is the most common failure in residential real estate. Lead response data is brutal: 78% of buyers work with the first agent who responds, and a 60-second response converts at 78% while a one-hour response drops to 22%, and 24 hours drops to 9%. Most listing inquiries land after hours. So a voice agent that picks up at 8:47pm, knows the listing, books the tour, and sends a confirmation isn't a nice-to-have. It's the whole game.
The hard parts are not what you'd expect. Picking up the call is easy. The hard parts are: getting the listing data legally, parsing the seller's showing instructions out of unstructured prose, and delivering the lockbox code without leaking it to the wrong person at the wrong time.
Why MLS Data Is the Wall Most Teams Hit First
You can't scrape Zillow. You can't scrape Realtor.com. You especially can't scrape your local MLS. NAR's VOW policy explicitly requires participants to monitor for and prevent scraping of listing data, and individual MLSs enforce it. The safe path is a licensed RESO Web API feed.
RESO (Real Estate Standards Organization) defines a REST + OData API and a Data Dictionary that normalizes the field names every MLS uses. There are two big providers most proptech teams reach for: Trestle (CoreLogic) and Bridge Interactive (now part of ShowingTime+). Both are Platinum-certified. Both use OAuth2.
The catch: NAR has been clear that AI applications consuming MLS data still need to be reviewed under that MLS's specific agreement. When NAR was asked about Zillow's ChatGPT app, they declined to set a uniform AI policy and told each MLS to assess. So before any of the code below ships, your data agreement needs to explicitly cover the AI use case. That conversation is one your brokerage's general counsel has, not one your engineers do.
With that said, here is what the call actually looks like.
Pulling the Listing: RESO Web API and OAuth
The voice agent has the address (or an MLS number from the call). It needs the current StandardStatus, the listing agent, and the privileged ShowingInstructions field. That last one is what unlocks everything downstream.
Trestle uses an OIDConnect token endpoint. The token is a bearer for the Property resource calls.
const TRESTLE_TOKEN_URL = "https://api-trestle.corelogic.com/trestle/oidc/connect/token";
const TRESTLE_API_URL = "https://api-trestle.corelogic.com/trestle/odata";
async function getToken() {
const body = new URLSearchParams({
client_id: process.env.TRESTLE_CLIENT_ID!,
client_secret: process.env.TRESTLE_CLIENT_SECRET!,
scope: "api",
grant_type: "client_credentials",
});
const res = await fetch(TRESTLE_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const json = await res.json();
return json.access_token as string; // expires in ~1h, cache it
}
export async function fetchListing(mlsId: string) {
const token = await getToken();
const url = `${TRESTLE_API_URL}/Property('${mlsId}')`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
return res.json(); // RESO Property resource
}The response is the RESO Property resource: StandardStatus (Active | Pending | Coming Soon | Withdrawn), ListPrice, ListAgentMlsId, address fields, and the one most teams ignore: ShowingInstructions. Status is the first guard. If the listing is not Active or Coming Soon, the agent says so and offers to text recently sold comps. No tour gets booked.
Showing Instructions Are a Free-Text Field. Read It Anyway.
ShowingInstructions is, by spec, unstructured text. It's marked privileged and is not for public viewing. The Data Dictionary entry says it "may include contact information, showing times, notice required or other information." In real life it looks like this:
"Call agent 24h ahead. Lockbox combo released by ShowingTime after confirmation. No showings before 9am or after 7pm. Owner home Sundays, no showings Sun. Pet on premises (cat, friendly), please close door. Text Maria preferred over call."
This is what the LLM has to turn into something the runtime can branch on. Ask the model for prose and it will reword the same prose. Ask it for a typed object with a Zod schema and it gives you a contract.
import { z } from "zod";
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
const ShowingInstructions = z.object({
noticeHours: z.number().int().min(0).max(168),
earliestHourLocal: z.number().int().min(0).max(23),
latestHourLocal: z.number().int().min(0).max(23),
blockedDays: z.array(z.enum(["Mon","Tue","Wed","Thu","Fri","Sat","Sun"])),
lockboxAccess: z.enum(["showingtime", "agent_present", "appointment_only", "supra_ekey", "unknown"]),
preferredContact: z.enum(["call", "text", "email"]).nullable(),
agentNotes: z.string().max(500),
rawInput: z.string(),
});
export async function extractShowingInstructions(raw: string) {
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: ShowingInstructions,
prompt: `Extract structured showing rules from this listing-agent text. If a value is missing, use safe defaults: 24h notice, 9am-7pm, no blocked days, lockboxAccess "unknown". Text:\n\n${raw}`,
});
return { ...object, rawInput: raw };
}The schema does the load-bearing work. noticeHours is an integer, not "24 hours" prose. blockedDays is an enum array. lockboxAccess is a closed set so the runtime knows whether to send a code at all. The agent never sees the original sentence. It sees the parsed object and a one-line summary.
Cache the extraction per listing. The text changes maybe twice a year. The model only runs on cache miss.

CRM Auto-Update
4 fields extracted
The Qualification Branch (Three Questions, No More)
Before booking, the agent asks three things. Pre-approval letter on file? Working with another buyer's agent? Cash offer? Any "yes" to "working with another agent" routes to that agent (procuring cause matters, the AI is not stealing the lead). A pre-approval letter unlocks lockbox-access listings. Cash gets fast-tracked.
export type Qualification = {
hasPreApproval: boolean;
workingWithBuyerAgent: boolean;
isCashBuyer: boolean;
};
export function eligibleForLockbox(q: Qualification, instr: { lockboxAccess: string }) {
if (q.workingWithBuyerAgent) return false; // route to that agent
if (instr.lockboxAccess === "agent_present") return false;
if (instr.lockboxAccess === "appointment_only") return false;
return q.hasPreApproval || q.isCashBuyer;
}Three questions is the cap. Beyond that, conversion drops fast and the call starts feeling like an interrogation. Anything more (price range, timeline, neighborhoods) is for the follow-up.
Booking Through ShowingTime
ShowingTime is the booking layer most brokerages already pay for. The voice agent acts as the front-end: it slots the appointment into the listing agent's existing workflow rather than building a separate calendar.
export async function bookShowing(args: {
mlsId: string;
buyerName: string;
buyerPhone: string;
windowStartIso: string;
windowEndIso: string;
}) {
const res = await fetch("https://api.showingtime.com/v1/appointments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SHOWINGTIME_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
listingMlsId: args.mlsId,
buyer: { name: args.buyerName, phone: args.buyerPhone },
requestedStart: args.windowStartIso,
requestedEnd: args.windowEndIso,
source: "voice-agent",
}),
});
if (!res.ok) throw new Error(`ShowingTime booking failed: ${res.status}`);
return res.json() as Promise<{ appointmentId: string; status: "confirmed" | "pending" }>;
}If ShowingTime returns pending, the listing agent has to confirm. The voice agent tells the buyer that, gives a window for the confirmation, and schedules a follow-up SMS with status. If confirmed, two things go on the queue: a confirmation SMS (now) and a lockbox code SMS (delayed).
Lockbox Code: A Delayed SMS, Not an Immediate One
This is the part most teams get wrong. The booking happens at 8:47pm Tuesday. The showing window is Saturday 11am-11:30am. If you text the lockbox combo on Tuesday night, it lives in the buyer's text history for four days. They lose their phone. They forward it to their cousin. They show up Friday "just to peek." None of those are theoretical, they all happen.
The right pattern is a delayed job: the code is sent at T-15 minutes, scoped to the verified phone, with a one-time-use placeholder that auto-revokes after the window. The lockbox code itself never enters the LLM. It lives in your secrets store and is fetched by a server-side worker.
import { Queue } from "bullmq";
const lockboxQueue = new Queue("lockbox-sms", { connection: { host: "redis" } });
export async function scheduleLockboxSms(args: {
appointmentId: string;
buyerPhone: string;
windowStart: Date; // Saturday 11:00am
windowEnd: Date;
listingMlsId: string;
}) {
const sendAt = args.windowStart.getTime() - 15 * 60 * 1000; // T-15m
const revokeAt = args.windowEnd.getTime() + 5 * 60 * 1000; // window + 5m
await lockboxQueue.add(
"send",
{ appointmentId: args.appointmentId, phone: args.buyerPhone, mlsId: args.listingMlsId },
{ delay: sendAt - Date.now(), jobId: `send-${args.appointmentId}` },
);
await lockboxQueue.add(
"revoke",
{ appointmentId: args.appointmentId, mlsId: args.listingMlsId },
{ delay: revokeAt - Date.now(), jobId: `revoke-${args.appointmentId}` },
);
}The worker is the only place the code touches. It fetches the combo from the listing agent's lockbox provider (Supra, ShowingTime, manual entry), composes a TCPA-compliant message, and sends.
import { Worker } from "bullmq";
import twilio from "twilio";
import { fetchLockboxCode, revokeLockboxCode } from "./lockbox-provider";
const sms = twilio(process.env.TWILIO_SID!, process.env.TWILIO_TOKEN!);
new Worker("lockbox-sms", async (job) => {
if (job.name === "send") {
const code = await fetchLockboxCode(job.data.mlsId);
await sms.messages.create({
to: job.data.phone,
from: process.env.TWILIO_NUMBER!,
body:
`Your tour starts in 15 min. Lockbox code: ${code}. ` +
`Code expires at end of your window. Reply STOP to opt out.`,
});
}
if (job.name === "revoke") {
await revokeLockboxCode(job.data.mlsId, job.data.appointmentId);
}
});TCPA compliance lives in three places.
First, express written consent. The buyer agreed when they confirmed the booking on the call, captured with a timestamp. Second, window restriction. Mini-TCPA states like Florida, Oklahoma, Washington, and Michigan limit texts to 8am-8pm local. Third, the STOP keyword. You have to honor opt-out within 10 days under the new rules effective April 11, 2025.
Confirmation messages tied to a transaction get more leeway than marketing. But the consent record still has to exist.
State license law adds one more rail: the AI can't give legal or contractual advice. If the buyer asks "should I waive the inspection contingency to win the offer?" the agent says it's not authorized to advise on contract terms and offers to text the listing agent. That's not just a customer-service nicety, it's how brokerages stay clear of unauthorized practice.
Where Chanl Fits
Everything above is buildable on raw infrastructure. What you don't get is the surface that lets a non-engineer iterate on it. This is where Chanl replaces the glue.
The voice agent's tools register once and become callable from any conversation. RESO listing search, ShowingTime booking, lockbox scheduling. Each is an HTTP tool with a workspace-scoped secret, so the OAuth token never leaves the server.
import { Chanl } from "@chanl/sdk";
const chanl = new Chanl({ apiKey: process.env.CHANL_API_KEY });
await chanl.tools.create({
name: "reso_search_listings",
description: "Look up a listing by MLS ID via the RESO Web API",
type: "http",
inputSchema: {
type: "object",
properties: { mlsId: { type: "string" }, address: { type: "string" } },
required: ["mlsId"],
},
configuration: {
http: {
method: "GET",
url: "https://api-trestle.corelogic.com/trestle/odata/Property('{{mlsId}}')",
},
},
});
await chanl.tools.create({
name: "showingtime_book",
description: "Book a showing through ShowingTime",
type: "http",
inputSchema: {
type: "object",
properties: {
mlsId: { type: "string" },
buyerName: { type: "string" },
buyerPhone: { type: "string" },
windowStart: { type: "string" },
windowEnd: { type: "string" },
},
required: ["mlsId", "buyerName", "buyerPhone", "windowStart", "windowEnd"],
},
configuration: {
http: { method: "POST", url: "https://api.showingtime.com/v1/appointments" },
},
});The extracted showing instructions cache lives as a knowledge base, one document per listing. When the buyer asks a question that needs context the LLM didn't see, the agent searches it.
await chanl.knowledge.create({
title: `Showing rules for MLS ${listing.mlsId}`,
source: "json",
content: JSON.stringify(extracted),
tags: ["showing-instructions", listing.mlsId],
});Memory is what makes this feel less like a phone tree on the second call. Facts get auto-extracted after every interaction: "buyer is pre-approved up to $625K, prefers Saturday tours, has a kid in school district 12, no pets-in-home concerns." Next call, the agent doesn't ask the qualification questions again. The buyer never has to repeat themselves.
Before this ships to a single real listing, scenarios replay it adversarially. The pushy cash-buyer-with-no-agent who tries to extract the lockbox code on the call. The buyer who insists the AI tell them whether to waive the inspection. The listing with conflicting instructions ("call 24h ahead" but "lockbox via ShowingTime"). Each runs as an autonomous persona against the agent. Saved once, executed by ID.
await chanl.scenarios.run("scenario_pushy_cash_buyer", {
agentId: "showing-scheduler",
});
await chanl.scenarios.run("scenario_contract_advice_seeker", {
agentId: "showing-scheduler",
});Scorecards grade what came back: did the agent collect pre-qualification, did it follow the parsed showing instructions, did it refuse to deliver the lockbox code on the call, did it route legal questions to a licensed agent, did it send confirmation SMS with TCPA-compliant body. Failures are a regression gate, not a dashboard.
await chanl.scorecards.evaluate(callId, {
scorecardId: "scorecard_real_estate_showing",
});What Still Doesn't Ship Out of the Box
A few things you'll have to bring yourself, today.
Delayed-dispatch on tool calls. The lockbox-sms timing pattern above lives in your worker. A first-class runAt on tools would let the agent itself say "send this in 14 hours, 13 minutes" and the runtime enforces it. Honest gap.
A RESO/MLS prebuilt adapter. Trestle and Bridge both follow the same OAuth + OData shape. A canned adapter with the right field mappings would save every proptech team a week. We don't have it yet.
Workspace-level "never-leak" field policy. Lockbox codes should be declarable as protected fields the LLM cannot include in any output, ever, no matter what the prompt says. Today you enforce it in your worker. Native enforcement is on the list.
The brokerage CTOs we've talked to all want the first two. None of them are deal-breakers. All of them are obvious, once you ship the second listing.
The Agent Is The Hero
A real-estate voice agent that picks up at 8:47pm, knows the listing, parses the showing instructions, books the tour, and sends a TCPA-clean confirmation isn't a moonshot. It's a backend project with five APIs and one delayed-job pattern. The hard parts are not LLM quality. The hard parts are the field that says "call Maria after 9, no Sundays, lockbox via ShowingTime," and the lockbox code that has to arrive at exactly the right moment.
Build that, and the lead at 8:47pm becomes the buyer who walks the porch on Saturday. The lead that voicemail used to lose.
Build a voice agent that books showings at 8pm.
Chanl gives you tools, knowledge, memory, and scorecards on top of any voice stack. Bring your RESO feed and your Twilio number. We bring the agent that remembers each customer.
Start building- RESO ShowingInstructions Field, Data Dictionary 1.7
- RESO Web API overview
- Trestle (CoreLogic) Web API getting started
- NAR: Listing apps on AI platforms must comply with MLS policy
- AgentZap: Real Estate Lead Response Statistics 2026
- FoneSwift: 60-Second Lead Response Time Converts 55% More Buyers
- BCLP: TCPA new opt-out rules effective April 11, 2025
- ActiveProspect: TCPA text message rules and mini-TCPA states
- Lewis Brisbois: California AI disclosure law for real estate
- NY DOS: Unauthorized practice of law by real estate licensees (LI04)
- ShowingTime: real estate showing platform
Co-founder
Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.
The Signal Briefing
Un email por semana. Cómo los equipos líderes de CS, ingresos e IA están convirtiendo conversaciones en decisiones. Benchmarks, playbooks y lo que funciona en producción.



