Welcome letters for forty new customers, a certificate for every attendee, an invoice per order: the same document, a different record each time. With GroupDocs.Assembly.Mcp running locally (dnx GroupDocs.Assembly.Mcp --yes), Claude Desktop, Cursor or Copilot does it with one tool call, assemble_per_record, which fills your Word template once per element of a JSON array, saves each result as its own PDF named by a field you choose, and reports the records that failed instead of stopping at the first one:
Make a welcome letter PDF for every customer in customers.json using welcome-letter.docx, and name each file after the customer.
The step-by-step version with config and troubleshooting is in the documentation: How to generate one document per record (mail merge) with an AI agent.
Why not let the agent loop?
It can, and for five records that is fine. But a loop of forty tool calls is forty chances for the agent to lose count, skip a record when the context gets long, or “helpfully” rephrase one letter. A per-record tool moves the loop into the server: one call, one template, one pass over the array, and a report that says exactly how many files were written and which records did not make it. The agent’s job is to choose the template, the data file and the naming field, and then to read the report back to you.
What does the agent check first?
Two things: what the data looks like, and what the template reads. In our test, five customers in customers.json and a one-page welcome letter:
-> get_document_info {"file":{"filePath":"customers.json"}}
<- { "dataKind": "json",
"data": { "root": "array", "length": 5, "keys": null } }
-> get_template_fields {"template":{"filePath":"welcome-letter.docx"}}
<- { "dataSourceNames": [ "customer" ],
"fields": [ "customer.Contact", "customer.Plan", "customer.Name",
"customer.Manager", "customer.FirstInvoice" ] }
The template calls one record customer; the server works that out by itself, so the agent does not have to pass dataSourceName.
One call, five records, one failure
-> assemble_per_record {"template":{"filePath":"welcome-letter.docx"},
"data":{"filePath":"customers.json"},"fileNameField":"Name",
"format":"pdf"}
<- Assembled 4 of 5 document(s) from 'welcome-letter.docx' with
'customers.json', one per record (each record as 'customer'), as PDF.
Saved to:
.../Contoso Pharma.pdf
.../Fabrikam Logistics.pdf
.../Tailspin Toys.pdf
.../Litware Labs.pdf
1 record(s) failed:
record 4: InvalidOperationException: ... Can not get the value of
member 'FirstInvoice' on type 'System.Data.DataRow'. The template
reads 'FirstInvoice', which the data does not have. ...
Record 4, Wingtip Travel, had no first-invoice date in the data. The other four letters were written, each named after the customer, and the failure came back with the record number and the field that was missing. An agent reports that as “four letters done; Wingtip Travel has no first-invoice date, do you want me to leave the line empty or wait for the date?”. That is the conversation you want, rather than forty files and no idea which one is wrong.
How the fan-out works
customers.json [ {Name:"Contoso Pharma",...}, {Name:"Fabrikam ..."},
... 5 records ]
|
assemble_per_record {"template":{"filePath":"welcome-letter.docx"},
"data":{"filePath":"customers.json"},
"fileNameField":"Name","format":"pdf"}
|
+--> Contoso Pharma.pdf
+--> Fabrikam Logistics.pdf ... 2 more
'--> "4 of 5 ... 1 record(s) failed: record 4 ..."
Three parameters do the work. fileNameField names each output by a record field, customer.Name or an invoice number; without it, files get a running number. recordsPath points at the array when the JSON is an object that holds one, such as "customers" or "batch.items". And maxDocuments is the brake.
What stops a runaway batch?
maxDocuments, which defaults to 100 and goes up to 1000. Before writing anything, the server counts the records; if there are more than the limit, it refuses. We set it to 3 against our five customers:
-> assemble_per_record {..., "maxDocuments":3}
<- Per-record assembly failed for 'welcome-letter.docx': there are 5
records, more than maxDocuments=3. Raise 'maxDocuments' (up to 1000)
or pass fewer records.
Nothing was written. For a mailing that goes to real people, that is the right default: a prompt that accidentally points at the whole CRM export gets a question back, not ten thousand PDFs.
My data is a spreadsheet, not JSON
Per-record generation reads JSON only: an array of objects, or an object holding one. When the list lives in a CSV or a worksheet, the agent converts the rows to JSON first, which agents do well, and writes the file into the storage folder. If what you want is one document that lists every row, a price list or a register, that is assemble_document with a <<foreach>> in the template, and it reads CSV and spreadsheets directly.
Honest limits
- Per-record assembly reads JSON only; CSV and spreadsheet data need converting first.
- Evaluation mode: every generated file starts with “Evaluation Only. Created with GroupDocs.Assembly 26.7…”, and e-mail templates keep only their plain-text body. License the server before a real mailing.
- A missing field fails that record.
allowMissingMembers: truewrites it with the line empty instead; decide which you want. - Dates: write them as
2026-12-01T00:00:00and format them in the template, as our letter does with<<[customer.FirstInvoice]:"d MMMM yyyy">>. A date-only string shifted by a day on a UTC+2 machine in our test. - The server writes files; it does not send them. Delivery is a separate step.
FAQ
Can I mail merge with Claude without uploading the customer list?
Yes. The server runs locally and reads customers.json from your storage folder; the generated PDFs are written there too.
How are the output files named?
By the record field you pass as fileNameField, here the customer name, so Contoso Pharma.pdf. Without it, a running number.
Can each document be an e-mail instead of a PDF?
Yes: use an EML or MSG template, or pass format: "eml". In evaluation mode the HTML body of an e-mail is replaced by the evaluation line, so test e-mail output with a license.
Go deeper
- Documentation, canonical how-to: How to generate one document per record (mail merge) with an AI agent
- Documentation hub: GroupDocs.Assembly MCP Server
- Start here: Let the model fill in the data, not write the document
- Related: What does this template need? Ask it.
- Related: The chart comes from the template, the numbers come from the data
- Related: Your repo already has the data; let the agent make the PDF
- On-premise and security model: 3 architectures for AI document processing, and the one that keeps files inside your network
- Questions: GroupDocs Assembly forum
- Source: GroupDocs.Assembly.Mcp on GitHub