POST /v1/email/batches takes a list, the agent task that drafts each row, a connected Resend account and a field map. It validates the whole combination, writes the rows and the drafting job in one transaction, and returns a scheduled batch. Nothing is sent.
An invalid batch is a 400 listing every problem at once, not the first one — because telling you about row 12 when rows 12, 47 and 88 are wrong means three round trips through a five-thousand-row upload:
The column space
This is the part people get wrong, and everything else follows from it. A row has one flat column space, filled from two directions and edited from a third:
They are merged in exactly that order —
input, then output, then overrides — so a human edit always wins, and a re-draft can never silently discard one. The merged result is the row’s columns, and it is what the review table renders.
field_map points to, subject and body at names in that merged space, and each may name either a CSV header or a field the task produces. That is the whole design in one control, and it is what makes these two the same feature:
email is a column of the file and subject_line / email_body are output fields of the task. In the second, all three are output fields — the task was given a phone number and a company, looked the person up with its tools, and returned work_email beside the copy it wrote. Nothing downstream can tell the difference, and nothing downstream needs to.
What makes a row sendable
A row can be sent when all three mapped names resolve to a non-empty string in the merged space, and the resolvedto parses as an email address. Nothing else is checked.
Every other field the task produces is kept on the row verbatim as context — visible in the review table, useful when you are deciding whether to send — and read by nobody. A task that returns confidence, industry and last_order_date beside its three mapped fields is doing something useful; those three values are never sent anywhere.
What the task has to look like
The drafting engine is an ordinary agent task: a prompt, a model, tools and MCP servers, plus a typedoutput. A batch adds two requirements on top of what inputs and output already says:
- Anything you map must be a
stringoutput field. A task output field may bestring,boolean,integerornumber. The other three are fine to produce and are kept on the row, but ato,subjectorbodymapped at one is refused — an email’s subject has to be text. - The task’s variables are the list’s columns. Each row’s uploaded columns are handed to the task whole as its
{{vars.*}}values — not filtered to what it declares — so a column the task does not read costs nothing, and a variable the task later declares picks up a column that was already there.
{{vars.name}} is refused at create.
Everything refused at create
All of these come back together in one 400.
The field map is re-validated on every drafting pass, not only at create. A task has no publish step, so an edit that renames the output field your mapping points at lands with nothing to announce it — and the batch stops on the next pass rather than producing four thousand rows that turn out to be unsendable. See monitoring.
Policy
Everything that is not the list, the task, the account or the mapping.calling_window applies to drafting only. Sending is never scheduled — it happens when a person presses send, whatever the clock says.
What happens after create
The batch isscheduled, and a drafting job exists for it in the same transaction that wrote the rows. From there:
- At
start_at, or on the first pass if there is none, the batch moves todraftingandemail_batch.startedfires. - Each pass claims up to
max_concurrencyrows, runs the task on each, and writes the result onto the row. If the calling window is shut it waits for the next opening instead. - When the last row lands the batch becomes
drafted— Ready to review in the dashboard —email_batch.draftedfires, and nothing about the batch is scheduled any more.
drafted means the drafts are ready. It says nothing at all about what has been sent.
A complete create
EmailBatchResponse. Two of its fields describe the column space directly: input_columns is every header your list carried, in the order it first appeared, and output_columns is every field the task declares — read live, so a task that grew a field shows the new column.
In the dashboard
Email → Outbound → New batch asks the same three questions in the order they have to be answered, because the third cannot be asked before the first two.1
The list
Type rows in, upload a CSV, or both. The file is parsed in the browser and posted as JSON — it never leaves the tab as a file. Bad column names are flagged red on the header, and a file over 5,000 rows is refused where you chose it rather than after the form is filled in.
2
How it runs
The task, the Resend account, the sender, the body format, the schedule, the drafting hours and the pacing. This is the same form you get later when you edit a live batch, because they are the same fields.
3
The email
Three dropdowns — To, Subject, Body — each listing your file’s columns under From your file and the task’s output fields under a group named after the task. Non-text output fields appear greyed with
(not text) beside them. Common names (email, subject, body) are pre-selected if they exist.Below them, in place of a 400 you would otherwise get: collisions between your columns and the task’s fields, required variables your file has no column for, and a count of how many rows have a blank cell in a required column.Review and send
Reading the drafts, editing cells, and the 50-row send cap.
Monitoring a batch
Statuses, counts, cost, and why a batch stops itself.