cd /news/ai-agents/run-a-legal-intake-agent-on-its-own-… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-53528] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Run a legal intake agent on its own mailbox

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.

read12 min views1 publishedJul 10, 2026

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.

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:

intake@yourfirm.com

) 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

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.

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" }
  }'

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"

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.

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"
  }'

Or with the CLI:

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

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);
});

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

From the terminal, while you're developing the agent and want to eyeball what came in:

nylas email read <message-id>

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.

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?"
  }'

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?"

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>"
nylas email threads show <thread-id>

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.

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 only β€” from.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

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" }'
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"] }'

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

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" } ]
  }'

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>"] }'
nylas email move <message-id> --folder <conflict-review-folder-id>

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.

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" }'
nylas email folders create "Attorney review"

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>

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

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

id

, and additionally on the message id, before you ever send a reply.GET

doesn't change its unread state β€” mark it read explicitly with PUT ... {"unread": false}

if your attorney's view depends on it.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.

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

:

── more in #ai-agents 4 stories Β· sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/run-a-legal-intake-a…] indexed:0 read:12min 2026-07-10 Β· β€”