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
: