DEV Community

Run a legal intake agent on its own mailbox

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

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.

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

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

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

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.

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

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.

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

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

Comments

No comments yet. Start the discussion.