{"slug": "chase-e-signature-completions-with-an-email-agent", "title": "Chase e-signature completions with an email agent", "summary": "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.", "body_md": "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.\n\nThat 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.\n\nThe 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`\n\n, which means everything below runs against the same `/v3/grants/{grant_id}/*`\n\nendpoints 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`\n\nnext to each so you can drop it into your backend unchanged.\n\nThe 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:\n\n`thread_id`\n\n. Every nudge is a reply `awaiting_signature`\n\n→ `signed`\n\n, plus a nudge count and a `next_nudge_at`\n\ntimestamp. Agent Accounts don't support custom `metadata`\n\n, so this state is `message.created`\n\nfires 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.\n\nThe 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:\n\n`signed`\n\nand the nudges die. Nobody has to notice the signature arrived and manually cancel the reminder.`contracts@yourcompany.com`\n\non 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.\n\nNylas 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`\n\nfield coming from the email layer. In practice that detection comes from one of two places:\n\nEither 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.\n\nYou need a Nylas API key and a registered sending domain — a custom domain you've verified, or a Nylas `*.nylas.email`\n\ntrial 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.\n\nI'll use `contracts@yourcompany.com`\n\nas the agent's address throughout.\n\nAn Agent Account is created with `POST /v3/connect/custom`\n\n, using `\"provider\": \"nylas\"`\n\nand the email on your registered domain. No OAuth, no refresh token — the agent doesn't borrow a human's mailbox, it gets its own.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Contracts Bot\",\n    \"settings\": {\n      \"email\": \"contracts@yourcompany.com\"\n    }\n  }'\n```\n\nThe response carries `data.id`\n\n— save it, that's your `grant_id`\n\nfor every call after this. The top-level `name`\n\nbecomes the default `From`\n\nname, so the recipient sees `Contracts Bot <contracts@...>`\n\nrather than a bare address.\n\nFrom the CLI it's one line:\n\n```\nnylas agent account create contracts@yourcompany.com --name \"Contracts Bot\"\n```\n\nThe CLI creates the `nylas`\n\nconnector 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>`\n\n. There's no `--workspace`\n\nflag on create.\n\nConfirm the account is live with the CLI's connector-status helper (this one's CLI-only — it reports the readiness of the `nylas`\n\nconnector and your managed accounts, with no clean public-API analog):\n\n```\nnylas agent status\n```\n\nThe sequence starts when the agent sends the document. This is an outbound send from the agent's grant, with the contract PDF attached.\n\nOver the API, attaching a file means a `multipart/form-data`\n\nrequest: the JSON message goes in a `message`\n\npart, and the file rides alongside it.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --form 'message={\n    \"to\": [{ \"email\": \"alex@acme.com\", \"name\": \"Alex Rivera\" }],\n    \"subject\": \"Your Acme service agreement — ready to sign\",\n    \"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!\"\n  };type=application/json' \\\n  --form 'attachment=@./acme-service-agreement.pdf;type=application/pdf'\n```\n\nThe CLI `email send`\n\ncommand 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:\n\n```\nnylas email drafts create <GRANT_ID> \\\n  --to alex@acme.com \\\n  --subject \"Your Acme service agreement — ready to sign\" \\\n  --body \"Hi Alex, attached is the service agreement we discussed. Please review, sign, and reply with the signed copy.\" \\\n  --attach ./acme-service-agreement.pdf\n```\n\nThat prints a `draft-id`\n\n. Send it:\n\n```\nnylas email drafts send <DRAFT_ID> <GRANT_ID>\n```\n\nThe critical move happens in *your* code, not in the request: when you send this, write the sequence record. Recipient, the outbound `message_id`\n\n, the `thread_id`\n\nfrom the response, and the sequence state — `awaiting_signature`\n\n, zero nudges so far, and when the first nudge is due.\n\n```\ncontract: acme-service-agreement\nthread_id: <THREAD_ID>\nstate: awaiting_signature\nnudge_count: 0\nsent_at: 2026-06-25T14:00:00Z\nnext_nudge_at: 2026-06-28T14:00:00Z   # +3 days\n```\n\nThat record is the spine of the sequence. Everything from here either reads it or advances it.\n\nInbound mail to the agent fires the standard `message.created`\n\nwebhook. Subscribe once.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://yourapp.com/webhooks/nylas\",\n    \"description\": \"Contract signature agent\"\n  }'\n```\n\nOr with the CLI:\n\n```\nnylas webhook create \\\n  --url https://yourapp.com/webhooks/nylas \\\n  --triggers message.created \\\n  --description \"Contract signature agent\"\n```\n\nNow the part that trips people up. **The message.created payload carries summary fields only** —\n\n`subject`\n\n, `from`\n\n, `thread_id`\n\n, `snippet`\n\n, and the `date`\n\nthe message arrived. It does `message.created.truncated`\n\nand drops the body entirely. The webhook is a doorbell, not a delivery. Acknowledge it fast, then go fetch the real thing.\n\n``` js\napp.post(\"/webhooks/nylas\", async (req, res) => {\n  // Verify X-Nylas-Signature first — see /docs/v3/notifications/.\n  res.status(200).end();\n\n  const event = req.body;\n  if (event.type !== \"message.created\") return;\n\n  const msg = event.data.object;\n  if (msg.grant_id !== AGENT_GRANT_ID) return;\n\n  // The agent's own outbound sends also fire message.created. Skip them.\n  if (msg.from?.[0]?.email === AGENT_EMAIL) return;\n\n  // Dedup: webhook redelivery and concurrent workers both hit this.\n  if (await db.alreadyProcessed(msg.id)) return;\n  await db.markProcessed(msg.id);\n\n  const seq = await db.getSequenceByThread(msg.thread_id);\n  if (!seq) return; // not a contract we're tracking\n\n  await processReply(msg, seq);\n});\n```\n\nTwo guardrails to burn into muscle memory: the webhook fires for the agent's *own* sends too, so filter on `from`\n\n; 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\n\n`msg.date`\n\nis the message timestamp — `message.created`\n\nis just the name of the event, not a field on the object.)With the inbound `message_id`\n\nin hand, fetch the full message so you can inspect the body and any attachments.\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MSG_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nFrom the CLI, reading a message is:\n\n```\nnylas email read <MSG_ID> <GRANT_ID>\n```\n\nYou 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.\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\nnylas email threads show <THREAD_ID> <GRANT_ID>\n```\n\nNow 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.\n\n``` js\nasync function processReply(msg, seq) {\n  const fullMsg = await getMessage(AGENT_GRANT_ID, msg.id);\n\n  // Option A — signed PDF by reply: look for an executed document attachment.\n  const signed = await isSignedContract(fullMsg, seq);\n  // ^ your logic: attachment present + content_type application/pdf,\n  //   optionally a model confirming it's the executed copy, not a question.\n\n  // Option B — e-sign provider: this reply might just be chatter while the\n  // real \"complete\" signal comes from DocuSign/Dropbox Sign's own webhook.\n  // Either way, the truth check is yours.\n\n  if (signed) {\n    await db.setState(seq.id, \"signed\");      // stop the sequence\n    await confirmAndThank(msg, seq);\n  } else {\n    // Not signed yet — a question, a \"will do tomorrow,\" etc.\n    // Leave the sequence running; the schedule keeps the cadence.\n    await maybeReplyToQuestion(fullMsg, seq);\n  }\n}\n```\n\nThe single line that matters is `db.setState(seq.id, \"signed\")`\n\n. That flip is what *ends* the chase. Everything else is sending; this is the off switch.\n\nHere'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`\n\nwhose `next_nudge_at`\n\nhas passed, and sends the next nudge in-thread.\n\n```\n// Runs every few minutes from your scheduler.\nasync function runDueNudges() {\n  const due = await db.getSequences({\n    state: \"awaiting_signature\",\n    nextNudgeBefore: Date.now(),\n  });\n\n  for (const seq of due) {\n    const step = NUDGE_LADDER[seq.nudgeCount];\n    if (!step) {\n      // Out of nudges — escalate to a human and stop chasing.\n      await db.setState(seq.id, \"handed_off\");\n      await notifyOwner(seq);\n      continue;\n    }\n\n    await sendNudge(seq, step.body);\n    await db.advanceNudge(seq.id, {\n      nudgeCount: seq.nudgeCount + 1,\n      nextNudgeAt: Date.now() + step.waitMs,\n    });\n  }\n}\n\nconst NUDGE_LADDER = [\n  { body: \"Hi Alex — just floating this back up. The agreement's attached above whenever you have a minute to sign.\", waitMs: 4 * DAY },\n  { body: \"Hi Alex, following up on the service agreement. Let me know if anything's blocking the signature — happy to help.\", waitMs: 7 * DAY },\n  { body: \"Hi Alex, last check-in from me on this. Would a different format or a quick call make signing easier?\", waitMs: 0 },\n];\n```\n\nEach 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:\n\n```\nnylas email reply <MSG_ID> <GRANT_ID> \\\n  --body \"Hi Alex — just floating this back up. The agreement's attached above whenever you have a minute to sign.\"\n```\n\nThe equivalent over the API sets `reply_to_message_id`\n\nso Nylas writes the `In-Reply-To`\n\nand `References`\n\nheaders for you and the nudge threads cleanly:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"reply_to_message_id\": \"<MSG_ID>\",\n    \"to\": [{ \"email\": \"alex@acme.com\" }],\n    \"subject\": \"Re: Your Acme service agreement — ready to sign\",\n    \"body\": \"Hi Alex — just floating this back up. The agreement is attached above whenever you have a minute to sign.\"\n  }'\n```\n\n`reply_to_message_id`\n\nis 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`\n\n: 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.\n\nThe sequence also terminates the instant the signed copy lands: `processReply`\n\nflips the state to `signed`\n\n, and `runDueNudges`\n\nnever sees it again. That's the whole loop closing itself — no human polls a queue to notice the signature arrived and cancel the reminders.\n\nA few things that bite if you skip them:\n\n`message_id`\n\nand 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`\n\nwhen you're sure. A false positive stops the chase on an unsigned contract.`message.bounced`\n\nand `message.complaint`\n\nwebhooks. 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.\n\nFrom here:\n\n`Message-ID`\n\n, `In-Reply-To`\n\n, and `References`\n\nkeep every nudge on one thread, and why `thread_id`\n\nis your stable key.`nylas agent`\n\n, `nylas email`\n\n, and `nylas webhook`\n\nsubcommand 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.\n\nWhen this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/chase-e-signature-completions-with-an-email-agent", "canonical_source": "https://dev.to/mqasimca/chase-e-signature-completions-with-an-email-agent-48lk", "published_at": "2026-07-18 11:54:29+00:00", "updated_at": "2026-07-18 11:58:38.307784+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-products"], "entities": ["Nylas", "Agent Account"], "alternates": {"html": "https://wpnews.pro/news/chase-e-signature-completions-with-an-email-agent", "markdown": "https://wpnews.pro/news/chase-e-signature-completions-with-an-email-agent.md", "text": "https://wpnews.pro/news/chase-e-signature-completions-with-an-email-agent.txt", "jsonld": "https://wpnews.pro/news/chase-e-signature-completions-with-an-email-agent.jsonld"}}