Triage tenant maintenance requests with a property-management agent
Triage Tenant Maintenance Requests with a Property-Management Agent
Most "AI for property management" pitches start with a model that reads a leasing manager's inbox and "helps them keep up." That's fine until you realize the bottleneck isn't reading - it's that a leaky faucet, a dead furnace in January, and a "the porch light is out" all arrive at the same address, in the same font, with the same level of zero structure, and somebody has to decide which one is an emergency before a tenant is sitting in the cold.
So let's not point a model at a human's mailbox. Let's give the property its own mailbox - maintenance@oakwood-apartments.com as a first-class participant that receives every tenant request, decides how urgent it actually is, drops it in the right priority queue, loops in the right vendor, and emails the tenant a status update from the property's own address. No shared inbox, no human triaging at 11pm, no "did anyone see this one?"
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I stand one of these up. Every concrete step gets the two-angle tour: the raw curl call and the nylas command that does the same thing.
What You Actually Get
An Agent Account is, underneath, just a Nylas grant with a grant_id. That's the whole trick, and it's worth sitting with: there's nothing new to learn on the data plane. Every grant-scoped endpoint you already know - Messages, Drafts, Threads, Folders, Attachments, Contacts, Calendars, Events - works against this grant exactly the way it works against a Gmail or Microsoft grant you got through OAuth. The provider is nylas instead of google, and that's the only difference your code sees.
For a maintenance pipeline that means:
- A real send-and-receive mailbox on a domain you control (or a
*.nylas.emailtrial subdomain) - tenants email it, and replies come from it. - The standard
message.createdwebhook on inbound mail, plus deliverability triggers -message.delivered,message.bounced,message.complaint, andmessage.rejected- so you know when a status update to a tenant actually landed. - No OAuth dance, no refresh token to babysit. One API call provisions it.
The part I like as an SRE: because it's a normal grant, it slots into whatever webhook, retry, and observability plumbing you already built for human accounts. The maintenance agent isn't a special-case code path - it's another grant ID flowing through the same machinery.
Why This Beats a Shared Inbox with Rules
A property manager's first instinct is a Gmail filter or two. The reason that falls apart for maintenance is that urgency lives in the body of the message, and a folder rule can't read the body. "Hi, no rush, the cabinet door is loose" and "THERE IS WATER COMING THROUGH THE CEILING" are indistinguishable to a sender-based filter - same tenant domain, same subject-less mess.
The split that makes this work, and the single most important thing to get right:
- Sender-based routing is a server-side Rule. If a request comes from a known HVAC vendor, your insurance adjuster, or the building owner, you can route it by who sent it before your app ever wakes up. That's a Nylas Rule - and Nylas inbound rules match only sender fields (
from.address,from.domain,from.tld). They cannot see the subject or the body. - Urgency-based prioritization is your app's job. Deciding "ceiling leak = emergency, loose cabinet = low" requires reading the content, which a Rule structurally cannot do. So that classification runs in your application - your LLM reads the fetched body - and then you move the message into a priority folder with a plain Messages call.
Get that boundary wrong and you'll spend a weekend trying to write a Rule that "matches urgent keywords in the subject" and wondering why it never fires. It never fires because inbound Rules don't look at the subject. Keep the two halves separate and each one is simple.
Before You Begin
Two things:
- An API key. All requests authenticate with
Authorization: Bearer <NYLAS_API_KEY>, and the key identifies your application. Examples here hithttps://api.us.nylas.com. - A verified domain. The account lives on a domain - a custom one you register and publish DNS for, or a Nylas trial subdomain like
oakwood.nylas.email. New domains warm up over roughly four weeks, so register the production one early. The DNS walkthrough is in the provisioning docs.
If you've already run nylas init, the CLI is pointed at your application and you're ready.
Provision maintenance@
You create the account with a single POST /v3/connect/custom using "provider": "nylas" and the address in settings.email. The optional top-level name becomes the default From display name on everything the account sends - tenants will see it, so make it human.
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": "Oakwood Maintenance",
"settings": {
"email": "maintenance@oakwood-apartments.com"
}
}'
The response hands you back data.id. Save it - that's the grant_id on every subsequent call.
From the CLI it's one line:
nylas agent account create maintenance@oakwood-apartments.com --name "Oakwood Maintenance"
That provisions the grant and prints its id, status, and connector details. If the underlying nylas connector doesn't exist on your application yet, the CLI creates it first. The API also auto-creates a default workspace and a default policy for the account - that's where your sender Rules will hang in a minute.
If you want a human leasing manager to be able to peek at the inbox over IMAP, pass --app-password at creation (18–40 printable ASCII characters, codes 33–126, with at least one uppercase, one lowercase, and one digit). Skip it and protocol access stays off, which for a fully automated intake mailbox is usually what you want.
Receive Every Tenant Request
The whole agent runs off one webhook. Inbound mail to maintenance@ fires the standard message.created notification - and a critical detail: webhooks are application-scoped, not grant-scoped. You subscribe once at the app level, events for every grant arrive at that one endpoint, and each payload carries a grant_id you filter on.
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://maintenance.oakwood-apartments.com/webhooks/nylas",
"description": "Tenant maintenance intake"
}'
Same thing from the terminal:
nylas webhook create \
--url https://maintenance.oakwood-apartments.com/webhooks/nylas \
--triggers message.created \
--description "Tenant maintenance intake"
Your handler should return 200 immediately, verify the X-Nylas-Signature header, and then work asynchronously. Two guards at the top: filter to your maintenance grant, and skip messages the agent itself sent (because message.created fires for outbound mail too, and an agent that triages its own status updates is a special kind of broken).
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end(); // ack fast; verify X-Nylas-Signature first in real code
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== MAINTENANCE_GRANT_ID) return;
if (msg.from?.[0]?.email === "maintenance@oakwood-apartments.com") return;
await intake(msg);
});
One thing not to overstate: don't rely on the webhook payload for the message body. Fetch the full message by ID when you need it - and branch on message.created.truncated, which is the event type you'll see if the body is large enough that Nylas omits it. Treat the payload as a pointer ("a message landed, here's its ID and summary"), not as the message.
Read the Full Request
Before any classification, pull the full message so the model has the actual complaint, not a snippet.
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
From the CLI:
nylas email read <message-id>
Reading a message does not mark it read - that's a separate PUT /v3/grants/{id}/messages/{id} with {"unread": false}, or nylas email read --mark-read. Keep fetch and mark-read as distinct operations; you don't want a GET silently mutating inbox state while you're still deciding what to do with the request.
Prioritize by Urgency - In Your App, Not in a Rule
Here's the half a folder filter can't do. Hand the model the subject, sender, and body, and ask it to bucket the request into a fixed set of priorities. Keep the label set small and closed - a model choosing from four levels is reliable; a model asked to "assess severity" in prose is a 2am debugging session.
async function intake(msg) {
const full = await getFullMessage(msg.grant_id, msg.id); // GET .../messages/{id}
const priority = await llm.classify({
instruction:
"Classify this tenant maintenance request into exactly one of: " +
"emergency (flooding, gas, no heat, electrical, security), " +
"high (no hot water, appliance down, pest), " +
"normal (leaky faucet, minor repair), " +
"low (cosmetic, request for info). Reply with only the label.",
subject: full.subject,
from: full.from[0].email,
body: full.body,
});
// Persist the decision in YOUR store. Agent Account resources don't support
// custom metadata, so priority/state lives in your DB, keyed by message id.
await db.requests.upsert({
messageId: full.id,
threadId: full.thread_id,
tenant: full.from[0].email,
priority,
state: "triaged",
});
await route(full, priority);
}
Two things to internalize. First, the priority decision is your application's state - there's no custom-metadata field on the message or grant to stash it on, so it lives in Postgres or Redis keyed by message ID. Second, the model's output is what drives every downstream action: which folder it lands in, whether a vendor gets paged, and how quickly a tenant hears back.
Move It into a Priority Folder
Once you have a label, move the message into the matching folder so a human (or a dashboard) can see the emergency queue at a glance. This is a plain Messages update - PUT the message with the destination in folders[].
curl --request PUT \
--url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"folders": ["<EMERGENCY_FOLDER_ID>"]
}'
The CLI wraps the same call:
nylas email move <message-id> --folder <emergency-folder-id>
Folder IDs come from nylas email folders list. I keep one folder per priority - emergency, high, normal, low - and the move is the visible artifact of the classification. If the agent decides something is an emergency, it's in the emergency folder within seconds of the tenant hitting send, and your on-call escalation can watch that folder instead of polling the model.
Route Known Vendors and the Owner by Sender Rule
Now the other half - the part that genuinely belongs server-side. Some senders you can route on identity alone, before your app spends a token. Your HVAC vendor replying with a quote, the building owner forwarding a notice, your plumbing contractor confirming a slot - those are known addresses, and routing them by sender is exactly what a Nylas Rule is for.
Remember the boundary: inbound Rules match only from.* (from.address, from.domain, from.tld) with operators is, is_not, contains, and in_list. They never read subject or content. That's perfect here, because vendor routing is a sender decision.
Put your vendors in a List so the office can add a new contractor without a deploy, then reference it from a Rule. API first - create the list and seed it:
curl --request POST \
--url "https://api.us.nylas.com/v3/lists" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Known vendors",
"type": "domain"
}'
That returns the list id. Add domains to it (list items are their own sub-resource):
curl --request POST \
--url "https://api.us.nylas.com/v3/lists/<LIST_ID>/items" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"items": ["acme-hvac.com", "reliable-plumbing.example"]
}'
Then a Rule that drops anything from those domains into a vendors folder and marks it read, so vendor traffic never competes with tenant requests for the model's attention:
curl --request POST \
--url "https://api.us.nylas.com/v3/rules" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Route known vendors to a folder",
"trigger": "inbound",
"match": {
"conditions": [
{
"field": "from.domain",
"operator": "in_list",
"value": ["<LIST_ID>"]
}
]
},
"actions": [
{ "type": "assign_to_folder", "value": "<VENDORS_FOLDER_ID>" },
{ "type": "mark_as_read" }
]
}'
The CLI collapses the list (with seeding) into one command, and the rule into another:
nylas agent list create --name "Known vendors" --type domain \
--item acme-hvac.com --item reliable-plumbing.example
nylas agent rule create \
--name "Route known vendors to a folder" \
--trigger inbound \
--condition from.domain,in_list,<LIST_ID> \
--action assign_to_folder=<VENDORS_FOLDER_ID> \
--action mark_as_read
A heads-up that bites people: a Rule is inert until it's attached to a workspace. Creating it through POST /v3/rules (or the bare API) does nothing until its ID lands in a workspace's rule_ids. The nylas agent rule create command attaches to the default workspace for you, but the raw API does not - you have to PATCH the workspace yourself:
curl --request PATCH \
--url "https://api.us.nylas.com/v3/workspaces/<WORKSPACE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"rule_ids": ["<RULE_ID>"]
}'
Comments
No comments yet. Start the discussion.