One letter per person, from a roster
A list of people goes in. One separate, signed, letterheaded document per person comes out, delivered row by row. The employer signs once for the batch — the product never signs on anyone's behalf.
Why it has to be one document per person
The obvious shortcut is a single letter with everybody listed on it, signed once. For benefits paperwork that shortcut is rejected: the reviewer is checking one person's facts against one signed page, and a roster on a page is not that. Anything that has to be accepted per person — a special enrollment period letter, an offer of coverage, a rate confirmation, a verification of employment — has the same shape.
So the unit of work is the row, not the batch, and the language has a form for
exactly that: a for-each group runs a series of steps once
per item, with its own scope per row. Row 2 can never be handed row 1's
document.
What a letter like this has to carry
A worked example: the special enrollment period (SEP) letter a benefits broker produces at every enrollment. Seven things have to be on the page, and each one has a place to live.
| Requirement | Where it lives |
|---|---|
| One document per person | The for-each group. One iteration, one document. |
| The person's start date | A field on the roster row → a dynamic field on the template. |
| The person's address | Same — a roster field, read as $employee.employee_address. |
| Signed by the employer |
An image field on the template, filled from
$input.employer_signature. It is read from the run,
not from the row, because it is captured once for the
batch.
|
| On company letterhead | The template is the letterhead. Static fields — the company name, address and telephone number — are owned by the template, not sent per row. |
| The special enrollment wording | A static field. One employer, one approved paragraph. |
| The specific plan offer wording | A static field, for the same reason. |
Three places a value can come from
Getting this split right is most of the design. There are exactly three sources:
The template
Static fields. One employer has one letterhead, one approved paragraph, one person who signs. Put them on the template and they cannot be wrong on row 400 because a spreadsheet column was blank.
The row
$employee.… — everything that
differs per person: name, address, start date, where their copy is
sent. Inside the loop this names this iteration's row and
nothing else.
The run
$input.… — what is true of
this batch and only this batch: the letter date, and the captured
signature. Reading the signature from $input is what makes
“sign once, produce many” visible in the source.
The workflow
This is the shipped sample, verbatim. Open the Orchestrator, click Examples, and pick One Letter Per Person, From a Roster to put it on the canvas.
(pipeline "SEP Letters from a Roster"
:result-step "letters"
(for-each "letters"
:label "One letter per employee"
:in $input.employees
:as "employee"
:max-parallel 4
(step "letter"
:label "Fill the SEP letter for this employee"
(action "ComposeFill"
:templateId "REPLACE_WITH_TEMPLATE_ID"
:data {"letter_date": $input.letter_date, "employee_name": $employee.employee_name, ...}
:outputFormat "pdf"
:filename "SEP Letter"))
(step "deliver"
:label "Email this employee their own letter"
(action "SendEmail"
:to $employee.employee_email
:subject "Your special enrollment period letter"
:body "<p>Your special enrollment period letter is attached.</p>"
:attachments [{"name": "SEP Letter.pdf", "contentType": "application/pdf", "content": (output-of "letter" "file")}]))))
:max-parallel 4 runs four people at a time. The default is one at a
time, and the ceiling is eight — a batch is not allowed to stampede whatever
is on the other end of it.
What you send it
Publish the workflow as a webhook and POST the roster. The collection named by
:in is an ordinary array anywhere in the payload.
{
"letter_date": "2026-09-07",
"employer_signature": "data:image/png;base64,iVBORw0KGgo...",
"employees": [
{ "employee_name": "Alex Example",
"employee_email": "alex.example@example.com",
"employee_address": "14 Example Lane\nAnytown, ZZ 99999",
"employee_start_date": "2026-08-17" }
]
}
The signature is a data: URI for a PNG or JPEG. Capture it however the
employer prefers — a photographed signature, an image exported from whatever
they already sign with — and pass it once. It is applied to every letter in
the batch and to nothing else.
What a run costs, before you click
A loop multiplies. The workflow above is two steps, so it is two tokens per person — forty people is eighty tokens, not two.
The number of people is not known until :in resolves, so nothing can
quote you a total in advance and nothing pretends to. What is quoted is the rate and
the bound: the per-item cost, and the loop's :limit (1,000 by default,
refused above 10,000 before a single row runs). The balance for the whole batch is
held the moment the count is known, so a run that cannot be paid for is refused
with nothing produced rather than dying at person 400 with 399 letters
already sent.
Delivering the letters
Delivery goes inside the loop, so each person's own document goes
to their own address. Swap SendEmail for any destination that takes one
document at a time — upload it to SharePoint, write it to blob storage, push
it over SFTP, or add a row to a system of record.
CreateZip step
after the loop and hand it the loop’s own output:
:files (output-of "letters" "items"). The same works for
MergePdfs (:pdfs) and for SendEmail’s
:attachments. A row that failed leaves an empty slot and is skipped,
so a partial batch still produces an archive of what it made; the response says how
many were skipped. Give each row a distinct :filename — every
letter is the same step, so without one they all arrive with the same name and the
archive renames the collisions … (2).
Run it end to end
Everything the sample needs ships in the repository under
Samples/sep-letters/: the letter template, its field contract, an
invented roster, a sample signature image and a runner that creates the template,
publishes the workflow and invokes it.
- Create the template. Portal → Template Generator, or the runner. Its static fields are your letterhead and your wording; replace the placeholders.
-
Put its id in the workflow. That is the one
REPLACE_WITH_TEMPLATE_IDin the source. - Publish it and POST your roster to the webhook address.
-
Read the answer. The loop returns
{ok, count, items, failed}—itemsis indexed by row, withnullwhere a row failed, so “which three of the two hundred did not go out” has an answer you can act on.