cd /news/developer-tools/chase-e-signature-completions-with-a… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-64570] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=↑ positive

Chase e-signature completions with an email agent

Nylas introduces Agent Accounts that automate e-signature follow-ups by monitoring email threads and sending polite nudges until a signed document is received. The system uses a sequencer wrapped around a single email thread, with state tracking and automatic cessation once the signature arrives. This approach replaces manual reminders and ensures contracts are chased without human intervention.

read12 min views1 publishedJul 18, 2026

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.

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.

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

:

── more in #developer-tools 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/chase-e-signature-co…] indexed:0 read:12min 2026-07-18 Β· β€”