An agent that sends one email and reads one reply does not need to think about threading. An agent that negotiates a meeting time, answers follow-up questions on a ticket, or chases a missing invoice across five messages does. Every turn has to know which conversation it belongs to and what has already been said, and email carries that information in three headers, not in any conversation object you can query.

This post covers the mechanics: what those headers do, what Lumbox sets when an agent sends and replies, how an inbound reply gets attached to a thread, and a loop that holds a multi-turn conversation together.

The three headers

RFC 5322 defines the identification fields that threaded mail depends on:

  • Message-ID: a unique id for one message, written as <something@domain>.
  • In-Reply-To: the Message-ID of the message being answered.
  • References: the chain of Message-IDs that led to this message.

A reply that carries none of these has nothing linking it to the earlier message. The recipient's client can only guess from the subject line, and often it shows the reply as a brand new conversation.

What Lumbox sets on the way out

Two endpoints send mail from an agent's inbox, and they differ in exactly this respect.

POST /v1/inboxes/:id/send starts a conversation. It generates a Message-ID of the form <msg_...@trylumbox.com> (the domain is your inbox's domain), stores the sent message in the same inbox with category outbound, and opens a thread whose id is the id of that stored email. The response returns all three:

{
  "ok": true,
  "email_id": "eml_...",
  "thread_id": "eml_...",
  "message_id": "<msg_...@trylumbox.com>",
  "provider_message_id": "...",
  "provider": "ses"
}

POST /v1/inboxes/:id/reply continues one. It takes the email_id of a message in the same inbox and derives everything else from it. The recipient is that message's sender, and the subject gets a single Re: prefix (an existing one is not doubled). In-Reply-To is set to the original's Message-ID, and References includes it. With reply_all: true, the original To and Cc addresses (minus your own) go into Cc.

curl -X POST https://api.lumbox.co/v1/inboxes/inb_.../reply \
  -H "X-API-Key: $LUMBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-4411-turn-3" \
  -d '{"email_id": "eml_...", "text": "Thursday at 15:00 UTC works. The invite is on its way."}'

The send body has no field for custom headers, so /send cannot continue a thread even if you copy the subject line. Every turn after the first goes through /reply. One detail catches people here: the reply is addressed to the original message's sender, so pass the id of the latest inbound message. If you pass the id of your agent's own outbound message, the reply goes back to your agent.

How an inbound reply finds its thread

When mail arrives, Lumbox takes its In-Reply-To value and looks for a stored message with that Message-ID. If there is none, it walks the References list and uses the first id it has stored. A hit puts the new message in that message's thread. No hit leaves thread_id as null. Before any of this, a message whose Message-ID is already stored is dropped as a duplicate.

Two things follow from that rule. First, a message that opens a conversation (a customer writing in cold) arrives with thread_id: null. The thread is created when your agent replies to it, and its id is the id of that first message.

Second, matching depends on the reply naming a Message-ID that Lumbox recorded. Amazon SES, which delivers mail sent from @trylumbox.com addresses, documents that it overrides a Message-ID header you supply with its own value. So the recipient sees an id Lumbox never stored, and the first reply to a conversation your agent opened with /send can arrive with thread_id: null. Later replies link again, to the thread that starts at that first reply, because their References carry ids Lumbox did store. The loop below matches replies by sender address for this reason, and treats thread_id as useful when present, not guaranteed.

Reading the conversation back

GET /v1/threads/:threadId returns the thread with a messages array in chronological order. It includes your agent's own outbound messages, so a model reading it sees both sides. ?last_n=5 limits it to the five newest.

For a prompt, GET /v1/threads/:threadId/context?budget=3000 is usually the better call. It packs the thread into one text block under a token budget (default 4,000, with 512 held back for your question). The newest messages go in whole; older ones are condensed to a stored summary when one exists, or dropped when one does not. The response reports tokens, summarized and dropped, and the token count is characters divided by four, so treat the budget as approximate.

For the newest message on its own, read stripped_text rather than text_body. On inbound mail it drops lines that start with > and stops at an On ... wrote: line, a -- signature delimiter, or a row of dashes, which removes most of the quoted history a reply drags along. Both text fields arrive wrapped in an untrusted-content fence: a row of 40 = characters and a marker line above the text, and another row of = below it. Strip the fence before you parse the text yourself, and leave it on when you hand the text to a model.

A working loop

This loop sends an opening message, waits for the customer's reply, builds context, drafts a response with whatever model you use (draft_reply is your function and returns None when the conversation is done), and replies. It uses plain HTTP throughout.

import os, time, httpx
from datetime import datetime, timedelta, timezone

INBOX = os.environ["AGENT_INBOX_ID"]
api = httpx.Client(base_url="https://api.lumbox.co/v1",
                   headers={"X-API-Key": os.environ["LUMBOX_API_KEY"]},
                   timeout=150)
FENCE = "=" * 40

def unfence(text):
    lines = (text or "").split("\n")
    if len(lines) >= 3 and lines[0] == FENCE and lines[-1] == FENCE:
        lines = lines[2:-1]
    return "\n".join(lines).strip()

def iso(dt):
    return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")

def converse(customer, subject, opening, draft_reply, max_turns=6, hours=48):
    since = datetime.now(timezone.utc)
    api.post(f"/inboxes/{INBOX}/send",
             json={"to": customer, "subject": subject, "text": opening}).raise_for_status()
    thread_id, turns, stop_at = None, 0, time.time() + hours * 3600

    while turns < max_turns and time.time() < stop_at:
        r = api.get(f"/inboxes/{INBOX}/wait",
                    params={"from": customer.lower(), "since": iso(since), "timeout": 120})
        if r.status_code == 408:
            continue
        r.raise_for_status()
        msg = r.json()
        received = datetime.fromisoformat(msg["received_at"].replace("Z", "+00:00"))
        since = received + timedelta(milliseconds=1)
        thread_id = msg["thread_id"] or thread_id

        latest = unfence(msg["stripped_text"])
        if thread_id:
            ctx = api.get(f"/threads/{thread_id}/context", params={"budget": 3000}).json()
            history = ctx["context"]
        else:
            history = f"You wrote: {opening}\n\nThey replied: {latest}"

        text = draft_reply(history, latest)
        if text is None:
            break
        rep = api.post(f"/inboxes/{INBOX}/reply",
                       headers={"Idempotency-Key": f"reply-{msg['id']}"},
                       json={"email_id": msg["id"], "text": text})
        rep.raise_for_status()
        thread_id = rep.json()["thread_id"]
        turns += 1

A few choices in there are deliberate. since is taken before the opening send, so a reply that lands in the gap before the first /wait is not missed; the from filter keeps the agent's own outbound copy out of the match. /wait returns the newest matching message with received_at at or after since, and it does not skip messages it has already returned, so the loop moves since one millisecond past each message it handles. If the customer sends two messages while the agent is drafting, the next /wait hands back only the newer one. The older one still shows up in the thread context, but a workflow that must act on every message should list from the cursor instead, as the state machine version does. The Idempotency-Key is derived from the inbound message id: if the process crashes after replying and the same message is handled again within 24 hours, Lumbox replays the stored response instead of sending a second reply.

Each /wait call holds for at most 120 seconds and returns HTTP 408 when nothing matched, so a human who takes a day to answer costs about 720 calls. If that is too many, subscribe to the email.received webhook and keep a slow poll as a backstop. The threads docs list every field on the thread object, the sending docs cover the reply body, and why threading matters for autonomous agents covers what goes wrong when these links break.