Multi-Turn Email Conversations for LLM Agents
Day 0, 10:00 — your agent sends a demo follow-up. Day 2, 14:37 — the prospect replies with a question. Day 2, 14:39 — they send a second thought. Day 5 — silence, then a reply to something the agent said a week ago. Somewhere between day 0 and day 5, your process restarted twice and deployed once. A single send-and-forget email is easy. The timeline above is the actual job: a conversation spanning five exchanges over days, where the agent has to remember what it said, what it's waiting for, and where in the workflow it stands — across restarts, deploys, and hours of dead air. The multi-turn conversation recipe builds this loop on a Nylas Agent Account (the feature's in beta), running entirely on webhooks and the Threads API — no polling, no missed messages. State lives outside the model The core design decision: every active conversation gets a durable record keyed by the thread ID. const conversationRecord = { threadId : " nylas-thread-id " , grantId : AGENT_GRANT_ID , contactEmail : " prospect@example.com " , purpose : " demo_followup " , // What started this conversation step : " awaiting_reply " , // Where in the workflow we are turnCount : 1 , maxTurns : 10 , // Safety cap before escalation lastActivityAt : " 2026-04-14T10:00:00Z " , metadata : {}, }; The step field is the heart of it — a tiny state machine tracking what the agent is waiting for, which determines how the next inbound message gets handled. The store has to be durable (Postgres, Redis with AOF, DynamoDB); the gap between messages can be days, so in-memory state is a non-starter. Starting a conversation means sending the first message and persisting the record under the threadId the send returns: async function startConversation ({ to , subject , body , purpose , metadata }) { const sent = await nylas . messages . send ({ identifier : AGENT_GRANT_ID , requestBody : { to : [{ email : to . email , name : to . name }], subject , body , }, }); await db . conversations . create ({ threadId : sent . dat