For agents and developers
Let your agent design and send handwritten letters.
Your agent can use the handwriting fonts you make here: find the right one, draft a note, show you a preview, and mail it once you agree. Connect it through our MCP server, or call the REST API directly.
What an agent can do
| MCP tool | Does | Cost |
|---|---|---|
| list_fonts | Your fonts: id, name, how many characters | free |
| get_font | One font: characters, alternates, settings, attribution | free |
| list_library | Library fonts with their attribution; their ids work for previews and sends | free |
| preview_letter | Render a letter or postcard as PDF, PNG or SVG | free |
| send_letter | Mail a handwritten letter | $1.00 or 100 credits |
| send_postcard | Mail a handwritten postcard | $1.65 or 165 credits |
| list_sends | Your sends, newest first, with status | free |
| get_send_status | Where a send is: queued, submitted, mailed… | free |
| get_credits | Balance and recent ledger | free |
What keeps an agent in check
The MCP send tools instruct the agent to preview, show you the text, the address and the price, and wait for a clear yes. That is an instruction to the agent, not something our server can see or enforce. What the server does enforce: each API key can make at most 5 sends a day, it can only spend credits you already have, a card payment returns a checkout link you open yourself, and you can revoke a key at any time.
Get an API key
- Sign in with a passkey and make at least one font.
- On your account page, create an API key and name it after the agent that will use it (“Claude Desktop”).
- Copy the key straight away. It starts with
hw_and is shown once. - Revoke it on the same page whenever you like; the agent loses access immediately.
A key acts as you: it can read your fonts and spend your credits on sends, up to 5 sends a day per key. Give each agent its own.
MCP setup
The server runs on your machine over stdio (or as a small HTTP server) and forwards each call to the API with your key. It needs Node 20 or newer.
1. Build it
git clone <this repo> handwritten && cd handwritten
npm install
npm run mcp:build2a. Claude Code
claude mcp add handwritten \
--env HANDWRITTEN_API_KEY=hw_... \
--env HANDWRITTEN_BASE_URL=https://your-handwritten-site \
-- node /absolute/path/to/handwritten/mcp/dist/mcp/src/stdio.js2b. Claude Desktop
{
"mcpServers": {
"handwritten": {
"command": "node",
"args": [
"/absolute/path/to/handwritten/mcp/dist/mcp/src/stdio.js"
],
"env": {
"HANDWRITTEN_API_KEY": "hw_...",
"HANDWRITTEN_BASE_URL": "https://your-handwritten-site"
}
}
}
}Or over HTTP
PORT=3333 HANDWRITTEN_BASE_URL=https://your-handwritten-site \
npm run mcp:http
claude mcp add --transport http handwritten http://127.0.0.1:3333/mcp \
--header "Authorization: Bearer hw_..."The HTTP server holds no key; each client sends its own in the Authorization header.
Then ask: “Send my sister a postcard in my handwriting saying congratulations on the new flat.”
REST quickstart
Everything lives under /api/v1, speaks JSON, and takes your key as Authorization: Bearer. Stroke coordinates are em units from 0 to 1000 with y pointing down; money is in cents; times are ISO 8601 UTC.
export HANDWRITTEN_BASE_URL=https://your-handwritten-site
export HANDWRITTEN_API_KEY=hw_...{
"error": {
"code": "insufficient_credits",
"message": "This postcard needs 165 credits."
}
}GET
/api/v1/fontsList the user's fonts (no stroke data).
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/fonts" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY"Response { "fonts": [ { "id": "fnt_8k2m4q", "name": "My hand", "mode": "quick", "createdAt": "2026-09-26T10:00:00.000Z", "updatedAt": "2026-09-26T10:00:00.000Z", "glyphCount": 26, "alternateCount": 0 } ] }POST
/api/v1/fontsCreate a font from strokes. Points are em units 0–1000, y down.
creditsEarned is paid once per font.
Request curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/fonts" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"My hand","mode":"quick","glyphs":{"a":[{"char":"a","source":"drawn","strokes":[[{"x":520,"y":300,"t":0},{"x":380,"y":260,"t":40},{"x":300,"y":420,"t":90},{"x":420,"y":520,"t":140},{"x":540,"y":380,"t":190},{"x":560,"y":540,"t":240}]]}]},"settings":{"weight":1,"smoothing":0.4,"sizeBalance":0.5,"slant":0},"consent":{"dataContribution":false}}'Response { "font": { "name": "My hand", "mode": "quick", "glyphs": { "a": [ { "char": "a", "source": "drawn", "strokes": [ [ { "x": 520, "y": 300, "t": 0 }, { "x": 380, "y": 260, "t": 40 }, { "x": 300, "y": 420, "t": 90 }, { "x": 420, "y": 520, "t": 140 }, { "x": 540, "y": 380, "t": 190 }, { "x": 560, "y": 540, "t": 240 } ] ] } ] }, "settings": { "weight": 1, "smoothing": 0.4, "sizeBalance": 0.5, "slant": 0 }, "consent": { "dataContribution": false }, "id": "fnt_8k2m4q", "createdAt": "2026-09-26T10:00:00.000Z", "updatedAt": "2026-09-26T10:00:00.000Z" }, "creditsEarned": 10 }GET
/api/v1/fonts/fnt_8k2m4qGet one font with every glyph and its alternates.
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/fonts/fnt_8k2m4q" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY"Response { "font": { "name": "My hand", "mode": "quick", "glyphs": { "a": [ { "char": "a", "source": "drawn", "strokes": [ [ { "x": 520, "y": 300, "t": 0 }, { "x": 380, "y": 260, "t": 40 }, { "x": 300, "y": 420, "t": 90 }, { "x": 420, "y": 520, "t": 140 }, { "x": 540, "y": 380, "t": 190 }, { "x": 560, "y": 540, "t": 240 } ] ] } ] }, "settings": { "weight": 1, "smoothing": 0.4, "sizeBalance": 0.5, "slant": 0 }, "consent": { "dataContribution": false }, "id": "fnt_8k2m4q", "createdAt": "2026-09-26T10:00:00.000Z", "updatedAt": "2026-09-26T10:00:00.000Z" } }PUT
/api/v1/fonts/fnt_8k2m4qReplace a font’s editable fields.
Request curl -s -X PUT "$HANDWRITTEN_BASE_URL/api/v1/fonts/fnt_8k2m4q" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"My hand, tidier","mode":"quick","glyphs":{"a":[{"char":"a","source":"drawn","strokes":[[{"x":520,"y":300,"t":0},{"x":380,"y":260,"t":40},{"x":300,"y":420,"t":90},{"x":420,"y":520,"t":140},{"x":540,"y":380,"t":190},{"x":560,"y":540,"t":240}]]}]},"settings":{"weight":1,"smoothing":0.7,"sizeBalance":0.5,"slant":0},"consent":{"dataContribution":false}}'Response { "font": { "name": "My hand, tidier", "mode": "quick", "glyphs": { "a": [ { "char": "a", "source": "drawn", "strokes": [ [ { "x": 520, "y": 300, "t": 0 }, { "x": 380, "y": 260, "t": 40 }, { "x": 300, "y": 420, "t": 90 }, { "x": 420, "y": 520, "t": 140 }, { "x": 540, "y": 380, "t": 190 }, { "x": 560, "y": 540, "t": 240 } ] ] } ] }, "settings": { "weight": 1, "smoothing": 0.7, "sizeBalance": 0.5, "slant": 0 }, "consent": { "dataContribution": false }, "id": "fnt_8k2m4q", "createdAt": "2026-09-26T10:00:00.000Z", "updatedAt": "2026-09-26T10:20:00.000Z" }, "creditsEarned": 0 }DELETE
/api/v1/fonts/fnt_8k2m4qDelete a font.
Request curl -s -X DELETE "$HANDWRITTEN_BASE_URL/api/v1/fonts/fnt_8k2m4q" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY"Response { "deleted": true }GET
/api/v1/fonts/fnt_8k2m4q/ttfDownload the font as TrueType.
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/fonts/fnt_8k2m4q/ttf" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY" \ -o my-hand.ttfResponse: binary
font/ttf.GET
/api/v1/libraryList the free library fonts, each with its attribution.
A library font id works as fontId for /preview and /sends too.
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/library"Response { "fonts": [ { "id": "lib_fieldnote", "name": "Fieldnote", "description": "Upright print, 78 recorded characters from one writer.", "glyphCount": 78, "attribution": { "dataset": "UJI Pen Characters v2", "license": "CC-BY-4.0", "text": "UJI Pen Characters (Version 2), F. Prat, M. Castro, D. Llorens, A. Marzal, J. Vilar. https://doi.org/10.24432/C5FG8S. CC BY 4.0.", "url": "https://doi.org/10.24432/C5FG8S" } } ] }GET
/api/v1/library/lib_fieldnoteA library font as an editable FontProject, to customise and save to /fonts as your own.
Keep credits: it carries the CC BY 4.0 attribution into every font and letter made from it.
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/library/lib_fieldnote"Response { "font": { "id": "lib_fieldnote", "name": "Fieldnote", "createdAt": "2026-09-26T10:00:00.000Z", "updatedAt": "2026-09-26T10:00:00.000Z", "mode": "full", "glyphs": { "a": [ { "char": "a", "source": "library", "strokes": [ [ { "x": 402, "y": 610 }, { "x": 330, "y": 700 }, { "x": 420, "y": 760 } ] ] } ] }, "settings": { "weight": 1, "smoothing": 0.42857142857142855, "sizeBalance": 0.5, "slant": 0 }, "consent": { "dataContribution": false }, "credits": "UJI Pen Characters (Version 2), F. Prat, M. Castro, D. Llorens, A. Marzal, J. Vilar. https://doi.org/10.24432/C5FG8S. CC BY 4.0." }, "attribution": { "dataset": "UJI Pen Characters v2", "license": "CC-BY-4.0", "text": "UJI Pen Characters (Version 2), F. Prat, M. Castro, D. Llorens, A. Marzal, J. Vilar. https://doi.org/10.24432/C5FG8S. CC BY 4.0.", "url": "https://doi.org/10.24432/C5FG8S" } }GET
/api/v1/library/lib_fieldnote/ttfDownload a library font.
Works signed out. Signed in, the X-Credits-Earned response header says what the download earned (once per library font, a few a day).
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/library/lib_fieldnote/ttf" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY" \ -o fieldnote.ttf -D -Response: binary
font/ttf.POST
/api/v1/previewRender a letter or postcard without sending. format: pdf (default), png (first page / postcard back, 144 dpi) or svg.
Library fonts preview without a key. For a postcard, recipient is sketched into the address strip.
Request curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/preview" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"kind":"postcard","fontId":"fnt_8k2m4q","text":"Happy birthday, Grandma! The garden says hi.","format":"pdf","seed":42,"recipient":{"name":"Rosa Alvarez","line1":"418 Willow Street","city":"Burlington","region":"VT","postalCode":"05401","country":"US"}}' \ -o preview.pdfResponse: binary
application/pdf.GET
/api/v1/capabilitiesWhat this deployment does right now: whether sends are mailed, and whether card payment is on.
fulfilment "dry-run" is test mode: sends are rendered to a PDF proof and nothing is mailed. With cardPayments false, POST /sends refuses "paymentMethod": "stripe" (503) before creating anything.
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/capabilities"Response { "cardPayments": false, "fulfilment": "dry-run" }POST
/api/v1/sendsMail a letter or postcard. Pays with credits, or returns a Stripe checkoutUrl.
With "paymentMethod": "stripe" the status is awaiting_payment and checkoutUrl is set; nothing is mailed until the user pays. Each agent API key can make 5 sends a UTC day (429 rate_limited after). US recipients only for now.
Request curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/sends" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"kind":"postcard","fontId":"fnt_8k2m4q","text":"Happy birthday, Grandma! The garden says hi.","recipient":{"name":"Rosa Alvarez","line1":"418 Willow Street","city":"Burlington","region":"VT","postalCode":"05401","country":"US"},"paymentMethod":"credits","seed":42}'Response { "id": "snd_3j9x1c", "status": "queued", "priceCents": 165, "creditsUsed": 165 }GET
/api/v1/sends?limit=20Your sends, newest first.
Pass nextCursor back as cursor for the next page; it is absent on the last page. limit is 1–100.
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/sends?limit=20" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY"Response { "sends": [ { "id": "snd_3j9x1c", "status": "submitted", "priceCents": 165, "creditsUsed": 165, "kind": "postcard", "fontId": "fnt_8k2m4q", "recipient": { "name": "Rosa Alvarez", "line1": "418 Willow Street", "city": "Burlington", "region": "VT", "postalCode": "05401", "country": "US" }, "createdAt": "2026-09-26T10:00:00.000Z", "updatedAt": "2026-09-26T10:03:00.000Z", "fulfilmentRef": "mc_0f41b2", "pdfUrl": "/api/v1/sends/snd_3j9x1c/pdf" } ], "nextCursor": "41" }GET
/api/v1/sends/snd_3j9x1cStatus of a send, with the proof PDF once rendered.
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/sends/snd_3j9x1c" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY"Response { "id": "snd_3j9x1c", "status": "submitted", "priceCents": 165, "creditsUsed": 165, "kind": "postcard", "fontId": "fnt_8k2m4q", "recipient": { "name": "Rosa Alvarez", "line1": "418 Willow Street", "city": "Burlington", "region": "VT", "postalCode": "05401", "country": "US" }, "createdAt": "2026-09-26T10:00:00.000Z", "updatedAt": "2026-09-26T10:03:00.000Z", "fulfilmentRef": "mc_0f41b2", "pdfUrl": "/api/v1/sends/snd_3j9x1c/pdf" }GET
/api/v1/sends/snd_3j9x1c/pdfThe rendered PDF of your own send (404 until rendered).
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/sends/snd_3j9x1c/pdf" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY" \ -o letter.pdfResponse: binary
application/pdf.GET
/api/v1/creditsBalance and ledger, most recent first.
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/credits" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY"Response { "balance": 12, "entries": [ { "id": 2, "delta": 2, "reason": "library_download", "ref": "lib_fieldnote", "at": "2026-09-26T09:40:00.000Z" }, { "id": 1, "delta": 10, "reason": "font_created", "ref": "fnt_8k2m4q", "at": "2026-09-26T09:30:00.000Z" } ] }GET
/api/v1/keysList your active API keys and their scopes.
Request # Browser session only (passkey sign-in); API keys get 403. \ curl -s "$HANDWRITTEN_BASE_URL/api/v1/keys"Response { "keys": [ { "id": "key_71hd", "name": "Claude Desktop", "prefix": "hw_ab12cd34", "scopes": [ "fonts:read", "fonts:write", "sends:write" ], "createdAt": "2026-09-26T10:00:00.000Z", "lastUsedAt": "2026-09-26T11:00:00.000Z" } ] }POST
/api/v1/keysMint an API key (do this on the account page).
The secret is shown exactly once; only its hash is stored. Scopes: fonts:read fonts:write sends:write.
Request # Browser session only (passkey sign-in); API keys get 403. \ curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/keys" \ -H "Content-Type: application/json" \ -d '{"name":"Claude Desktop"}'Response { "key": { "id": "key_71hd", "name": "Claude Desktop", "prefix": "hw_ab12cd34", "scopes": [ "fonts:read", "fonts:write", "sends:write" ], "createdAt": "2026-09-26T10:00:00.000Z" }, "secret": "hw_ab12cd34_…(shown once)" }DELETE
/api/v1/keys/key_71hdRevoke an API key.
Request # Browser session only (passkey sign-in); API keys get 403. \ curl -s -X DELETE "$HANDWRITTEN_BASE_URL/api/v1/keys/key_71hd"Response { "deleted": true }POST
/api/v1/consentOpt a font in or out of handwriting-data contribution.
The person’s own decision: a browser session, or a phone key linked through /connect/android (scope consent:write). Agent keys get 403. false revokes and deletes the samples that font contributed.
Request curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/consent" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"fontId":"fnt_8k2m4q","dataContribution":false}'Response { "fontId": "fnt_8k2m4q", "consent": { "dataContribution": false, "at": "2026-09-26T10:00:00.000Z" }, "samplesDeleted": 26 }GET
/api/v1/auth/meWho this session or key belongs to (401 when signed out).
Request curl -s "$HANDWRITTEN_BASE_URL/api/v1/auth/me" \ -H "Authorization: Bearer $HANDWRITTEN_API_KEY"Response { "user": { "id": "usr_5f2a", "displayName": "Ada", "createdAt": "2026-09-26T10:00:00.000Z" }, "via": "key" }POST
/api/v1/auth/register/optionsStart creating an account with a passkey: WebAuthn creation options { options }.
Then POST /auth/register/verify { response } with the browser credential; answers { user: { id } } and sets the session cookie.
Request # Browser session only (passkey sign-in); API keys get 403. \ curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/auth/register/options" \ -H "Content-Type: application/json" \ -d '{"name":"Ada"}'Response { "options": "…PublicKeyCredentialCreationOptionsJSON…" }POST
/api/v1/auth/login/optionsStart a passkey sign-in: WebAuthn request options { options }.
Then POST /auth/login/verify { response }; answers { user: { id } } and sets the session cookie. POST /auth/logout ends the session.
Request # Browser session only (passkey sign-in); API keys get 403. \ curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/auth/login/options"Response { "options": "…PublicKeyCredentialRequestOptionsJSON…" }POST
/api/v1/auth/login/verifyFinish a passkey sign-in with the browser’s credential JSON.
Request # Browser session only (passkey sign-in); API keys get 403. \ curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/auth/login/verify" \ -H "Content-Type: application/json" \ -d '{"response":"…AuthenticationResponseJSON…"}'Response { "user": { "id": "usr_5f2a" } }POST
/api/v1/connect/android/approveThe /connect/android page approves linking the Android app; answers the mailcraftfonts:// redirect.
The code in the redirect is single-use, lasts 5 minutes, is stored hashed and is bound to the PKCE challenge (S256).
Request # Browser session only (passkey sign-in); API keys get 403. \ curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/connect/android/approve" \ -H "Content-Type: application/json" \ -d '{"state":"Zq4…state","codeChallenge":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM","app":"io.kept.handwriting.fonts"}'Response { "redirectUrl": "mailcraftfonts://connect?state=Zq4%E2%80%A6state&code=…", "expiresAt": "2026-09-26T10:05:00.000Z" }POST
/api/v1/connect/android/exchangeThe app trades its one-time code and PKCE verifier for its own API key.
No auth header: the code and verifier are the proof. A wrong verifier burns the code. The key can also change data consent (consent:write).
Request curl -s -X POST "$HANDWRITTEN_BASE_URL/api/v1/connect/android/exchange" \ -H "Content-Type: application/json" \ -d '{"code":"one-time-code-from-the-redirect","codeVerifier":"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"}'Response { "key": { "id": "key_a9c2", "name": "Android phone (io.kept.handwriting.fonts)", "prefix": "hw_9f00e1c2", "scopes": [ "fonts:read", "fonts:write", "sends:write", "consent:write" ], "createdAt": "2026-09-26T10:00:00.000Z" }, "secret": "hw_9f00e1c2_…(shown once)" }
Send statuses
- awaiting_payment
- Waiting for the user to pay at checkoutUrl.
- queued
- Paid; waiting to be rendered.
- rendering
- Being written out in the font.
- rendered_dry_run
- Test mode: rendered and recorded, not mailed.
- submitted
- Handed to the print-and-mail partner.
- mailed
- In the post.
- failed
- Could not be completed; the error field says why.
- cancelled
- Stopped before mailing.
- needs_review
- The mail service did not confirm it; a person checks, then refunds or submits it. Do not resend.