DEV Community

Qasim
Qasim

Posted on

Run a legal intake agent on its own mailbox

Law-firm intake is one of those problems that looks like a chatbot and isn't. A prospective client emails intake@yourfirm.com describing 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.

Most "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.

I 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 call and the nylas equivalent.

What you get

An Agent Account is just a grant — the same grant_id abstraction 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, you send with .../messages/send, you reply in-thread with reply_to_message_id. The same calls you'd make against a connected Gmail account work here, except this mailbox belongs to your application instead of a person.

For intake specifically, that buys you:

  • A real, addressable inbox (intake@yourfirm.com) that prospects can email and reply to, with threads that hold together across days.
  • Inbound webhooks so your code reacts the moment a new-matter inquiry lands.
  • Account-level Rules that can reject mail from known-adverse senders at SMTP, before your application ever sees it.
  • A clean place to park escalations — a folder an attorney monitors — with the agent's auto-replies paused.

Before you begin

You'll need a Nylas application with an API key, and a registered sending domain (a custom domain, or a Nylas *.nylas.email trial 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 and an Authorization: Bearer <NYLAS_API_KEY> header.

One 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.

Provision the intake mailbox

Create the Agent Account with POST /v3/connect/custom, using provider: "nylas" and a settings.email on your registered domain. The optional top-level name sets the display name prospects see. No OAuth, no refresh token.

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": "Firm Intake",
    "settings": { "email": "intake@yourfirm.com" }
  }'
Enter fullscreen mode Exit fullscreen mode

The response carries a grant_id. That's your handle for everything else.

From 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:

nylas agent account create intake@yourfirm.com --name "Firm Intake"
Enter fullscreen mode Exit fullscreen mode

There's no --workspace flag 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>. For most intake setups, the default workspace is fine to start.

Receive new-matter inquiries

Inbound mail to an Agent Account fires the standard message.created webhook. 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 you filter on. So you subscribe through the top-level /v3/webhooks endpoint:

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://intake.yourfirm.com/webhooks/nylas"
  }'
Enter fullscreen mode Exit fullscreen mode

Or with the CLI:

nylas webhook create \
  --url https://intake.yourfirm.com/webhooks/nylas \
  --triggers message.created
Enter fullscreen mode Exit fullscreen mode

When an inquiry lands, you get a notification. Two things matter in how you handle it.

First, 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 data.object.id is the message id — you can additionally guard on that to avoid acting twice on the same message, but the notification id is 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 (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.

A sketch of the handler:

app.post("/webhooks/nylas", async (req, res) => {
  res.status(200).end(); // ack fast

  const event = req.body;
  if (event.type !== "message.created" && event.type !== "message.created.truncated") return;
  if (seen(event.id)) return;          // dedup on the notification id
  markSeen(event.id);

  const { grant_id, id: messageId } = event.data.object;
  await handleInquiry(grant_id, messageId);
});
Enter fullscreen mode Exit fullscreen mode

Read the inquiry

Now fetch the full message by id. Keep this as a plain read — fetching does not mark anything read, that's a separate PUT if you want it.

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 terminal, while you're developing the agent and want to eyeball what came in:

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

nylas email read shows the full message content; add -r if 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.

Reply to collect the details you're missing

A 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.

Reply with reply_to_message_id set to the inbound message. That preserves the thread by setting the In-Reply-To and References headers, so the prospect sees a threaded reply, not a fresh email:

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": "prospect@example.com" }],
    "subject": "Re: New matter inquiry",
    "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?"
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI fetches the original to populate recipient and subject automatically, so you only supply the body:

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

When the prospect replies, message.created fires again on the same thread_id. 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:

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
nylas email threads show <thread-id>
Enter fullscreen mode Exit fullscreen mode

Loop 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.

Conflict-check routing: what a Rule can do, and what it can't

Here'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.

Agent Accounts have account-level Rules that filter mail. An inbound Rule matches on sender fields onlyfrom.address, from.domain, and from.tld, with operators is, is_not, contains, and in_list. 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.

Build it as a List the conflicts team can maintain, then a Rule that references it. From the CLI:

nylas agent list create --name "Adverse parties" --type domain --item adverseco.example
nylas agent rule create --name "Suppress adverse-party mail" \
  --trigger inbound \
  --condition from.domain,in_list,<LIST_ID> \
  --action block
Enter fullscreen mode Exit fullscreen mode

The 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, which you use both to add items and to reference the list from the rule:

curl --request POST \
  --url "https://api.us.nylas.com/v3/lists" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "name": "Adverse parties", "type": "domain" }'
Enter fullscreen mode Exit fullscreen mode
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": ["adverseco.example", "anothercounterparty.example"] }'
Enter fullscreen mode Exit fullscreen mode

The CLI does the same — the --item flag above seeds at creation time, and nylas agent list add appends to an existing list, which is what the conflicts team runs as the watchlist grows:

nylas agent list add <list-id> adverseco.example anothercounterparty.example
Enter fullscreen mode Exit fullscreen mode

That'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:

curl --request POST \
  --url "https://api.us.nylas.com/v3/rules" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Suppress adverse-party mail",
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "in_list", "value": ["<LIST_ID>"] }
      ]
    },
    "actions": [ { "type": "block" } ]
  }'
Enter fullscreen mode Exit fullscreen mode

One thing to know: a Rule is inert until it's attached to a workspace via that workspace's rule_ids. The CLI nylas agent rule create attaches 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 array with PATCH /v3/workspaces/{workspace_id}. Until you do, the rule exists but does nothing.

Now 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 header. A Rule can't see any of that, and you should never claim it does.

So 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:

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": ["<CONFLICT_REVIEW_FOLDER_ID>"] }'
Enter fullscreen mode Exit fullscreen mode
nylas email move <message-id> --folder <conflict-review-folder-id>
Enter fullscreen mode Exit fullscreen mode

The 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.

Escalate to a human attorney

When 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."

First, move the message into a human-review folder an attorney monitors. Create that folder once — the response carries the folder id you route into later:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/folders" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "name": "Attorney review" }'
Enter fullscreen mode Exit fullscreen mode
nylas email folders create "Attorney review"
Enter fullscreen mode Exit fullscreen mode

Then route into it with the same PUT ... folders[] / nylas email move you used for conflict review — just a different destination folder.

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

Second, 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 and 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.

That'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.

Guardrails worth keeping

A few things I'd nail down before pointing real prospects at this:

  • Dedup is load-bearing. Webhook redelivery plus concurrent workers will re-trigger your handler. Guard on the notification id, and additionally on the message id, before you ever send a reply.
  • Sender suppression and content conflicts are different mechanisms. Rules catch known-adverse senders; your code catches adverse parties named in the matter. Run both.
  • Keep fetch and mark-read separate. Reading a message via GET doesn't change its unread state — mark it read explicitly with PUT ... {"unread": false} if your attorney's view depends on it.
  • Watch the limits while prototyping. Free-plan Agent Accounts cap at 200 messages per account per day, with 30-day inbox and 7-day spam retention. That's plenty for intake testing; size it up for production volume.
  • This is intake, not advice. The agent gathers and routes. Every substantive decision lands with a licensed attorney. Build the wall in code, and don't let "helpful auto-reply" drift into anything that reads like counsel.

What's next

The grant abstraction is the whole trick here: once intake@yourfirm.com is 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 for provisioning and domain warming, the Policies, Rules, and Lists guide for the full Rule condition reference (including exactly which fields each trigger accepts), and the handle email replies recipe for the reply-loop and dedup patterns in depth. The CLI commands are all documented at cli.nylas.com/docs/commands.

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)