DEV Community

Qasim
Qasim

Posted on

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.email trial subdomain) — tenants email it, and replies come from it.
  • The standard message.created webhook on inbound mail, plus deliverability triggers — message.delivered, message.bounced, message.complaint, and message.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:

  1. An API key. All requests authenticate with Authorization: Bearer <NYLAS_API_KEY>, and the key identifies your application. Examples here hit https://api.us.nylas.com.
  2. 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"
    }
  }'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

Same thing from the terminal:

nylas webhook create \
  --url https://maintenance.oakwood-apartments.com/webhooks/nylas \
  --triggers message.created \
  --description "Tenant maintenance intake"
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

From the CLI:

nylas email read <message-id>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>"]
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI wraps the same call:

nylas email move <message-id> --folder <emergency-folder-id>
Enter fullscreen mode Exit fullscreen mode

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" }'
Enter fullscreen mode Exit fullscreen mode

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"] }'
Enter fullscreen mode Exit fullscreen mode

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" }
    ]
  }'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>"] }'
Enter fullscreen mode Exit fullscreen mode

The CLI equivalent, if you built the rule some other way and just need to wire it up:

nylas workspace update <workspace-id> --rules-ids <rule-id>
Enter fullscreen mode Exit fullscreen mode

If you also want a stricter custom policy on this mailbox — say a tighter daily send cap so a bug can't blast tenants — create it and attach it to the workspace. There's no --workspace flag on account creation; policies attach through the workspace.

One sharp edge here: nylas agent policy create --name "..." makes a bare policy with no limits set, which means the account just inherits your plan maximums — not what you want when the whole point is a tighter cap. To actually set limits you pass the full body. The send cap lives at limits.limit_count_daily_email_sent. API first:

curl --request POST \
  --url "https://api.us.nylas.com/v3/policies" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Maintenance Mailbox Policy",
    "limits": {
      "limit_count_daily_email_sent": 200,
      "limit_inbox_retention_period": 365,
      "limit_spam_retention_period": 30
    }
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI carries the same body — use --data (or --data-file), not --name, to get the limits in:

nylas agent policy create --data '{
  "name": "Maintenance Mailbox Policy",
  "limits": {
    "limit_count_daily_email_sent": 200,
    "limit_inbox_retention_period": 365,
    "limit_spam_retention_period": 30
  }
}'
Enter fullscreen mode Exit fullscreen mode

Either call returns the policy id. A policy does nothing until it's attached to the workspace — same as a rule. Attach it with PATCH /v3/workspaces/{id}:

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 '{ "policy_id": "<POLICY_ID>" }'
Enter fullscreen mode Exit fullscreen mode

Or the CLI:

nylas workspace update <workspace-id> --policy-id <policy-id>
Enter fullscreen mode Exit fullscreen mode

Email the tenant a status update — from the property's address

This is where the Agent Account earns its keep. The tenant should hear back from maintenance@oakwood-apartments.com, in the same thread they started, not from some no-reply transactional sender they can't reply to.

When the agent acknowledges a request or pushes a status ("a plumber is scheduled for Thursday 9am–noon"), thread it by passing reply_to_message_id. Nylas sets the In-Reply-To and References headers for you, so the update lands inside the tenant's existing conversation.

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "reply_to_message_id": "<MESSAGE_ID>",
    "to": [{ "email": "tenant@example.com" }],
    "subject": "Re: Water coming through the ceiling",
    "body": "Got it — this is logged as an emergency. A plumber is on the way and will call you within the hour."
  }'
Enter fullscreen mode Exit fullscreen mode

From the CLI, nylas email reply does the bookkeeping. Point it at the original message ID; it fetches the original to populate the recipient and subject, and preserves threading via reply_to_message_id automatically:

nylas email reply <message-id> --body "Got it — this is logged as an emergency. A plumber is on the way and will call you within the hour."
Enter fullscreen mode Exit fullscreen mode

For a brand-new outbound message that isn't a reply — a scheduled-work notice you initiate, say — use a plain send instead. It's the same messages/send endpoint, just without reply_to_message_id:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "to": [{ "email": "tenant@example.com" }],
    "subject": "Scheduled maintenance: water heater replacement",
    "body": "Your building'\''s water heater will be replaced Friday between 8am and noon."
  }'
Enter fullscreen mode Exit fullscreen mode

From the CLI:

nylas email send \
  --to tenant@example.com \
  --subject "Scheduled maintenance: water heater replacement" \
  --body "Your building's water heater will be replaced Friday between 8am and noon."
Enter fullscreen mode Exit fullscreen mode

nylas email reply is the command I lean on most when I'm testing one of these by hand — it's the shortest path from "a request landed" to "the tenant has a threaded acknowledgment in their inbox."

Carry context across the thread

A maintenance thread is rarely one message. The tenant reports the leak, you acknowledge, they reply "it's getting worse," and the agent needs the whole exchange to decide whether to upgrade the priority. Because every message in a conversation shares a thread_id, pulling the history is one call.

nylas email threads show <thread-id>
Enter fullscreen mode Exit fullscreen mode

Or the same over the API:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads/<THREAD_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
Enter fullscreen mode Exit fullscreen mode

The thread carries its message_ids; fetch those, sort by date, and hand the model the running conversation rather than the latest message in isolation. When a tenant replies "still leaking," re-running classification against the thread — not just the new line — is what lets the agent bump a normal ticket to emergency and re-file it.

Don't dispatch twice

This is where naive intake pipelines fall over in production. Nylas guarantees at-least-once delivery: the same event can arrive up to three times. Add concurrent workers and two of them can grab the same notification in the same millisecond. Either way, without a guard, one tenant request becomes two vendor dispatches and two acknowledgment emails.

Dedupe on the top-level notification id — it's constant across all retries of one event, which makes it the delivery dedup key. (The inner data.object.id is the message id; you can additionally guard on it to avoid acting on the same message twice, but the notification id is what catches redeliveries.) The state lives entirely in your own store — Agent Account resources don't support custom metadata, so there's nowhere on the message to stash it.

const notificationId = event.id; // constant across redeliveries of one event

// Atomic insert — truthy ONLY when the key was newly inserted:
//   Redis    -> SET notificationId 1 NX EX 86400
//   Postgres -> INSERT ... ON CONFLICT DO NOTHING, then check rowCount === 1
const wasInserted = await db.processedEvents.setIfAbsent(notificationId, {
  receivedAt: Date.now(),
});

if (!wasInserted) return; // already handled this delivery — bail
Enter fullscreen mode Exit fullscreen mode

Watch the polarity: setIfAbsent returns truthy only the first time it sees an ID. You proceed when it's true, exit when it's false. Invert that and you'd drop every genuine first request while happily reprocessing every duplicate. Give the record a ~24-hour TTL — long enough to catch a redelivery hours later, short enough that the table doesn't grow forever. For the concurrent-worker race, layer a per-thread lock on top.

Guardrails before you ship

An agent that emails tenants and dispatches vendors autonomously needs brakes:

  • Human-in-the-loop for emergencies. An emergency label should page a human and draft the acknowledgment — don't let the model be the only thing standing between a gas leak and a response.
  • Cap the conversation. Track a turn count per thread and escalate when it crosses a limit. An unbounded reply loop is a token sink and a reputation risk.
  • Outbound send cap. A tighter daily send limit on the workspace policy is the backstop that keeps a logic bug from blasting every tenant in the building.
  • Mind retention and limits. On the free plan, accounts cap at 200 messages/account/day with a 30-day inbox / 7-day spam retention window. A busy building can blow past that — size your plan and policy before launch.

What's next

You've now got maintenance@ as a real participant: it receives every tenant request, routes known vendors server-side by sender, classifies the rest by urgency in your own code, files each into a priority folder, and emails tenants threaded status updates from the property's own address — without dispatching anything twice.

AI-answer pages for agents

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

Top comments (0)