{"slug": "run-a-legal-intake-agent-on-its-own-mailbox", "title": "Run a legal intake agent on its own mailbox", "summary": "Nylas developer tools enable building a legal intake agent on a dedicated mailbox using an Agent Account. The agent captures prospect emails, checks conflicts, and routes to human attorneys without providing legal advice. The implementation uses Nylas APIs for inbound webhooks, message reading, and sending, with conflict-check routing that distinguishes between mail rules and application-level classification.", "body_md": "Law-firm intake is one of those problems that looks like a chatbot and isn't. A prospective client emails `intake@yourfirm.com`\n\ndescribing a dispute, and what the firm actually needs is careful *capture* and a clean *handoff* — collect the matter details, check the names against existing clients and known adverse parties, and put the whole thing in front of a human attorney. The one thing you must not do is let a model freelance legal advice over email.\n\nMost \"AI email\" demos point a model at a human's inbox and let it draft replies. That's fine for a personal assistant. It's the wrong shape for regulated intake, where you want the agent to *be* a participant with its own address, its own audit trail, and a hard wall between \"I'm gathering facts\" and \"an attorney decides.\" So this post builds the agent on a dedicated mailbox using a Nylas **Agent Account**, wires up inbound webhooks, walks the collect-and-reply loop, and — the interesting part — implements conflict-check routing that knows the difference between what a mail rule can see and what your application has to classify itself.\n\nI work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. Every step gets the two-angle tour: the raw `curl`\n\ncall and the `nylas`\n\nequivalent.\n\nAn Agent Account is just a **grant** — the same `grant_id`\n\nabstraction behind every Nylas mailbox. There's nothing new to learn on the data plane. Once the account exists, you read with `GET /v3/grants/{grant_id}/messages`\n\n, you send with `.../messages/send`\n\n, you reply in-thread with `reply_to_message_id`\n\n. The same calls you'd make against a connected Gmail account work here, except this mailbox belongs to your application instead of a person.\n\nFor intake specifically, that buys you:\n\n`intake@yourfirm.com`\n\n) that prospects can email and reply to, with threads that hold together across days.You'll need a Nylas application with an API key, and a registered sending domain (a custom domain, or a Nylas `*.nylas.email`\n\ntrial subdomain to start). New domains warm over roughly four weeks, so if deliverability matters for client correspondence, register the real one early. Examples below use `https://api.us.nylas.com`\n\nand an `Authorization: Bearer <NYLAS_API_KEY>`\n\nheader.\n\nOne honest caveat up front, because this is a regulated space: what follows builds *intake capture and routing*, not legal advice. The agent collects facts and hands off to a licensed attorney. Nothing here is legal advice about how to run a conflicts process — your firm's rules govern that. Treat the agent as the front desk, not the lawyer.\n\nCreate the Agent Account with `POST /v3/connect/custom`\n\n, using `provider: \"nylas\"`\n\nand a `settings.email`\n\non your registered domain. The optional top-level `name`\n\nsets the display name prospects see. No OAuth, no refresh token.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Firm Intake\",\n    \"settings\": { \"email\": \"intake@yourfirm.com\" }\n  }'\n```\n\nThe response carries a `grant_id`\n\n. That's your handle for everything else.\n\nFrom the CLI it's one line. The API auto-creates a default workspace and policy for the account, so you don't pass a workspace on create:\n\n```\nnylas agent account create intake@yourfirm.com --name \"Firm Intake\"\n```\n\nThere's no `--workspace`\n\nflag here, and that's deliberate — if you later want a custom policy (stricter send limits, tighter retention), you attach it to the account's workspace separately with `nylas workspace update <workspace-id> --policy-id <policy-id>`\n\n. For most intake setups, the default workspace is fine to start.\n\nInbound mail to an Agent Account fires the standard `message.created`\n\nwebhook. Webhooks in Nylas are **application-scoped**, not grant-scoped: you subscribe once at the app level, and every grant's events arrive at that one endpoint, each payload carrying a `grant_id`\n\nyou filter on. So you subscribe through the top-level `/v3/webhooks`\n\nendpoint:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://intake.yourfirm.com/webhooks/nylas\"\n  }'\n```\n\nOr with the CLI:\n\n```\nnylas webhook create \\\n  --url https://intake.yourfirm.com/webhooks/nylas \\\n  --triggers message.created\n```\n\nWhen an inquiry lands, you get a notification. Two things matter in how you handle it.\n\nFirst, **dedup.** Nylas guarantees at-least-once delivery — the same event can arrive up to three times. The dedup key is the **top-level notification id**, which stays constant across all retries of one event. Record it; if you've seen it, drop the duplicate. (The inner\n\n`data.object.id`\n\nis the message id — you can additionally guard on that to avoid acting twice on the same message, but the notification `id`\n\nis the delivery-level key.) For an intake agent this is not optional: you do not want to send a prospect two \"thanks, we got your inquiry\" replies because a worker retried.Second, **the body.** 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`\n\n(which Nylas uses when the body is large). The reliable move is always the same: take the message id from the notification and go get the message.\n\nA sketch of the handler:\n\n``` js\napp.post(\"/webhooks/nylas\", async (req, res) => {\n  res.status(200).end(); // ack fast\n\n  const event = req.body;\n  if (event.type !== \"message.created\" && event.type !== \"message.created.truncated\") return;\n  if (seen(event.id)) return;          // dedup on the notification id\n  markSeen(event.id);\n\n  const { grant_id, id: messageId } = event.data.object;\n  await handleInquiry(grant_id, messageId);\n});\n```\n\nNow fetch the full message by id. Keep this as a plain read — fetching does not mark anything read, that's a separate `PUT`\n\nif you want it.\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nFrom the terminal, while you're developing the agent and want to eyeball what came in:\n\n```\nnylas email read <message-id>\n```\n\n`nylas email read`\n\nshows the full message content; add `-r`\n\nif you specifically want to mark it read after viewing. This is the input your classifier works on — the prospect's description of the matter, the parties involved, the relief they're after.\n\nA first-touch inquiry is almost never complete. The agent's job is to ask for the specific facts intake needs — the other party's full legal name, the jurisdiction, key dates, whether litigation has already started — and to do it in-thread so the conversation holds together.\n\nReply with `reply_to_message_id`\n\nset to the inbound message. That preserves the thread by setting the `In-Reply-To`\n\nand `References`\n\nheaders, so the prospect sees a threaded reply, not a fresh email:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"reply_to_message_id\": \"<MESSAGE_ID>\",\n    \"to\": [{ \"email\": \"prospect@example.com\" }],\n    \"subject\": \"Re: New matter inquiry\",\n    \"body\": \"Thanks for reaching out. To get your matter to the right attorney, could you share the full legal name of the other party, the state where this is happening, and any key dates?\"\n  }'\n```\n\nThe CLI fetches the original to populate recipient and subject automatically, so you only supply the body:\n\n```\nnylas email reply <message-id> --body \"Thanks for reaching out. To route your matter, could you share the full legal name of the other party, the state involved, and any key dates?\"\n```\n\nWhen the prospect replies, `message.created`\n\nfires again on the same `thread_id`\n\n. Pull the thread for full context before deciding what to ask next, so the agent reasons over the whole exchange instead of one stray reply:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads/<THREAD_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\nnylas email threads show <thread-id>\n```\n\nLoop until you have what intake needs. Two practical notes on the loop: the webhook fires for the agent's *own* outbound sends too, so filter on the sender to avoid replying to yourself, and keep the dedup guard in place because multiple replies can land on one thread.\n\nHere's the part that trips people up, and it's worth being precise about because conflicts are exactly where \"close enough\" gets a firm in trouble.\n\nAgent Accounts have account-level **Rules** that filter mail. An inbound Rule matches on **sender fields only** — `from.address`\n\n, `from.domain`\n\n, and `from.tld`\n\n, with operators `is`\n\n, `is_not`\n\n, `contains`\n\n, and `in_list`\n\n. That makes Rules a genuinely good fit for one slice of conflict checking: *known adverse parties you can identify by their email*. If your firm maintains a list of domains or addresses you must not represent against, a Rule can reject that mail at SMTP before it ever reaches the mailbox.\n\nBuild it as a **List** the conflicts team can maintain, then a Rule that references it. From the CLI:\n\n```\nnylas agent list create --name \"Adverse parties\" --type domain --item adverseco.example\nnylas agent rule create --name \"Suppress adverse-party mail\" \\\n  --trigger inbound \\\n  --condition from.domain,in_list,<LIST_ID> \\\n  --action block\n```\n\nThe same thing over the API — create the list, seed it with the adverse-party domains, then create the rule. The create call returns the list's `id`\n\n, which you use both to add items and to reference the list from the rule:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/lists\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"name\": \"Adverse parties\", \"type\": \"domain\" }'\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/lists/<LIST_ID>/items\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"items\": [\"adverseco.example\", \"anothercounterparty.example\"] }'\n```\n\nThe CLI does the same — the `--item`\n\nflag above seeds at creation time, and `nylas agent list add`\n\nappends to an existing list, which is what the conflicts team runs as the watchlist grows:\n\n```\nnylas agent list add <list-id> adverseco.example anothercounterparty.example\n```\n\nThat's the seam non-engineers own: they keep the List current, and every rule referencing it picks up new values immediately. With the list seeded, create the rule that references it:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/rules\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"name\": \"Suppress adverse-party mail\",\n    \"trigger\": \"inbound\",\n    \"match\": {\n      \"conditions\": [\n        { \"field\": \"from.domain\", \"operator\": \"in_list\", \"value\": [\"<LIST_ID>\"] }\n      ]\n    },\n    \"actions\": [ { \"type\": \"block\" } ]\n  }'\n```\n\nOne thing to know: a Rule is **inert until it's attached to a workspace** via that workspace's `rule_ids`\n\n. The CLI `nylas agent rule create`\n\nattaches the new rule to the account's default workspace for you; over the raw API you add the rule's ID to the workspace's `rule_ids`\n\narray with `PATCH /v3/workspaces/{workspace_id}`\n\n. Until you do, the rule exists but does nothing.\n\nNow the honest limit, and it's the whole reason this section exists. **A Rule cannot read message content.** It matches the *sender*, not the subject and not the body. Real conflict checking is mostly about *names inside the matter* — the opposing party a prospect mentions in their description, a company that isn't the sender, a person who'll never appear in a `from`\n\nheader. A Rule can't see any of that, and you should never claim it does.\n\nSo content-level conflict checks are **application-side classification**, not a Rule. After you fetch the message, run the matter text through your own conflicts logic (your client database, a watchlist, an LLM extracting party names — your call), and act on the result by *moving the message*, not by trusting a rule to have caught it:\n\n```\ncurl --request PUT \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"folders\": [\"<CONFLICT_REVIEW_FOLDER_ID>\"] }'\nnylas email move <message-id> --folder <conflict-review-folder-id>\n```\n\nThe mental model worth keeping: **sender-based suppression is a Rule; content-based conflict review is your code plus a folder move.** Conflating the two is how you'd accidentally let a real conflict through because you assumed a rule was reading something it can't.\n\nWhen the agent has collected enough — or when conflict logic flags a possible hit — the matter goes to a person. Escalation here means two concrete things, and neither of them is \"the agent decides the case.\"\n\nFirst, **move the message into a human-review folder** an attorney monitors. Create that folder once — the response carries the folder `id`\n\nyou route into later:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/folders\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{ \"name\": \"Attorney review\" }'\nnylas email folders create \"Attorney review\"\n```\n\nThen route into it with the same `PUT ... folders[]`\n\n/ `nylas email move`\n\nyou used for conflict review — just a different destination folder.\n\n```\nnylas email move <message-id> --folder <attorney-review-folder-id>\n```\n\nSecond, **pause the agent's auto-replies for that thread.** This is application-side state — you flip a flag in your own store keyed by `thread_id`\n\nand your webhook handler checks it before replying. Nylas Agent Accounts don't support custom metadata on messages, so don't try to stash \"escalated\" on the message itself; keep that state in your database. Once an attorney is in the loop, your handler sees the flag and stops generating replies, so the agent never talks over a lawyer.\n\nThat's the clean handoff: facts captured, routed, parked in front of a human, with the bot silenced. The agent did intake. The attorney does law.\n\nA few things I'd nail down before pointing real prospects at this:\n\n`id`\n\n, and additionally on the message id, before you ever send a reply.`GET`\n\ndoesn't change its unread state — mark it read explicitly with `PUT ... {\"unread\": false}`\n\nif your attorney's view depends on it.The grant abstraction is the whole trick here: once `intake@yourfirm.com`\n\nis an Agent Account, every endpoint you already know — messages, threads, folders, webhooks — works exactly as it does on a personal mailbox. Read the [Agent Accounts overview](https://developer.nylas.com/docs/v3/agent-accounts/) for provisioning and domain warming, the [Policies, Rules, and Lists guide](https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/) for the full Rule condition reference (including exactly which fields each trigger accepts), and the [handle email replies recipe](https://developer.nylas.com/docs/cookbook/agent-accounts/handle-replies/) for the reply-loop and dedup patterns in depth. The CLI commands are all documented at [cli.nylas.com/docs/commands](https://cli.nylas.com/docs/commands).\n\nWhen this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/run-a-legal-intake-agent-on-its-own-mailbox", "canonical_source": "https://dev.to/mqasimca/run-a-legal-intake-agent-on-its-own-mailbox-6n5", "published_at": "2026-07-10 01:27:37+00:00", "updated_at": "2026-07-10 01:35:50.024574+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-products"], "entities": ["Nylas", "Agent Account", "Nylas CLI"], "alternates": {"html": "https://wpnews.pro/news/run-a-legal-intake-agent-on-its-own-mailbox", "markdown": "https://wpnews.pro/news/run-a-legal-intake-agent-on-its-own-mailbox.md", "text": "https://wpnews.pro/news/run-a-legal-intake-agent-on-its-own-mailbox.txt", "jsonld": "https://wpnews.pro/news/run-a-legal-intake-agent-on-its-own-mailbox.jsonld"}}