# Chase e-signature completions with an email agent

> Source: <https://dev.to/mqasimca/chase-e-signature-completions-with-an-email-agent-48lk>
> Published: 2026-07-18 11:54:29+00:00

Every unsigned contract sitting in someone's inbox is revenue you've already earned and haven't collected. The work is done, the terms are agreed, the deal is closed in everyone's head — and then the document just... sits there. Nobody opened it. Or they opened it, meant to sign, got pulled into a meeting, and forgot. A week later it's buried under forty newer emails and you're the one who has to remember to nudge.

That nudge is the whole game. The contracts that get signed aren't the ones with the best terms — they're the ones somebody followed up on. And following up is exactly the kind of patient, scheduled, slightly-annoying-but-polite work that a human is bad at and a piece of software is good at. *Send the document, watch the thread, nudge on a cadence until the signed copy comes back, then stop.* That's not an AI parlor trick. It's a sequencer wrapped around one email thread.

The participant that runs that sequence is an **Agent Account** — a Nylas grant with its own email address on your domain. Under the hood it's just a `grant_id`

, which means everything below runs against the same `/v3/grants/{grant_id}/*`

endpoints you'd use for any connected mailbox. Nothing new to learn on the data plane. I work on the Nylas CLI, so the terminal commands here are the exact ones I reach for when I'm wiring this up — and I'll show the raw `curl`

next to each so you can drop it into your backend unchanged.

The agent owns one job per contract: drive a single thread from "document sent" to "signed copy received," nudging on a schedule and going quiet the moment it's done. The moving parts:

`thread_id`

. Every nudge is a reply `awaiting_signature`

→ `signed`

, plus a nudge count and a `next_nudge_at`

timestamp. Agent Accounts don't support custom `metadata`

, so this state is `message.created`

fires when the recipient replies on the thread. You fetch the message, decide whether it's the signed copy or just chatter, and either advance the sequence or keep waiting.The Nylas side handles sending, receiving, threading, and attachment storage. The *sequencing* — the part that actually collects the signature — is a few hundred lines of your own code on top.

The naive version is a sales rep with a sticky note and a calendar reminder that says "chase the Acme contract." Here's what the agent buys you over that:

`signed`

and the nudges die. Nobody has to notice the signature arrived and manually cancel the reminder.`contracts@yourcompany.com`

on your domain, replies thread back to it, and the signed PDF lands in a mailbox you control — not a rep's personal inbox where it's invisible to the rest of the team.If you send one contract a month, a sticky note is fine. Once you're juggling a dozen open documents at different stages, the sequencer earns its keep.

Nylas delivers the mail. It does **not** know what a signature is. When the recipient's reply lands, *deciding whether the contract is actually signed is your application's job* — there's no `signed: true`

field coming from the email layer. In practice that detection comes from one of two places:

Either way, "signed" is a decision your code makes. The agent's contribution is everything around that decision: sending, threading, the schedule, the escalation, and knowing when to shut up. Build it with that boundary clear and you won't be surprised later.

You need a Nylas API key and a registered sending domain — a custom domain you've verified, or a Nylas `*.nylas.email`

trial subdomain for prototyping. New domains warm over roughly four weeks, so if you're shipping to production, start that early. The free plan caps you at 200 messages per account per day and keeps inbox mail for 30 days, which is plenty for a contract-chasing flow.

I'll use `contracts@yourcompany.com`

as the agent's address throughout.

An Agent Account is created with `POST /v3/connect/custom`

, using `"provider": "nylas"`

and the email on your registered domain. No OAuth, no refresh token — the agent doesn't borrow a human's mailbox, it gets its own.

```
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": "Contracts Bot",
    "settings": {
      "email": "contracts@yourcompany.com"
    }
  }'
```

The response carries `data.id`

— save it, that's your `grant_id`

for every call after this. The top-level `name`

becomes the default `From`

name, so the recipient sees `Contracts Bot <contracts@...>`

rather than a bare address.

From the CLI it's one line:

```
nylas agent account create contracts@yourcompany.com --name "Contracts Bot"
```

The CLI creates the `nylas`

connector if it doesn't exist yet, and the API auto-provisions a default workspace and policy for the account. If you want a custom policy later — tighter attachment limits, spam rules — you attach it with `nylas workspace update <workspace-id> --policy-id <policy-id>`

. There's no `--workspace`

flag on create.

Confirm the account is live with the CLI's connector-status helper (this one's CLI-only — it reports the readiness of the `nylas`

connector and your managed accounts, with no clean public-API analog):

```
nylas agent status
```

The sequence starts when the agent sends the document. This is an outbound send from the agent's grant, with the contract PDF attached.

Over the API, attaching a file means a `multipart/form-data`

request: the JSON message goes in a `message`

part, and the file rides alongside it.

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --form 'message={
    "to": [{ "email": "alex@acme.com", "name": "Alex Rivera" }],
    "subject": "Your Acme service agreement — ready to sign",
    "body": "Hi Alex,<br><br>Attached is the service agreement we discussed. Please review and sign at your convenience, then reply with the signed copy. Happy to walk through anything. Thanks!"
  };type=application/json' \
  --form 'attachment=@./acme-service-agreement.pdf;type=application/pdf'
```

The CLI `email send`

command doesn't take a file flag, so for an attachment you create a draft with the file attached, then send that draft. Two commands, same result:

```
nylas email drafts create <GRANT_ID> \
  --to alex@acme.com \
  --subject "Your Acme service agreement — ready to sign" \
  --body "Hi Alex, attached is the service agreement we discussed. Please review, sign, and reply with the signed copy." \
  --attach ./acme-service-agreement.pdf
```

That prints a `draft-id`

. Send it:

```
nylas email drafts send <DRAFT_ID> <GRANT_ID>
```

The critical move happens in *your* code, not in the request: when you send this, write the sequence record. Recipient, the outbound `message_id`

, the `thread_id`

from the response, and the sequence state — `awaiting_signature`

, zero nudges so far, and when the first nudge is due.

```
contract: acme-service-agreement
thread_id: <THREAD_ID>
state: awaiting_signature
nudge_count: 0
sent_at: 2026-06-25T14:00:00Z
next_nudge_at: 2026-06-28T14:00:00Z   # +3 days
```

That record is the spine of the sequence. Everything from here either reads it or advances it.

Inbound mail to the agent fires the standard `message.created`

webhook. Subscribe once.

```
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://yourapp.com/webhooks/nylas",
    "description": "Contract signature agent"
  }'
```

Or with the CLI:

```
nylas webhook create \
  --url https://yourapp.com/webhooks/nylas \
  --triggers message.created \
  --description "Contract signature agent"
```

Now the part that trips people up. **The message.created payload carries summary fields only** —

`subject`

, `from`

, `thread_id`

, `snippet`

, and the `date`

the message arrived. It does `message.created.truncated`

and drops the body entirely. The webhook is a doorbell, not a delivery. Acknowledge it fast, then go fetch the real thing.

``` js
app.post("/webhooks/nylas", async (req, res) => {
  // Verify X-Nylas-Signature first — see /docs/v3/notifications/.
  res.status(200).end();

  const event = req.body;
  if (event.type !== "message.created") return;

  const msg = event.data.object;
  if (msg.grant_id !== AGENT_GRANT_ID) return;

  // The agent's own outbound sends also fire message.created. Skip them.
  if (msg.from?.[0]?.email === AGENT_EMAIL) return;

  // Dedup: webhook redelivery and concurrent workers both hit this.
  if (await db.alreadyProcessed(msg.id)) return;
  await db.markProcessed(msg.id);

  const seq = await db.getSequenceByThread(msg.thread_id);
  if (!seq) return; // not a contract we're tracking

  await processReply(msg, seq);
});
```

Two guardrails to burn into muscle memory: the webhook fires for the agent's *own* sends too, so filter on `from`

; and redelivery is a fact of life, so **dedup on the inbound message id** before doing any work. Skip either and you'll either nudge yourself or count the same reply twice. (Note

`msg.date`

is the message timestamp — `message.created`

is just the name of the event, not a field on the object.)With the inbound `message_id`

in hand, fetch the full message so you can inspect the body and any attachments.

```
curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MSG_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
```

From the CLI, reading a message is:

```
nylas email read <MSG_ID> <GRANT_ID>
```

You can also pull the whole conversation in one call to see how the thread has gone — useful when the agent has already nudged twice and you want context before it acts again.

```
curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
nylas email threads show <THREAD_ID> <GRANT_ID>
```

Now the decision Nylas can't make for you: *is this the signed contract?* This is your application logic, and the path depends on how you collect signatures.

``` js
async function processReply(msg, seq) {
  const fullMsg = await getMessage(AGENT_GRANT_ID, msg.id);

  // Option A — signed PDF by reply: look for an executed document attachment.
  const signed = await isSignedContract(fullMsg, seq);
  // ^ your logic: attachment present + content_type application/pdf,
  //   optionally a model confirming it's the executed copy, not a question.

  // Option B — e-sign provider: this reply might just be chatter while the
  // real "complete" signal comes from DocuSign/Dropbox Sign's own webhook.
  // Either way, the truth check is yours.

  if (signed) {
    await db.setState(seq.id, "signed");      // stop the sequence
    await confirmAndThank(msg, seq);
  } else {
    // Not signed yet — a question, a "will do tomorrow," etc.
    // Leave the sequence running; the schedule keeps the cadence.
    await maybeReplyToQuestion(fullMsg, seq);
  }
}
```

The single line that matters is `db.setState(seq.id, "signed")`

. That flip is what *ends* the chase. Everything else is sending; this is the off switch.

Here's the part that separates a sequence from a one-shot send. The cadence isn't triggered by replies — it's triggered by *time*. A scheduler in your code (a cron job, a queue with delays, whatever you've got) wakes up, finds every sequence still in `awaiting_signature`

whose `next_nudge_at`

has passed, and sends the next nudge in-thread.

```
// Runs every few minutes from your scheduler.
async function runDueNudges() {
  const due = await db.getSequences({
    state: "awaiting_signature",
    nextNudgeBefore: Date.now(),
  });

  for (const seq of due) {
    const step = NUDGE_LADDER[seq.nudgeCount];
    if (!step) {
      // Out of nudges — escalate to a human and stop chasing.
      await db.setState(seq.id, "handed_off");
      await notifyOwner(seq);
      continue;
    }

    await sendNudge(seq, step.body);
    await db.advanceNudge(seq.id, {
      nudgeCount: seq.nudgeCount + 1,
      nextNudgeAt: Date.now() + step.waitMs,
    });
  }
}

const NUDGE_LADDER = [
  { body: "Hi Alex — just floating this back up. The agreement's attached above whenever you have a minute to sign.", waitMs: 4 * DAY },
  { body: "Hi Alex, following up on the service agreement. Let me know if anything's blocking the signature — happy to help.", waitMs: 7 * DAY },
  { body: "Hi Alex, last check-in from me on this. Would a different format or a quick call make signing easier?", waitMs: 0 },
];
```

Each nudge is an in-thread reply, so it lands in the same conversation the recipient already has — not a new email they have to connect to the original. From the CLI:

```
nylas email reply <MSG_ID> <GRANT_ID> \
  --body "Hi Alex — just floating this back up. The agreement's attached above whenever you have a minute to sign."
```

The equivalent over the API sets `reply_to_message_id`

so Nylas writes the `In-Reply-To`

and `References`

headers for you and the nudge threads cleanly:

```
curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "reply_to_message_id": "<MSG_ID>",
    "to": [{ "email": "alex@acme.com" }],
    "subject": "Re: Your Acme service agreement — ready to sign",
    "body": "Hi Alex — just floating this back up. The agreement is attached above whenever you have a minute to sign."
  }'
```

`reply_to_message_id`

is the message you're replying to — typically the agent's last outbound on the thread, or the recipient's most recent reply if there was one. Either keeps the conversation grouped. The escalation lives entirely in the `NUDGE_LADDER`

: gentler early, firmer later, then a graceful exit to a human. When the ladder runs out, the sequence stops chasing — an unanswered contract after three nudges is a signal to a person, not a fourth email.

The sequence also terminates the instant the signed copy lands: `processReply`

flips the state to `signed`

, and `runDueNudges`

never sees it again. That's the whole loop closing itself — no human polls a queue to notice the signature arrived and cancel the reminders.

A few things that bite if you skip them:

`message_id`

and check it before you act, or one re-delivered reply triggers a duplicate "thanks, all signed!" — or worse, an extra nudge after the deal's already done.`signed`

when you're sure. A false positive stops the chase on an unsigned contract.`message.bounced`

and `message.complaint`

webhooks. If your nudges are bouncing, stop the sequence and flag it — nudging a dead address isn't progress.You now have the shape of an e-signature follow-up agent: it owns an address, sends the contract on its own thread, watches that thread for the signed reply, nudges on an escalating schedule, and goes silent the moment the document comes back. The Nylas surface did the mail and the threading; your code did the sequencing and the signed/not-signed call.

From here:

`Message-ID`

, `In-Reply-To`

, and `References`

keep every nudge on one thread, and why `thread_id`

is your stable key.`nylas agent`

, `nylas email`

, and `nylas webhook`

subcommand used above.The pattern generalizes past contracts. Any single-thread, chase-to-completion task — an NDA, a renewal, a one-off approval you need back in writing — is the same sequencer with a different ladder.

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

:
