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.
What you actually build
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:
-
One contract, one thread. The agent sends the document and remembers the
thread_id. Every nudge is a reply on that thread, not a fresh email. The recipient sees one conversation, not a pile of "still waiting?" messages. -
A sequence state that lives in your database.
awaiting_signature→signed, plus a nudge count and anext_nudge_attimestamp. Agent Accounts don't support custommetadata, so this state is yours to keep. That's not a limitation to work around — it's just where the truth lives. -
An inbound webhook.
message.createdfires 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. - Escalating follow-up logic. Your code, driven by a clock: a gentle reminder at day three, a firmer one at day seven, a "should I send this another way?" at day fourteen, then stop or hand off to a human.
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.
Why this beats a reminder in your calendar
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:
- It never forgets and never gets awkward. A human stops nudging after the second reminder because it feels pushy. The agent runs the cadence you defined, exactly, every time.
- It's one thread, escalating. This is the part that distinguishes a sequence from spam. Each nudge replies in-thread and gets a little firmer, instead of sending the same "please sign" from scratch. The recipient feels followed-up-with, not blasted.
-
It stops on its own. The instant the signed copy lands, the sequence flips to
signedand the nudges die. Nobody has to notice the signature arrived and manually cancel the reminder. -
It's a real participant. The contract comes from
contracts@yourcompany.comon 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.
One honest caveat before you build
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:
- You use an e-sign provider (DocuSign, Dropbox Sign, your own flow). The signed-event truth lives in their webhook or status API. The email agent's job is the human-facing nudging; the provider's job is the legal signature. You cross-reference: "provider says envelope X is complete" advances the same sequence the email agent is chasing.
- You're collecting a signed PDF by reply. Then your code inspects the inbound message — does it carry a PDF attachment? Does the body say "signed, attached"? You parse it, and optionally hand the document to a model to confirm it's the executed copy and not a question about clause 4.
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.
Before you begin
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.
Provision the agent's mailbox
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
Send the contract for signature
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.
Subscribe to inbound replies
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 not carry the message body or attachment bytes, and on a large body the trigger becomes 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.)
Read the reply and decide if it's signed
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.
Nudge on a schedule, escalating each time
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.
Guardrails I'd build in from day one
A few things that bite if you skip them:
-
Dedup is non-negotiable. Webhook redelivery is normal, not exceptional. Key on inbound
message_idand 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. - State is your source of truth, not the inbox. Don't recompute "has this been signed?" by scanning the mailbox each run. Read the sequence record. The inbox is mail; the state machine is data.
-
"Signed" is a decision, not a field. Nylas tells you a reply arrived; it never tells you the contract is executed. Make that call explicitly — attachment inspection, a model check, or your e-sign provider's status — and only flip to
signedwhen you're sure. A false positive stops the chase on an unsigned contract. - Cap the ladder and escalate. An infinite nudge loop is a great way to get marked as spam. Three or four steps, then hand off to a human. The agent's job is persistence, not harassment.
-
Watch the bounce signals. Agent Accounts emit
message.bouncedandmessage.complaintwebhooks. If your nudges are bouncing, stop the sequence and flag it — nudging a dead address isn't progress. - Respect the limits. 200 messages per account per day on the free plan. A busy pipeline of contracts each on its own nudge cadence adds up fast; batch and pace your scheduler accordingly.
What's next
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:
- Handle email replies in an agent loop — the webhook-to-reply routing this sequence is built on, in more depth.
-
Email threading for agents — how
Message-ID,In-Reply-To, andReferenceskeep every nudge on one thread, and whythread_idis your stable key. - Supported endpoints for Agent Accounts — the full grant-scoped API surface, including why custom metadata isn't supported and your sequence state lives in your own store.
-
Nylas CLI command reference — every
nylas agent,nylas email, andnylas webhooksubcommand 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.
AI-answer pages for agents
When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:
- Topic runbook: https://cli.nylas.com/ai-answers/follow-up-reminder-agent.md
- Industry playbooks hub: https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md
Top comments (0)