← Dear Name
API

Drive Dear Name from your own code

The one metered task, merge, is available over HTTP. It takes a mail merge as the page has worked it out - what the email is for and who sends it, the subject and body with their {{field}} and {{field|fallback}} placeholders, the sheet columns the template uses, the template problems the page found, every recipient as one numbered line (r1, r2 ...) with the values the template uses, their notes and the page's status when the row needs a fix, and the counts - and returns zero to four review points on the template (each must or could, quoting the template's own words with a reason and a rewrite), one personal opener for each recipient with notes, a better subject when one helps, a verdict (ready or revise) and a note.

The division of labour first: the page reads the sheet, merges every email and checks each one; the model reviews the wording and writes the openers, naming rows only by id so the page can put each opener in the right email and check it against that row. The page reads up to 200 rows and 20 columns, sends up to 40 recipients (rows with no address or a repeated one are skipped and not sent), and flags subjects over 78 characters. After the reply it keeps one opener per sent row that has notes, splices it in after the greeting, marks every review quote it cannot find in the template, checks the verdict against the must-fix points, and builds the merged CSV and the text export. Nothing is sent from the page. Your code can do the same with the engine files the page loads (see "Build the input yourself").

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"dear-name"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, goal, sender, subject, body, fields, checks, rows, counts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the merge run is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field, the input and the rules

Every run sends task: merge (the one task is merge); a missing or different one is still answered as merge. All fields are strings. What each holds, in the words of the system prompt, with the first sample's value (its first line where it has several):

FieldRequiredWhat it holds
taskyesAlways merge. If it is missing or different, still answer this task and set "task": "merge".
goalyesWhat the email is for, in the sender's words, or none. First sample: Follow up with people I met at the Lisbon product conference and offer a short call.
senderyesWho is sending it, or none. First sample: Rosa Quellmore.
subjectyesThe template, with {{field}} and {{field|fallback}} placeholders. First sample: Great to meet you at the conference, {{first_name}}.
bodyyesThe template, with {{field}} and {{field|fallback}} placeholders. First sample: Hi {{first_name}}, ...
fieldsyesThe sheet columns the template uses (with any fallback), and the notes column. First sample: first_name, company (fallback "your team"); notes column: notes.
checksyesTemplate problems the page found, one per line, or none. First sample: none.
rowsyesOne line per recipient: r1. field: value | ... | notes: ..., then the page's status when a row needs a fix. First sample: r1. first_name: Ana | company: Brightloop | notes: asked how we cut activation time; runs a team of 6 designers ...
countsyesHow many recipients are ready, to fix or skipped, and how many have notes. First sample: 6 recipients: 4 ready, 1 to fix, 1 skipped; 5 with notes.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

How the page reads the sheet and the template (MergeKit): a header row, then one row per recipient, separated by commas, tabs or semicolons (the commonest in the header line), with quoted cells and doubled quotes; up to 200 rows and 20 columns, each cell clipped at a few hundred characters. The email column is the one named email (or the first whose name holds mail); the notes column is the first named notes, note, context, about, background, personal, remarks or comments. A placeholder matches a column ignoring case, spaces and punctuation ({{First Name}} is first_name); {{field|fallback}} prints the fallback when the cell is blank; {{opener}} places the personal line yourself, otherwise it goes after a greeting line (Hi, Hello, Dear ...) or at the top. The template checks (MergeKit.templateChecks): a placeholder with no column, an unclosed {{ or }}, an empty {{ }}, single braces that look like a field, no email column, and a subject with no placeholder over 78 characters. Every row is merged (MergeKit.mergeAll) and gets a status: skip when it has no address or repeats an earlier one (ignoring case), fix when the address is malformed or a placeholder with no fallback is blank, otherwise ready; a merged subject over 78 characters is noted too. Skipped rows are not sent; the first 40 of the rest are. The page needs a body, at least one row and an email column.

The model's rules

As the system prompt states them, in order:

  1. review - zero to four points about the template, most important first. Each has:
    • level - must only when the quoted words will read wrongly for at least one recipient once merged (a blank value in rows, a problem in checks) or say something the goal contradicts; otherwise could.
    • quote - the exact words from subject or body the point is about, at most 12 words, copied character for character (placeholders included).
    • why - one sentence, at most 20 words.
    • fix - the rewritten words, at most 25 words; keep or add {{field}} or {{field|fallback}} placeholders where they belong.
    Leave out anything a reader would not notice. [] when the template reads well for everyone.
  2. openers - one entry for each row in rows whose notes are not none, in row order: row (its id) and line - one sentence, at most 22 words, built only from that row's own notes and values. Write it in the sender's own voice (I or we, as the template does) to that recipient, and tie the note to what the email is for when the note allows - a sentence the sender would plausibly write by hand, not a restated note. No greeting, no sign-off, no placeholders, nothing from any other row, nothing the notes do not say. Rows with notes none get no entry.
  3. subject_alt - a better subject, at most 9 words, using only the template's own placeholders, or "" when the subject is fine.
  4. verdict - revise when any review point is must; ready otherwise.
  5. note - one sentence, at most 30 words: whether the merge is ready to send and the single most important change, if any.

And the house rules:

The reply

One JSON object, in the shape the system prompt asks for:

{
  "task": "merge",
  "verdict": "ready | revise",
  "review": [ { "level": "must | could", "quote": "exact words", "why": "one sentence", "fix": "rewritten words" } ],
  "openers": [ { "row": "r1", "line": "one sentence" } ],
  "subject_alt": "a few words or empty",
  "note": "one sentence"
}

The page reads the reply with ReplyKit.normalize (the verdict, levels and row ids matched in any case; a level other than must reads as could) and keeps what it trusts with ReplyKit.trusted(reply, built): an opener is set aside, with the reason, when it names a row the page did not send, a row with no notes, a row that already has an opener, when it is empty or when it still holds a placeholder; every review point is marked by whether its quote is found word for word in the template; and every sent row with notes and no opener is listed as missing. The emails, the addresses and the statuses always come from the page's own merge, never from the reply.

ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that the must-fix points contradict (revise exactly when a point is must); a review quote not in the template; blank fields or template problems with no must-fix point; more than four points; every opener set aside and every row with notes but no opener; an opener that mentions a word identifying another recipient (their values or the capitalised words of their notes); an opener quoting a number its row, the template and the goal do not have; an opener over 22 words, or one that repeats a greeting or sign-off; a suggested subject using a placeholder the sheet has no column for, or over 9 words; a sentence that names a row id, an internal field, a placeholder or uses backticks; words such as "again", "already", "always", "never" or "every" that the input never used; and a number in the note or a reason that the template and sheet do not have. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Ready to send" or "Fix before sending".

The exports: MergeKit.toCSV(merged) writes one row per recipient (email, subject, body, status, issues) with CRLF line ends, ready for a mail-merge add-on or a script; MergeKit.toText(merged) writes every email that is not skipped as plain text; ReplyKit.md and ReplyKit.json write the review and every merged email, and the envelope wall saves as PNG or SVG.

Worked examples - every sample

These are the free samples on the page: the template and the sheet as typed, the page's merge and its checks, the exact input it sends, the reply that replays for free, and what the page does with it - the review and the openers it trusts, one merged email with its opener, the verdict against the must-fix points and its own check. Together they cover the verdicts revise and ready.

Following up after a conference

The form: the goal Follow up with people I met at the Lisbon product conference and offer a short call, from Rosa Quellmore. The subject and body as typed:

Great to meet you at the conference, {{first_name}}

Hi {{first_name}},

It was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at {{company|your team}}, and the short write-up is below.

If a 20-minute call next week would help, just reply with a time that suits you.

Best,
Rosa

The sheet as typed:

first_name,last_name,email,company,notes
Ana,Vellacourt,ana.vellacourt@brightloop.example.com,Brightloop,asked how we cut activation time; runs a team of 6 designers
Tom,Brackenfeld,tom@northwind.example.com,Northwind,met at the coffee line on day two; building a mobile banking app
,Quarrington,c.quarrington@harbor.example.com,Harbor,talked about moving from spreadsheets to a CRM
Mei,Lindhaven,mei.lindhaven@quill.example.com,,interested in our checklist template for new hires
Jonas,Fjellmark,jonas@fjordlabs.example.com,Fjord Labs,
Tom,Brackenfeld,TOM@northwind.example.com,Northwind,duplicate from the badge scan

The page's merge (MergeKit.buildInput), one row per recipient with its status and issues; template checks: none.

RowNameEmailStatusIssues
r1Anaana.vellacourt@brightloop.example.comready-
r2Tomtom@northwind.example.comready-
r3Quarringtonc.quarrington@harbor.example.comfixfirst_name is blank
r4Meimei.lindhaven@quill.example.comready-
r5Jonasjonas@fjordlabs.example.comready-
r6TomTOM@northwind.example.comskip, not sentthe same address as row 2

The page warns: "1 email needs a fix before sending. 1 row is skipped (no address or a repeated one)."

Input:

{
 "task": "merge",
 "goal": "Follow up with people I met at the Lisbon product conference and offer a short call",
 "sender": "Rosa Quellmore",
 "subject": "Great to meet you at the conference, {{first_name}}",
 "body": "Hi {{first_name}},\n\nIt was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at {{company|your team}}, and the short write-up is below.\n\nIf a 20-minute call next week would help, just reply with a time that suits you.\n\nBest,\nRosa",
 "fields": "first_name, company (fallback \"your team\"); notes column: notes",
 "checks": "none",
 "rows": "r1. first_name: Ana | company: Brightloop | notes: asked how we cut activation time; runs a team of 6 designers\nr2. first_name: Tom | company: Northwind | notes: met at the coffee line on day two; building a mobile banking app\nr3. first_name: (blank) | company: Harbor | notes: talked about moving from spreadsheets to a CRM | fix: first_name is blank\nr4. first_name: Mei | company: (blank) | notes: interested in our checklist template for new hires\nr5. first_name: Jonas | company: Fjord Labs | notes: none",
 "counts": "6 recipients: 4 ready, 1 to fix, 1 skipped; 5 with notes"
}

Reply:

{
 "task": "merge",
 "verdict": "revise",
 "review": [
  {
   "level": "must",
   "quote": "Great to meet you at the conference, {{first_name}}",
   "why": "One recipient has no first name, so this line ends with a trailing comma and gap.",
   "fix": "Great to meet you at the conference, {{first_name|there}}"
  },
  {
   "level": "must",
   "quote": "Hi {{first_name}},",
   "why": "This same recipient's greeting will open with just Hi and a comma.",
   "fix": "Hi {{first_name|there}},"
  },
  {
   "level": "could",
   "quote": "the short write-up is below",
   "why": "Nothing follows this line, so the promised write-up does not appear.",
   "fix": "and here is a short summary of how it works"
  }
 ],
 "openers": [
  {
   "row": "r1",
   "line": "Great meeting you, Ana - I'll make sure the write-up answers how we cut activation time for your 6-designer team."
  },
  {
   "row": "r2",
   "line": "It was great meeting you at the coffee line on day two, Tom - good luck with the mobile banking app."
  },
  {
   "row": "r3",
   "line": "It was great talking about how Harbor could move from spreadsheets to a CRM."
  },
  {
   "row": "r4",
   "line": "I'm glad our checklist template for new hires caught your interest, Mei - let's dig into it on a call."
  }
 ],
 "subject_alt": "Great to meet you at the conference",
 "note": "Hold off on sending until the recipient missing a first name gets a fallback, so the opening line and greeting don't read oddly."
}

The review the page shows (ReplyKit.trusted), most important first, each quote checked against the template:

The openers it keeps, one per sent row with notes:

One merged email with its opener spliced in (MergeKit.mergeAll(built.f, t.openers), the first ready row with an opener, r1):

To: ana.vellacourt@brightloop.example.com
Subject: Great to meet you at the conference, Ana

Hi Ana,

Great meeting you, Ana - I'll make sure the write-up answers how we cut activation time for your 6-designer team.

It was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at Brightloop, and the short write-up is below.

If a 20-minute call next week would help, just reply with a time that suits you.

Best,
Rosa

The verdict: revise - "Fix before sending"; it agrees with the review, which raises 2 must-fix points.

Another subject: "Great to meet you at the conference"

The note: "Hold off on sending until the recipient missing a first name gets a fallback, so the opening line and greeting don't read oddly."

The page's check (ReplyKit.check): kept.

No notes.

A landlord's visit notice

The form: the goal Tell each tenant when the engineer will service their boiler, from Sam Pettigrove. The subject and body as typed:

Boiler service at {{flat}} on {{date}}

Dear {{name}},

The yearly boiler service for {{flat}} is booked for {{date}} between {{time}}. The engineer needs about 45 minutes and access to the kitchen cupboard.

If that time does not work, reply to this email and I will rearrange it.

Thank you,
Sam Pettigrove

The sheet as typed (tab-separated):

name	flat	email	date	time	notes
Mrs Hughes	Flat 1	hughes.m@example.org	Tuesday 10 November	9 and 11am	has a dog that barks at visitors
Daniel Ardenwick	Flat 2	dan.ardenwick@example.net	Tuesday 10 November	11am and 1pm	works nights, asked for late morning visits
Priya and Ollie Marlowby	Flat 3	marlowby.flat3@example.org	Wednesday 11 November	9 and 11am	new tenants since September
Mr Kestrelby	Flat 4	kestrelby.j@example.net	Wednesday 11 November	11am and 1pm	

The page's merge (MergeKit.buildInput), one row per recipient with its status and issues; template checks: none.

RowNameEmailStatusIssues
r1Mrs Hugheshughes.m@example.orgready-
r2Daniel Ardenwickdan.ardenwick@example.netready-
r3Priya and Ollie Marlowbymarlowby.flat3@example.orgready-
r4Mr Kestrelbykestrelby.j@example.netready-

Input:

{
 "task": "merge",
 "goal": "Tell each tenant when the engineer will service their boiler",
 "sender": "Sam Pettigrove",
 "subject": "Boiler service at {{flat}} on {{date}}",
 "body": "Dear {{name}},\n\nThe yearly boiler service for {{flat}} is booked for {{date}} between {{time}}. The engineer needs about 45 minutes and access to the kitchen cupboard.\n\nIf that time does not work, reply to this email and I will rearrange it.\n\nThank you,\nSam Pettigrove",
 "fields": "flat, date, name, time; notes column: notes",
 "checks": "none",
 "rows": "r1. flat: Flat 1 | date: Tuesday 10 November | name: Mrs Hughes | time: 9 and 11am | notes: has a dog that barks at visitors\nr2. flat: Flat 2 | date: Tuesday 10 November | name: Daniel Ardenwick | time: 11am and 1pm | notes: works nights, asked for late morning visits\nr3. flat: Flat 3 | date: Wednesday 11 November | name: Priya and Ollie Marlowby | time: 9 and 11am | notes: new tenants since September\nr4. flat: Flat 4 | date: Wednesday 11 November | name: Mr Kestrelby | time: 11am and 1pm | notes: none",
 "counts": "4 recipients: 4 ready, 0 to fix, 0 skipped; 3 with notes"
}

Reply:

{
 "task": "merge",
 "verdict": "ready",
 "review": [
  {
   "level": "could",
   "quote": "Boiler service at {{flat}} on {{date}}",
   "why": "The email heading does not show the appointment time tenants need to see.",
   "fix": "Boiler service at {{flat}} on {{date}}, {{time}}"
  }
 ],
 "openers": [
  {
   "row": "r1",
   "line": "I know your dog barks at visitors, so I wanted to mention it before the engineer visits for the boiler service."
  },
  {
   "row": "r2",
   "line": "Since you work nights and asked for late morning visits, I have scheduled the engineer between 11am and 1pm."
  },
  {
   "row": "r3",
   "line": "As you have been tenants only since September, I wanted to give you plenty of notice about this visit."
  }
 ],
 "subject_alt": "Boiler service at {{flat}} on {{date}}, {{time}}",
 "note": "This email can be sent now; the one change worth making is adding the appointment time to the heading."
}

The review the page shows (ReplyKit.trusted), most important first, each quote checked against the template:

The openers it keeps, one per sent row with notes:

One merged email with its opener spliced in (MergeKit.mergeAll(built.f, t.openers), the first ready row with an opener, r1):

To: hughes.m@example.org
Subject: Boiler service at Flat 1 on Tuesday 10 November

Dear Mrs Hughes,

I know your dog barks at visitors, so I wanted to mention it before the engineer visits for the boiler service.

The yearly boiler service for Flat 1 is booked for Tuesday 10 November between 9 and 11am. The engineer needs about 45 minutes and access to the kitchen cupboard.

If that time does not work, reply to this email and I will rearrange it.

Thank you,
Sam Pettigrove

The verdict: ready - "Ready to send"; it agrees with the review, which raises no must-fix point.

Another subject: "Boiler service at {{flat}} on {{date}}, {{time}}"

The note: "This email can be sent now; the one change worth making is adding the appointment time to the heading."

The page's check (ReplyKit.check): kept.

No notes.

End-of-term notes to parents

The form: the goal Send each parent a short end-of-term note about their child, from Ms Adeyemi. The subject and body as typed:

{{child}}'s autumn term

Dear {{parent_name}},

As the autumn term ends, I wanted to share a short note about {{child}}. Their reading level is now {{reading_level}}, and the class has finished its project on rivers.

Thank you for your support this term. Parents' evening is on 3 December.

Kind regards,
Ms Adeyemi

The sheet as typed:

parent_name;child;email;reading_level;notes
Mr Osei;Kwame;osei.family@example.org;Gold;built the best river model in the class and explained it to Year 2
Ms Novak;Ella;e.novak@example.net;Silver;much more confident reading aloud since October
Mrs Grant;Leo;grant.leo.parent@example;Purple;helps tidy the reading corner without being asked
Mr and Mrs Ito;Haru;itos@example.com;Gold;wrote a poem about the Thames for the display

The page's merge (MergeKit.buildInput), one row per recipient with its status and issues; template checks: none.

RowNameEmailStatusIssues
r1Mr Oseiosei.family@example.orgready-
r2Ms Novake.novak@example.netready-
r3Mrs Grantgrant.leo.parent@examplefix"grant.leo.parent@example" is not an email address
r4Mr and Mrs Itoitos@example.comready-

The page warns: "1 email needs a fix before sending."

Input:

{
 "task": "merge",
 "goal": "Send each parent a short end-of-term note about their child",
 "sender": "Ms Adeyemi",
 "subject": "{{child}}'s autumn term",
 "body": "Dear {{parent_name}},\n\nAs the autumn term ends, I wanted to share a short note about {{child}}. Their reading level is now {{reading_level}}, and the class has finished its project on rivers.\n\nThank you for your support this term. Parents' evening is on 3 December.\n\nKind regards,\nMs Adeyemi",
 "fields": "child, parent_name, reading_level; notes column: notes",
 "checks": "none",
 "rows": "r1. child: Kwame | parent_name: Mr Osei | reading_level: Gold | notes: built the best river model in the class and explained it to Year 2\nr2. child: Ella | parent_name: Ms Novak | reading_level: Silver | notes: much more confident reading aloud since October\nr3. child: Leo | parent_name: Mrs Grant | reading_level: Purple | notes: helps tidy the reading corner without being asked | fix: \"grant.leo.parent@example\" is not an email address\nr4. child: Haru | parent_name: Mr and Mrs Ito | reading_level: Gold | notes: wrote a poem about the Thames for the display",
 "counts": "4 recipients: 3 ready, 1 to fix, 0 skipped; 4 with notes"
}

Reply:

{
 "task": "merge",
 "verdict": "ready",
 "review": [],
 "openers": [
  {
   "row": "r1",
   "line": "I wanted you to know Kwame built the best river model in the class and explained it to Year 2."
  },
  {
   "row": "r2",
   "line": "I've noticed Ella has become much more confident reading aloud since October."
  },
  {
   "row": "r3",
   "line": "I really appreciate that Leo helps tidy the reading corner without being asked."
  },
  {
   "row": "r4",
   "line": "I was delighted that Haru wrote a poem about the Thames for the display."
  }
 ],
 "subject_alt": "",
 "note": "The merge is ready to send except for Mrs Grant, whose email address needs correcting before you send to her."
}

The review the page shows (ReplyKit.trusted), most important first, each quote checked against the template:

The openers it keeps, one per sent row with notes:

One merged email with its opener spliced in (MergeKit.mergeAll(built.f, t.openers), the first ready row with an opener, r1):

To: osei.family@example.org
Subject: Kwame's autumn term

Dear Mr Osei,

I wanted you to know Kwame built the best river model in the class and explained it to Year 2.

As the autumn term ends, I wanted to share a short note about Kwame. Their reading level is now Gold, and the class has finished its project on rivers.

Thank you for your support this term. Parents' evening is on 3 December.

Kind regards,
Ms Adeyemi

The verdict: ready - "Ready to send"; it agrees with the review, which raises no must-fix point.

Another subject: none - the subject is fine

The note: "The merge is ready to send except for Mrs Grant, whose email address needs correcting before you send to her."

The page's check (ReplyKit.check): kept.

No notes.

The merged CSV for the first sample

What MergeKit.toCSV(MergeKit.mergeAll(built.f, t.openers)) writes for "Following up after a conference" above: a header and one row per recipient (6), skipped rows included with their status, each body with its opener spliced in. Records end in CRLF; a cell holding a comma, a quote or a line break is quoted, with quotes doubled, so a body spans several lines inside its quotes.

email,subject,body,status,issues
ana.vellacourt@brightloop.example.com,"Great to meet you at the conference, Ana","Hi Ana,

Great meeting you, Ana - I'll make sure the write-up answers how we cut activation time for your 6-designer team.

It was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at Brightloop, and the short write-up is below.

If a 20-minute call next week would help, just reply with a time that suits you.

Best,
Rosa",ready,
tom@northwind.example.com,"Great to meet you at the conference, Tom","Hi Tom,

It was great meeting you at the coffee line on day two, Tom - good luck with the mobile banking app.

It was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at Northwind, and the short write-up is below.

If a 20-minute call next week would help, just reply with a time that suits you.

Best,
Rosa",ready,
c.quarrington@harbor.example.com,"Great to meet you at the conference,","Hi ,

It was great talking about how Harbor could move from spreadsheets to a CRM.

It was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at Harbor, and the short write-up is below.

If a 20-minute call next week would help, just reply with a time that suits you.

Best,
Rosa",fix,first_name is blank
mei.lindhaven@quill.example.com,"Great to meet you at the conference, Mei","Hi Mei,

I'm glad our checklist template for new hires caught your interest, Mei - let's dig into it on a call.

It was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at your team, and the short write-up is below.

If a 20-minute call next week would help, just reply with a time that suits you.

Best,
Rosa",ready,
jonas@fjordlabs.example.com,"Great to meet you at the conference, Jonas","Hi Jonas,

It was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at Fjord Labs, and the short write-up is below.

If a 20-minute call next week would help, just reply with a time that suits you.

Best,
Rosa",ready,
TOM@northwind.example.com,"Great to meet you at the conference, Tom","Hi Tom,

It was good to talk at the Lisbon product conference. I said I would send over how we handle onboarding at Northwind, and the short write-up is below.

If a 20-minute call next week would help, just reply with a time that suits you.

Best,
Rosa",skip,the same address as row 2

Build the input yourself

Load the engine in node (global.window = global; require("./mergekit.js"); require("./replykit.js")), then:

const K = MergeKit, R = ReplyKit;
const form = K.form({ goal: "Thank the volunteers who helped at the spring clean-up", sender: "Jo",
  subject: "Thank you, {{first_name}}", body: "Hi {{first_name}},\n\nThank you for helping at the spring clean-up on Saturday.\n\nBest,\nJo",
  rows: "first_name,email,notes\nAna,ana@example.org,ran the tool table all morning\nBen,ben@example.org," });
// form.head, form.rows: the sheet as read; form.email, form.notes: the column indexes; form.ph: every placeholder
K.templateChecks(form)                // template problems, no run needed
const built = K.buildInput(form);     // built.ok is false, with built.note, until there is a body, a row and an email column
// built.merged: every row [{id, name, email, subject, body, status, issues}]; built.sent: the rows in the input
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// const t = R.trusted(reply, built)      // {openers: {r1: line}, dropped, review, missing}
// const merged = K.mergeAll(built.f, t.openers)   // every email with its opener after the greeting
// R.check(reply, built)                   // {status: kept or flagged, notes}
// K.toCSV(merged)                         // email, subject, body, status, issues

Form keys: goal (what the email is for, or empty), sender (who sends it, or empty), subject and body (the template, with {{field}} and {{field|fallback}} placeholders) and rows (the sheet as text: a header row, then one row per recipient).

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://dear-name.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"dear-name"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "merge", "goal": "Thank the volunteers who helped at the spring clean-up", "sender": "Jo", "subject": "Thank you, {{first_name}}", "body": "Hi {{first_name}},\n\nThank you for helping at the spring clean-up on Saturday.\n\nBest,\nJo", "fields": "first_name; notes column: notes", "checks": "none", "rows": "r1. first_name: Ana | notes: ran the tool table all morning\nr2. first_name: Ben | notes: none", "counts": "2 recipients: 2 ready, 0 to fix, 0 skipped; 1 with notes"}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses dear-name:<task>:<inputHash>:a<attempt> (the one task is merge), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="dear-name:merge:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '.verdict, (.review | length), (.openers[] | "\(.row) \(.line)")' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: dear-name:merge:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json