{"slug": "show-hn-my-receipt-printer-prints-an-original-artwork-every-morning", "title": "Show HN: My receipt printer prints an original artwork every morning", "summary": "A developer created MorningPrint, a system that uses a Raspberry Pi Zero W and an Epson thermal receipt printer to print a unique piece of character art every morning, designed by Anthropic's Claude AI. The art is themed to the day's weather, season, and news, and is rendered as ESC/POS bytes from a declarative spec, with the entire cloud backend running on a single Google Apps Script. The project, posted on Hacker News, showcases a creative application of generative AI and aims to replace phone feeds with a tangible daily artifact.", "body_md": "One original print, every morning.\n\nA 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.\n\nTwo 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.\n\nThis 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.\n\nThe cloud half is a single Google Apps Script. The only hardware on my side is\na Raspberry Pi Zero W running a small Python bridge (systemd plus an ngrok\ntunnel; runbook in `docs/`\n\n) that pipes bytes into the printer's USB port.\n\n```\ngraph LR\n  subgraph cloud[\"Google Apps Script · daily trigger\"]\n    A([\"daily brief\"]) --> B[\"Claude designs<br/>an art spec\"] --> C[\"render to<br/>ESC/POS\"]\n  end\n  subgraph kitchen[\"the kitchen\"]\n    D[\"Pi Zero W\"] --> E([\"Epson prints<br/>and cuts\"])\n  end\n  C -- \"POST · ngrok ·<br/>basic auth\" --> D\n```\n\n**Context.**`printDailyArt()`\n\n(`src/art.ts`\n\n) 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.\n\nThe 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:\n\n| Trick | How |\n|---|---|\n| Tone / gradients | `░ ▒ ▓ █` ramps across rows |\n| Sub-character edges | half-blocks `▀ ▄ ▌ ▐ ■` for silhouettes |\n| Solid black fields | inverted (white-on-black) runs; even spaces print solid |\n| Seamless shapes | line spacing set to exactly one glyph height, so rows tile |\n| Giant type | independent width/height scaling, 1–8× each |\n| Fine texture | the printer's second font: 9×17 glyphs, 64 columns |\n| Structure | full single/double box drawing, `° · ∙ ≈ ∞ π Σ` and friends |\n\nThe \"seamless shapes\" row is the trick that makes block art possible at all:\nby default the printer leaves a white seam between text lines, but `ESC 3`\n\nwith the right value makes `░▒▓█`\n\nrows fuse into continuous fields. The value\nwas calibrated empirically with a built-in test page. That page, and every\nother byte this project sends, is documented in\n[ docs/escpos-protocol.md](/matt-w-horn/morningprint/blob/main/docs/escpos-protocol.md).\n\nThe model doesn't emit bytes; it emits a declarative spec the renderer executes. Abbreviated:\n\n```\n{\n  \"title\": \"GOLDEN RUN\",\n  \"caption\": \"Test pattern: gradient, silhouette, invert, scale, texture.\",\n  \"verse\": \"The mountains hold their breath;\\nthe sun tries every shade of gray\\nbefore committing to gold.\",\n  \"style\": \"calibration plate; gradient + silhouette + type specimen\",\n  \"continues\": \"\",\n  \"ops\": [\n    { \"text\": \"░░░░…\\n▒▒▒▒…\\n▓▓▓▓…\", \"gapless\": true },\n    { \"text\": \" DAWN \", \"width\": 2, \"height\": 2, \"bold\": true, \"invert\": true },\n    { \"text\": \"every feature · one receipt\", \"font\": \"B\", \"align\": \"right\" }\n  ]\n}\n```\n\nOnly the artwork and the verse are printed. The rest are archive fields: the\ntitle and style land in a rolling `ART_HISTORY`\n\nproperty that the next day's\nprompt receives as a list to differ from; the caption goes to the execution\nlog only. That pressure keeps a\ndaily generative loop from collapsing into the same sunset every morning, and\nthe prompt pushes rotation across the whole space: landscapes, geometric\nabstraction, pattern studies, giant-type posters, constellation maps, weather\nglyphs, emblems, diagrams.\n\n`continues`\n\nis the deliberate exception. It stays empty almost every day, but\nwhen the day itself hands the model a thread (the Fourth of July arriving\nafter its eve, an event still unfolding, a tenth anniversary), the model may\nbuild on one recent piece and record which. Those links show up as markers in\nthe history it reads on later days, and a fresh marker raises the bar for the\nnext one. That is what keeps continuations a surprise instead of a habit.\n\nThe renderer treats the spec as untrusted: sizes are clamped, rows are\ntruncated to the column budget, control characters are stripped, output is\ncapped at 150 rows (~45cm of paper), and anything CP437 can't print becomes a\nvisible-but-harmless `?`\n\n.\n\nStructured 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.\n\nA few API notes, for the curious: the art spec comes back via structured\noutput (`output_config.format`\n\nwith a JSON schema), and web search runs as a\nserver-side tool, so the client just resumes on `stop_reason: \"pause_turn\"`\n\n.\nThe local harness enables the server-side fallback beta (another model\nre-serves a refused request); the production path does not, so a refusal there\nfails loudly to the alert email. Details in `CLAUDE.md`\n\n.\n\n**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`\n\n).**Bridge:** Raspberry Pi Zero W running a ~40-line Python`http.server`\n\nthat writes request bodies straight to`/dev/usb/lp0`\n\n, 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`\n\n\", and the runbook covers the rest.\n\nThe full byte-level protocol (every command, the CP437 mapping, the geometry\nand calibration results) is specced in\n[ docs/escpos-protocol.md](/matt-w-horn/morningprint/blob/main/docs/escpos-protocol.md). The Pi's setup,\nmaintenance, troubleshooting, and disaster recovery live in\n\n[.](/matt-w-horn/morningprint/blob/main/docs/pi-print-server-runbook.md)\n\n`docs/pi-print-server-runbook.md`\n\nYou need an ESC/POS printer, something that can pipe bytes to its USB port\n(here, a Pi Zero W; see [Hardware](#hardware) and the\n[runbook](/matt-w-horn/morningprint/blob/main/docs/pi-print-server-runbook.md)), an ngrok static domain, an\nAnthropic API key, a Google account for Apps Script, and Node for the build.\n\nTypeScript under `src/`\n\nis the source of truth; esbuild bundles it to a single\n`dist/main.gs`\n\n, which [ clasp](https://github.com/google/clasp) pushes to Apps\nScript.\n\n```\nnpm install        # dev tooling: clasp, typescript, esbuild, prettier\nnpm run build      # tsc --noEmit + esbuild bundle -> dist/main.gs\nnpm run status     # list the files clasp would push (dry check)\nnpm run push       # build, then clasp push (uploads dist/)\n```\n\nThe Apps Script web editor is a mirror, not a second source of truth: edit\nlocally and push. `npm run pull`\n\nfetches remote back down if the editor was\ntouched directly.\n\nFirst-time setup on a new machine: `npx clasp login`\n\n(writes `~/.clasprc.json`\n\n),\nthen `npm run push`\n\n. The committed `.clasp.json`\n\nbinds to my script; for your\nown deployment, run `npx clasp create --type standalone --rootDir dist`\n\n(or put\nyour own `scriptId`\n\nin `.clasp.json`\n\n) before the first push.\n\nNo secrets live in the repo. Runtime config is read from **Script Properties**\n(Apps Script editor → Project Settings → Script Properties):\n\n| Property | Required | What it is |\n|---|---|---|\n`PI_URL` |\nyes | ngrok HTTPS URL of the Pi print bridge |\n`NGROK_USER` |\nyes | basic-auth username for the tunnel |\n`NGROK_PASS` |\nyes | basic-auth password for the tunnel |\n`ANTHROPIC_KEY` |\nyes | Anthropic API key (`claude-fable-5` ) |\n`EMAIL_ALERTS_TO` |\nno | where failure alerts are emailed (rate-limited) |\n`GEMINI_KEY` |\nno | Google API key for the Weather API |\n`LAT` / `LON` |\nno | location for weather context |\n\nWeather is garnish, not a dependency: without `GEMINI_KEY`\n\n/`LAT`\n\n/`LON`\n\nthe\nart still prints, just uninformed about the sky.\n\nState keys managed by the script itself (no setup): `LAST_ART_DATE`\n\n(one art\nprint per day), `ART_HISTORY`\n\n(the rolling archive fed back into the prompt,\nwith markers on pieces that continued an earlier one), and `LAST_ALERT_TIME`\n\n(rate-limits alert emails).\n\nCreate one time-driven trigger on `printDailyArt`\n\n(Apps Script editor →\nTriggers). A morning hour works well. An hourly trigger is also safe: `LAST_ART_DATE`\n\nlimits it to one print per day, and because the guard is only set on success,\nextra runs double as retries after a failure.\n\n`testDailyArt()`\n\nprints the golden test plate (no API call) to verify the\nhardware path. Set `DRY_RUN = true`\n\nin `src/art.ts`\n\nto log specs instead of\nprinting.\n\n`test-print.mjs`\n\nsends ESC/POS straight to the Pi bridge (the same endpoint\nApps Script hits), so you can iterate without deploying or waiting on a\ntrigger. The `art`\n\nmodes load the real builders from the built `dist/main.gs`\n\n,\nso run `npm run build`\n\nfirst; the preview then matches production exactly.\n\n```\ncp .env.example .env            # then fill in PI_URL / NGROK_USER / NGROK_PASS\nnpm run build                   # needed for modes that load dist/main.gs\nnode test-print.mjs hello       # minimal \"SYSTEM ONLINE\" connectivity test\nnode test-print.mjs text \"Hi\"   # arbitrary text\nnode test-print.mjs ruler       # column + gapless-spacing calibration page\nnode test-print.mjs art         # golden art spec through the real renderer (no API)\nnode test-print.mjs art:live    # LIVE end-to-end art (ANTHROPIC_KEY in .env)\nnode test-print.mjs art --dry   # print the hex payload instead of sending\n```\n\nThis script is local only; it isn't bundled into `dist/`\n\nor pushed to Apps\nScript.\n\nClone the repo, `npm install`\n\n, and start [Claude Code](https://claude.com/claude-code)\nin the root; it picks up [ CLAUDE.md](/matt-w-horn/morningprint/blob/main/CLAUDE.md) automatically, which carries\nthe architecture, the deploy ritual, and the byte-level gotchas. You can iterate\non the renderer with no hardware at all:\n\n`node test-print.mjs art --dry`\n\nshows\nthe exact ESC/POS bytes a change produces. The longer version, including a good\nfirst prompt to hand Claude, is in [.](/matt-w-horn/morningprint/blob/main/CONTRIBUTING.md)\n\n`CONTRIBUTING.md`\n\n```\nsrc/\n  appsscript.json   manifest (V8, America/Los_Angeles)\n  escpos.ts         CMD command table, column constants, encodeCP437\n  art.ts            the daily art job: prompt, schema, renderer, Anthropic client\n  transport.ts      sendToPi (HTTP → Pi bridge), retries, alert emails\n  weather.ts        Google Weather API fetcher (context for the prompt)\n  main.ts           entry points re-exported for the build footer\nbuild.js            esbuild bundle → dist/main.gs\ntest-print.mjs      local print harness (talks to the Pi directly)\ndocs/\n  escpos-protocol.md         the full byte-level protocol spec\n  pi-print-server-runbook.md the Pi side: setup, ops, troubleshooting\nexample/\n  example_1.jpeg             the Liberty Bell piece, fresh off the printer\n  example_2.jpeg             the Jul 3 -> Jul 4 sequence, pinned side by side\n```\n\n`clasp`\n\nuploads only `dist/`\n\n(`.clasp.json`\n\n→ `\"rootDir\": \"dist\"`\n\n), which is\ngitignored.\n\nA personal project by Matt Horn, developed on personal time using personal\nequipment and personal accounts. Not affiliated with, sponsored by, or\nendorsed by Google, Anthropic, or any past or present employer. Views and\ncontent are my own. Copyright (c) 2026 Matt Horn. MIT-licensed; see\n[LICENSE](/matt-w-horn/morningprint/blob/main/LICENSE) and [NOTICE](/matt-w-horn/morningprint/blob/main/NOTICE).", "url": "https://wpnews.pro/news/show-hn-my-receipt-printer-prints-an-original-artwork-every-morning", "canonical_source": "https://github.com/matt-w-horn/morningprint", "published_at": "2026-08-05 19:16:43+00:00", "updated_at": "2026-08-05 19:36:58.573297+00:00", "lang": "en", "topics": ["generative-ai", "artificial-intelligence", "ai-products", "ai-tools"], "entities": ["Anthropic", "Claude", "Raspberry Pi Zero W", "Epson", "Google Apps Script", "Google Weather API", "Hacker News"], "alternates": {"html": "https://wpnews.pro/news/show-hn-my-receipt-printer-prints-an-original-artwork-every-morning", "markdown": "https://wpnews.pro/news/show-hn-my-receipt-printer-prints-an-original-artwork-every-morning.md", "text": "https://wpnews.pro/news/show-hn-my-receipt-printer-prints-an-original-artwork-every-morning.txt", "jsonld": "https://wpnews.pro/news/show-hn-my-receipt-printer-prints-an-original-artwork-every-morning.jsonld"}}