One original print, every morning.
A thermal receipt printer in my kitchen wakes up each day and prints a piece of character art that has never existed before, designed minutes earlier by Claude, themed to the day: the weather outside, the season, whatever the news feels like. A short verse underneath. One physical copy, 80mm wide, and that is the whole broadcast.
Two mornings, a day apart. Most days stand on their own; once in a while a piece answers the one before it, the way July 4 answered July 3 above.
This started as a bet against my phone. The first minutes of the day kept going to feeds, so I pointed the same inputs at a printer instead. It reads what the feeds read (forecasts, anniversaries, and the day's headlines when it goes looking for them) and hands back one finished thing you can hold. There is nothing to refresh; when the cutter drops, the day's edition is out. Yesterday's goes up on the corkboard.
The cloud half is a single Google Apps Script. The only hardware on my side is
a Raspberry Pi Zero W running a small Python bridge (systemd plus an ngrok
tunnel; runbook in docs/
) that pipes bytes into the printer's USB port.
graph LR
subgraph cloud["Google Apps Script Β· daily trigger"]
A(["daily brief"]) --> B["Claude designs<br/>an art spec"] --> C["render to<br/>ESC/POS"]
end
subgraph kitchen["the kitchen"]
D["Pi Zero W"] --> E(["Epson prints<br/>and cuts"])
end
C -- "POST Β· ngrok Β·<br/>basic auth" --> D
Context.printDailyArt()
(src/art.ts
) builds a small brief: today's date, the season, current weather from the Google Weather API, and a rolling archive of the last 14 pieces (title + one-line style note each).Design. Claude gets a system prompt describing the medium (a 48-column monospace grid, 1-bit black, only CP437, the old IBM PC character set) and is asked for one committed idea that differs sharply from everything in the archive. On a day that genuinely earns it (a holiday after its eve, an event still unfolding, a resonant anniversary) it may instead answer an earlier piece; the pair at the top of this page is one of those. It can run a few web searches to feel out the day. Structured output (a JSON schema) constrains the reply to an art spec: an array of styled text ops plus a short verse.Render. A ~50-line renderer turns ops into raw ESC/POS bytes (ESC/POS is the byte-level command language receipt printers speak). No drivers, no images: the art is literally text with style commands.Print. The bytes are POSTed to the Pi, which writes them to the printer's character device. The receipt prints: a small date stamp, the artwork, the verse. Nothing else: no title, no branding.
The canvas: 203 dpi, one bit of color, 48 monospace columns, and a character set frozen in 1981. Everything the model can do, it does with CP437:
| Trick | How |
|---|---|
| Tone / gradients | β β β β ramps across rows |
| Sub-character edges | half-blocks β β β β β for silhouettes |
| Solid black fields | inverted (white-on-black) runs; even spaces print solid |
| Seamless shapes | line spacing set to exactly one glyph height, so rows tile |
| Giant type | independent width/height scaling, 1β8Γ each |
| Fine texture | the printer's second font: 9Γ17 glyphs, 64 columns |
| Structure | full single/double box drawing, Β° Β· β β β Ο Ξ£ and friends |
The "seamless shapes" row is the trick that makes block art possible at all:
by default the printer leaves a white seam between text lines, but ESC 3
with the right value makes ββββ
rows fuse into continuous fields. The value was calibrated empirically with a built-in test page. That page, and every other byte this project sends, is documented in docs/escpos-protocol.md.
The model doesn't emit bytes; it emits a declarative spec the renderer executes. Abbreviated:
{
"title": "GOLDEN RUN",
"caption": "Test pattern: gradient, silhouette, invert, scale, texture.",
"verse": "The mountains hold their breath;\nthe sun tries every shade of gray\nbefore committing to gold.",
"style": "calibration plate; gradient + silhouette + type specimen",
"continues": "",
"ops": [
{ "text": "βββββ¦\nβββββ¦\nβββββ¦", "gapless": true },
{ "text": " DAWN ", "width": 2, "height": 2, "bold": true, "invert": true },
{ "text": "every feature Β· one receipt", "font": "B", "align": "right" }
]
}
Only the artwork and the verse are printed. The rest are archive fields: the
title and style land in a rolling ART_HISTORY
property that the next day's prompt receives as a list to differ from; the caption goes to the execution log only. That pressure keeps a daily generative loop from collapsing into the same sunset every morning, and the prompt pushes rotation across the whole space: landscapes, geometric abstraction, pattern studies, giant-type posters, constellation maps, weather glyphs, emblems, diagrams.
continues
is the deliberate exception. It stays empty almost every day, but when the day itself hands the model a thread (the Fourth of July arriving after its eve, an event still unfolding, a tenth anniversary), the model may build on one recent piece and record which. Those links show up as markers in the history it reads on later days, and a fresh marker raises the bar for the next one. That is what keeps continuations a surprise instead of a habit.
The renderer treats the spec as untrusted: sizes are clamped, rows are
truncated to the column budget, control characters are stripped, output is
capped at 150 rows (~45cm of paper), and anything CP437 can't print becomes a
visible-but-harmless ?
.
Structured output guarantees the shape of the JSON, not the size of the strings inside it, and one run in July 2026 found that gap: valid JSON with a 3,155-character title of repeated filler. The archive saved it, every later prompt replayed it, and the model learned its own loop; within a week the receipt was a page of the model talking to itself instead of artwork. Specs are now checked against the length bounds the schema describes before anything is printed or written to the archive; a rejected spec throws to the alert email, and the next trigger retries. The archive is sanitized on read too, so an already poisoned entry is dropped once instead of fed back for fourteen days.
A few API notes, for the curious: the art spec comes back via structured
output (output_config.format
with a JSON schema), and web search runs as a
server-side tool, so the client just resumes on stop_reason: "_turn"
.
The local harness enables the server-side fallback beta (another model
re-serves a refused request); the production path does not, so a refusal there
fails loudly to the alert email. Details in CLAUDE.md
.
Printer: Epson TM-T20III, an 80mm ESC/POS thermal printer with an auto-cutter. Any ESC/POS printer with a CP437 code page should work after recalibrating the column constants (node test-print.mjs ruler
).Bridge: Raspberry Pi Zero W running a ~40-line Pythonhttp.server
that writes request bodies straight to/dev/usb/lp0
, exposed through an ngrok static domain with basic auth, kept alive by systemd. The script itself lives on the Pi and is not committed here; its whole contract is "write each POST body to/dev/usb/lp0
", and the runbook covers the rest.
The full byte-level protocol (every command, the CP437 mapping, the geometry and calibration results) is specced in docs/escpos-protocol.md. The Pi's setup, maintenance, troubleshooting, and disaster recovery live in
docs/pi-print-server-runbook.md
You need an ESC/POS printer, something that can pipe bytes to its USB port (here, a Pi Zero W; see Hardware and the runbook), an ngrok static domain, an Anthropic API key, a Google account for Apps Script, and Node for the build.
TypeScript under src/
is the source of truth; esbuild bundles it to a single
dist/main.gs
, which clasp pushes to Apps Script.
npm install # dev tooling: clasp, typescript, esbuild, prettier
npm run build # tsc --noEmit + esbuild bundle -> dist/main.gs
npm run status # list the files clasp would push (dry check)
npm run push # build, then clasp push (uploads dist/)
The Apps Script web editor is a mirror, not a second source of truth: edit
locally and push. npm run pull
fetches remote back down if the editor was touched directly.
First-time setup on a new machine: npx clasp login
(writes ~/.clasprc.json
),
then npm run push
. The committed .clasp.json
binds to my script; for your
own deployment, run npx clasp create --type standalone --rootDir dist
(or put
your own scriptId
in .clasp.json
) before the first push.
No secrets live in the repo. Runtime config is read from Script Properties (Apps Script editor β Project Settings β Script Properties):
| Property | Required | What it is |
|---|---|---|
PI_URL |
||
| yes | ngrok HTTPS URL of the Pi print bridge | |
NGROK_USER |
||
| yes | basic-auth username for the tunnel | |
NGROK_PASS |
||
| yes | basic-auth password for the tunnel | |
ANTHROPIC_KEY |
||
| yes | Anthropic API key (claude-fable-5 ) |
|
EMAIL_ALERTS_TO |
||
| no | where failure alerts are emailed (rate-limited) | |
GEMINI_KEY |
||
| no | Google API key for the Weather API | |
LAT / LON |
||
| no | location for weather context |
Weather is garnish, not a dependency: without GEMINI_KEY
/LAT
/LON
the art still prints, just uninformed about the sky.
State keys managed by the script itself (no setup): LAST_ART_DATE
(one art
print per day), ART_HISTORY
(the rolling archive fed back into the prompt,
with markers on pieces that continued an earlier one), and LAST_ALERT_TIME
(rate-limits alert emails).
Create one time-driven trigger on printDailyArt
(Apps Script editor β
Triggers). A morning hour works well. An hourly trigger is also safe: LAST_ART_DATE
limits it to one print per day, and because the guard is only set on success, extra runs double as retries after a failure.
testDailyArt()
prints the golden test plate (no API call) to verify the
hardware path. Set DRY_RUN = true
in src/art.ts
to log specs instead of printing.
test-print.mjs
sends ESC/POS straight to the Pi bridge (the same endpoint
Apps Script hits), so you can iterate without deploying or waiting on a
trigger. The art
modes load the real builders from the built dist/main.gs
,
so run npm run build
first; the preview then matches production exactly.
cp .env.example .env # then fill in PI_URL / NGROK_USER / NGROK_PASS
npm run build # needed for modes that load dist/main.gs
node test-print.mjs hello # minimal "SYSTEM ONLINE" connectivity test
node test-print.mjs text "Hi" # arbitrary text
node test-print.mjs ruler # column + gapless-spacing calibration page
node test-print.mjs art # golden art spec through the real renderer (no API)
node test-print.mjs art:live # LIVE end-to-end art (ANTHROPIC_KEY in .env)
node test-print.mjs art --dry # print the hex payload instead of sending
This script is local only; it isn't bundled into dist/
or pushed to Apps Script.
Clone the repo, npm install
, and start Claude Code in the root; it picks up CLAUDE.md automatically, which carries the architecture, the deploy ritual, and the byte-level gotchas. You can iterate on the renderer with no hardware at all:
node test-print.mjs art --dry
shows the exact ESC/POS bytes a change produces. The longer version, including a good first prompt to hand Claude, is in .
CONTRIBUTING.md
src/
appsscript.json manifest (V8, America/Los_Angeles)
escpos.ts CMD command table, column constants, encodeCP437
art.ts the daily art job: prompt, schema, renderer, Anthropic client
transport.ts sendToPi (HTTP β Pi bridge), retries, alert emails
weather.ts Google Weather API fetcher (context for the prompt)
main.ts entry points re-exported for the build footer
build.js esbuild bundle β dist/main.gs
test-print.mjs local print harness (talks to the Pi directly)
docs/
escpos-protocol.md the full byte-level protocol spec
pi-print-server-runbook.md the Pi side: setup, ops, troubleshooting
example/
example_1.jpeg the Liberty Bell piece, fresh off the printer
example_2.jpeg the Jul 3 -> Jul 4 sequence, pinned side by side
clasp
uploads only dist/
(.clasp.json
β "rootDir": "dist"
), which is gitignored.
A personal project by Matt Horn, developed on personal time using personal equipment and personal accounts. Not affiliated with, sponsored by, or endorsed by Google, Anthropic, or any past or present employer. Views and content are my own. Copyright (c) 2026 Matt Horn. MIT-licensed; see LICENSE and NOTICE.