Skip to content

4. Real estate leads — the system has a heartbeat

You run a small real estate brokerage. You want listings with photos, leads with a state machine, viewings linking them, and your Slack to wake up when something interesting happens. The agent watches the same data and answers analytics questions on demand.

You’ll learn: file uploads via type: 'File', state machines, the framework’s record event bus, and how plugins compose without glue code.

Time budget: ~45 minutes.

Terminal window
npx create-davepi-app brokerage --template blank
cd brokerage
docker compose up -d
npm start

Delete schema/versions/v1/note.js.

schema/versions/v1/listing.js:

module.exports = {
path: 'listing',
collection: 'listing',
fields: [
{ name: 'userId', type: String, required: true },
{ name: 'address', type: String, required: true, searchable: true, searchWeight: 5 },
{ name: 'neighborhood', type: String, searchable: true },
{ name: 'price', type: Number, required: true },
{ name: 'beds', type: Number, required: true },
{ name: 'baths', type: Number, required: true },
{ name: 'square_feet', type: Number },
{
name: 'status',
type: String,
enum: ['draft', 'active', 'pending', 'sold', 'withdrawn'],
default: 'draft',
},
{ name: 'description', type: String, searchable: true },
{
// Multi-file upload field. The framework handles multipart
// upload, MIME validation, and a per-record file index.
// For very large images, layer in davepi-plugin-object-storage
// to do presigned PUT URLs instead of server-proxied uploads.
name: 'photos',
type: 'File',
maxFiles: 12,
allowedMime: ['image/jpeg', 'image/png', 'image/webp'],
},
{ name: 'listed_at', type: Date, default: Date.now },
],
};

schema/versions/v1/lead.js:

module.exports = {
path: 'lead',
collection: 'lead',
fields: [
{ name: 'userId', type: String, required: true },
{ name: 'name', type: String, required: true, searchable: true, searchWeight: 5 },
{ name: 'email', type: String, required: true },
{ name: 'phone', type: String },
{ name: 'interested_in_listing_id', type: String },
{ name: 'source', type: String, enum: ['website', 'referral', 'open_house', 'cold_inbound'], default: 'website' },
{
name: 'stage',
type: String,
stateMachine: {
initial: 'new',
states: ['new', 'contacted', 'qualified', 'toured', 'closed_won', 'closed_lost'],
transitions: {
new: ['contacted', 'closed_lost'],
contacted: ['qualified', 'closed_lost'],
qualified: ['toured', 'closed_lost'],
toured: ['closed_won', 'closed_lost'],
closed_won: [],
closed_lost: ['new'],
},
},
},
{ name: 'notes', type: String },
{ name: 'created_at', type: Date, default: Date.now },
],
relations: {
listing: { belongsTo: 'listing', localKey: 'interested_in_listing_id' },
},
};

schema/versions/v1/viewing.js:

module.exports = {
path: 'viewing',
collection: 'viewing',
fields: [
{ name: 'userId', type: String, required: true },
{ name: 'leadId', type: String, required: true },
{ name: 'listingId', type: String, required: true },
{ name: 'scheduled_at', type: Date, required: true },
{ name: 'attended', type: Boolean, default: false },
{ name: 'feedback', type: String },
],
relations: {
lead: { belongsTo: 'lead', localKey: 'leadId' },
listing: { belongsTo: 'listing', localKey: 'listingId' },
},
};

Hot reload picks all three up. The admin SPA renders the state-machine transition buttons for lead.stage automatically — allowed-next-states comes from the schema, the UI doesn’t have to know them. See State machines.

Register, log in, then through the admin UI seed 4–5 listings. For each one, upload 1–2 photos via the multi-file picker. The framework stores them in Mongo’s GridFS by default; for production-scale buckets you’d add davepi-plugin-object-storage later.

Suggested seed values:

AddressNeighborhoodPriceBedsBathsStatus
88 Park Slope AvePark Slope125000032active
412 Bushwick PlBushwick87500021active
11 Williamsburg LoftWilliamsburg180000022pending
7 Brownstone MewsPark Slope240000043active
99 Dumbo View Apt 12BDumbo110000011sold

Add ~10 leads through the admin UI, distributing them across listings and stages:

  • 4 in new (recent inbound).
  • 3 in contacted.
  • 2 in qualified.
  • 1 in toured (yesterday — set viewing row with attended: true).

Add 2–3 viewings linking leads to listings, with scheduled_at ranging from yesterday to next week.

The framework’s plugin model wires plugins via package.json:

Terminal window
npm install davepi-plugin-slack

Add to your project’s package.json:

{
"davepi": {
"plugins": [
"davepi-plugin-slack"
]
}
}

Add to .env:

Terminal window
# Webhook URL from your Slack workspace's Incoming Webhooks app.
# Slack admin → Apps → Incoming Webhooks → Add to channel → copy URL.
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../...
# Which CRUD events trigger a post. Comma-separated patterns
# matched against the `type` field on each record event.
SLACK_EVENT_PATTERNS=lead.created,lead.transitioned,viewing.created

This plugin is one-way notifications — a totally different shape from the @davepi/agent package’s interactive Slack bot. They’re complementary; you can run both. See davepi-plugin-slack for the full config surface.

Restart the davepi server (Ctrl+C, npm start). On boot you should see a log line confirming the plugin loaded:

{"plugin":"slack","msg":"plugin loaded","events":["lead.created","lead.transitioned","viewing.created"]}

21:00 — Create a lead and watch Slack wake up

Section titled “21:00 — Create a lead and watch Slack wake up”

Add a new lead through the admin UI. Within ~1 second, your configured Slack channel posts:

🆕 lead.created — name=Maya Chen, email=maya@example.com,
interested_in=88 Park Slope Ave, stage=new

Transition that lead to contacted using the admin SPA’s state button. Slack channel posts the transition with previous and next states. You did not write that code. The plugin subscribed to the framework’s record event bus (Plugins, Hooks) and the framework emits an event for every CRUD mutation including state-machine transitions.

Terminal window
npm install @davepi/agent

This brokerage has ~25 MCP tools (5 per resource × 3 resources + relations + aggregations) — well under the default 40-tool limit, so the tool router isn’t engaged. If you add 4 more resources later, set AGENT_TOOL_LIMIT=80 or let the router kick in (the agent picks a resource first, then loads that resource’s tools). See Surfaces → Agent → Tool router.

.env.agent:

Terminal window
DAVEPI_URL=http://localhost:5050
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
# Service mode uses this bearer statically (no refresh-token rotation).
# For a 45-minute tutorial, set ACCESS_TOKEN_TTL=2h in the davepi
# server's .env and restart it before grabbing a fresh /login token.
DAVEPI_BEARER=eyJ...
SLACK_BOT_TOKEN=xoxb-... # different from SLACK_WEBHOOK_URL above
SLACK_SIGNING_SECRET=...
SLACK_SOCKET_MODE=true
SLACK_APP_TOKEN=xapp-...

Set up the Slack bot using the checklist from Tutorial 1 if you don’t have one for this app yet.

Start:

Terminal window
set -a; source .env; source .env.agent; set +a
npx davepi-agent

30:00 — Ask the agent to add an aggregation

Section titled “30:00 — Ask the agent to add an aggregation”

Open Claude Code in the project:

Add an aggregation to lead called leadsByWeekAndStage that groups leads by ISO week of created_at and by stage, sorted chronologically.

Claude adds:

aggregations: [
{
name: 'leadsByWeekAndStage',
description: 'Lead counts grouped by ISO week of created_at and current stage.',
pipeline: [
{
$group: {
_id: {
year: { $isoWeekYear: '$created_at' },
week: { $isoWeek: '$created_at' },
stage: '$stage',
},
count: { $sum: 1 },
},
},
{ $sort: { '_id.year': 1, '_id.week': 1, '_id.stage': 1 } },
],
cache: { ttlSeconds: 60 },
},
],

Hot reload exposes it. New MCP tool: aggregate_lead_leadsByWeekAndStage.

Open the bot’s DM:

Moment 1 — a filtered query.

Show me 3-bedroom active listings in Park Slope under $2M as a table.

Agent calls list_listing with the filter, calls render_table. A Block Kit table appears with address / price / beds / baths.

Moment 2 — the event-driven bit. Open the admin UI in another tab. Create a new lead. In ~1 second your one-way channel (the plugin) posts the notification. The interactive bot is silent — it only speaks when spoken to. Two complementary surfaces.

Moment 3 — analytics.

Chart leads by week for the last quarter, broken down by stage.

Agent calls aggregate_lead_leadsByWeekAndStage, gets the grouped result, and calls render_chart with a stacked-bar Vega-Lite spec. The Slack channel serialises that to a QuickChart image URL. You see a clean weekly stacked-bar chart of your funnel.

DM the bot:

Which leads have been in qualified stage for more than 7 days without a scheduled viewing?

The agent:

  1. Calls list_lead with filter: { stage: 'qualified' }.
  2. For each lead, calls list_viewing with the lead id and scheduled_at: { $gte: <now> }.
  3. Cross-references and renders the stale ones as a table.

That’s three tool calls, choreographed by the model. You did not write a query for this.

42:00 — File uploads via the agent (optional)

Section titled “42:00 — File uploads via the agent (optional)”

The framework’s type: 'File' field exposes upload tools to MCP. With Claude Code or another MCP client you can:

Show me the photos for the 88 Park Slope listing.

Agent calls list_listing_files (an auto-generated tool for the photos field), returns the URLs. In Slack, the URLs come back as links; in the embeddable widget you could <img src=...> them. See Features → Files.

What you have:

  • 3 collections with relations, a state machine, and multi-file uploads.
  • A one-way Slack channel that wakes up on every CRUD event — zero glue code.
  • An aggregation Claude wrote, exposed on REST + GraphQL + MCP + Swagger automatically.
  • An interactive Slack bot answering filtered queries, doing multi-step lookups, and rendering analytics charts.
  • Two Slack surfaces running side by side: the passive notification plugin and the active agent — different jobs, same workspace.