Writing
Agents ·Claude Code ·Hardware

Coffee Edition: a newspaper my printer prints while I sleep

Every night a Claude routine writes a two-page issue from my notes, GitHub Actions lays it out and checks every page, and a Raspberry Pi Pico 2 W prints it at breakfast. How it works, what broke in the first two weeks, and how to build your own.

18 min read
Contents

The gist

  • Three pieces, joined only by files in a git repository: a Claude routine writes the issue, GitHub Actions turns it into print files, and a Pico 2 W on the home Wi-Fi sends them to the printer.
  • The writer can be wrong, so every step after it checks: the layout must fit its columns, the PDF must carry every character, and nothing prints at the wrong hour or twice.
  • The Pico only pulls. It asks GitHub for work over ordinary HTTPS, so nothing on the internet can reach into the home network.
  • The kit is open source: a template repository, an English setup prompt for Claude Code, and a detailed guide for the Pico.

Every morning a two-page newspaper waits on my printer. Nobody at home pressed print: a Claude routine wrote it in the cloud at 05:15, GitHub Actions laid it out and checked every page, and a Raspberry Pi Pico 2 W on the Wi-Fi sent it to the printer at 08:45 (10:00 at weekends). This piece explains the three parts, the guards between them, and what broke in the first two weeks, then shows how to build your own.

Printed pages of Coffee Edition issue 001 and its RADAR page on a wooden desk, next to a cup of coffee and a laptop

Build your own

github.com/ierolsen/coffee-edition-kit

The whole setup is open source as a GitHub template: make a private copy, add your notes, and your printer gets a newspaper every morning.

Why paper

I read a lot on screens and keep little of it. A printed page is finished: it does not refresh, it has no feed under it, and it is still on the table when the coffee is ready. I can write on it. Each issue takes one idea from my notes and explains it the way a good newspaper explains one story: the big picture, how it works, the numbers, the traps, and how to use it.

The constraint that shaped everything else: the paper has to arrive without me. If I have to open a laptop to print it, it stops being a morning ritual and becomes a chore.

How a night works

One night, 14 October, from the logs. The issue started in the cloud, was refused once, was fixed within a minute, and printed on time.

05:15ROUTINEA Claude routine starts in the cloud,picks the next note, writes the issue.05:20ACTIONSFirst push. The layout check refusesit: diagram labels at 8.3 px, not 9.05:21ACTIONSShorter labels, second push. Acceptedand rendered as PWG raster.05:21QUEUEThe queue branch now holds one job,due at the print time, 06:45 then.5 minPICOThe Pico asks GitHub every 5 minutesand leaves the printer asleep.06:45PRINTERThe Pico streams 2 pages to the printer,then reports back: the log says printed.
The night of 14 October, top to bottom, in Istanbul time. Each dot is an event, coloured by who acted: green for the routine, violet for GitHub Actions, rose for a refusal, amber for the queue, teal for the Pico, the text colour for the printer. The refusal at 05:20 is the point: the first push did not print, the routine read the error and sent a fixed issue 53 seconds after the first push.

The three parts never talk to each other directly. They share one private git repository: the routine commits the issue to inbox/ on main, Actions publishes print files to a queue branch and every PDF to an archive branch, and the Pico reads queue. Every step leaves a file behind, so a bad morning can always be traced.

Why the Pico pulls

The printer sits behind my home router, and I want it to stay there. Anything that pushes a job to the printer from outside needs a way in: an open port, a tunnel, or a computer at home that stays on. The Pico avoids all of that by asking instead of listening. It makes ordinary outgoing HTTPS requests to GitHub, like a phone does, and talks to the printer on the local network.

GitHubqueue branch: the job list and print fileshome router: nothing gets in from outsidePico 2 Whome Wi-Fi, powered by a phone chargerPrinterIPP, the protocol behind AirPrint1. HTTPS, opened by the Picothe job comes back on it2. local network, 4 KB at a time
Who starts each connection, top to bottom. The dashed line is the home router: nothing from the internet crosses it inward. Arrow 1 is the only connection to the outside, opened by the Pico every 5 minutes; GitHub's answer comes back on that same connection. Arrow 2 never leaves the house. The takeaway: the printer is never reachable from outside, and the Pico's token can only read one repository and start its workflows.

Each row of the table is one way to get a job to the printer, with what it needs and why I passed on it. The bold row is the one built here: nothing at home is opened to the internet, no other service sits in the middle, and the only thing left on besides the printer is a tiny board.

Way to get a job to the printerWhat it needsWhy not here
Push from the cloudan open port on the router, or a tunnelsomething on the internet could then reach a home device
A computer at home printsa computer on all nightthe point was nothing left on but the printer and a tiny board
A vendor's cloud print servicean account and an app from the printer makeranother service in the middle, with its own rules
A board that pullsoutgoing HTTPS and the home Wi-Fino port is opened; the token reads one repository only

The newspaper engine

The two pages of a Coffee Edition issue about cognitive diagnosis of LLMs
Issue 001 of the new series, for 17 October 2026: the front page on the left, the results page on the right. Read it like a newspaper: the masthead carries the date and the print time, the article runs in three columns, and boxes such as TRY FIRST and RECALL leave work for the reader. Nothing here was typeset by hand: the routine wrote the content as JSON and the template did the rest.

The routine never writes HTML. It writes an issue as JSON: a headline, a standfirst, and a list of blocks (paragraphs, labels, steps, formulas, code, tables, bar charts, mermaid diagrams). A fixed template lays the blocks out on A4 in three 12 px columns. A block that does not fit moves whole to a new page; the type never shrinks below 12 px, and only the line spacing tightens, from 14.5 to 14.25 to 14 px, to pull back a nearly empty last page.

Then the build distrusts its own output. It opens the page in headless Chrome with fonts that ship in the repository, so every machine breaks lines the same way, and refuses the issue when:

  • a line or a box leaves its column, or the issue runs past four pages;
  • a character exists in none of the bundled fonts (the browser would silently borrow a system font);
  • a diagram's labels would print below 9 px at the width it prints at;
  • the printed PDF has a different page count, or a different number of characters on any page, than the layout. A clipped line would go missing exactly here.

Diagrams are drawn by mermaid at their print width and photographed at 600 dpi, so the PDF carries a picture the printer can render. A refused issue never reaches the queue; the first error goes back to the routine, which fixes the file and tries again.

Printing raster on a cheap laser

My printer is an HP LaserJet M111w, an entry-level black-and-white laser that accepts only raster documents: no PDF, no JPEG. Over IPP it lists image/pwg-raster, image/urf and application/PCLm. So Actions renders each PDF page in 8-bit gray and writes it in PWG framing: a sync word, a 1796-byte header per page, then each line as a repeat count and runs of pixels. Pages are rendered without anti-aliasing, because a laser can only place a dot or skip it: gray edge pixels would be halftoned into fuzz, and hard edges also roughly halve the file size.

The printer's needs live in one small file:

printer.json
{
  "formats": ["image/pwg-raster"],
  "pwg_dpi": 600,
  "keep_days": 3,
  "edition_print_time": "08:45",
  "edition_print_time_weekend": "10:00",
  "nightly": "live"
}
  • formats is what Actions prepares for each job. The Pico sends the first one the printer accepts, in the order PDF, PWG raster, Apple raster, JPEG. One format here, because this printer takes nothing else.
  • pwg_dpi is the raster resolution, in dots per inch: the pages are rendered at 600 for this printer.
  • keep_days clears jobs older than three days from the queue, so a printer that was off for a week does not print a stack of stale papers.
  • edition_print_time and edition_print_time_weekend are when an issue prints, in Istanbul time. Moving the morning paper from 06:45 to 08:45 was a change to this one line.
  • nightly switches the writer: off writes nothing, dry-run builds and archives the PDF without printing it, live prints.

The Pico up close

The Pico runs MicroPython and has no battery-backed clock, yet it must not print a paper at the wrong hour. Every answer from GitHub carries a Date header, so the Pico keeps the latest one plus the milliseconds elapsed since it arrived. Until the first answer the clock is unknown, and scheduled jobs wait.

pico/coffee_edition.py
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
 
def _days(y, m, d):
    """Days from 1970-01-01 to a proleptic Gregorian date."""
    y -= m <= 2
    era = y // 400
    yoe = y - era * 400
    doy = (153 * (m - 3 if m > 2 else m + 9) + 2) // 5 + d - 1
    return era * 146097 + yoe * 365 + yoe // 4 - yoe // 100 + doy - 719468
 
def http_date_to_unix(s):
    """'Sat, 03 Oct 2026 18:23:09 GMT' -> Unix seconds."""
    parts = s.split()
    hh, mm, ss = (int(x) for x in parts[4].split(":"))
    return _days(int(parts[3]), _MONTHS.index(parts[2]) + 1, int(parts[1])) * 86400 + hh * 3600 + mm * 60 + ss
  • s is the Date header as GitHub sends it. Split on spaces, parts[1] is the day, parts[2] the month name (its place in _MONTHS, plus one, is the month number), parts[3] the year and parts[4] the time.
  • _days(y, m, d) counts days since 1 January 1970 with plain integer arithmetic. It shifts the year to start in March, so the leap day falls at the end; era counts 400-year cycles of 146097 days; yoe is the year inside the cycle; doy the day inside that shifted year. No port-specific epoch, no library.
  • The result is Unix seconds, compared with each job's print_at. A test checks both functions against Python's own calendar on 13 edge dates and 5000 random ones.

The rest of a poll is short. Read the job list; set aside jobs already printed (a state.json on the board) and jobs not yet due, without waking the printer; find the printer (its last address first, then a quick sweep of the network, then a patient one, because a sleeping printer answers slowly); stream the file in 4 KB pieces, since the board has a few hundred kilobytes of RAM; record the job the moment the printer accepts it, so a crash can never print it twice; and start a GitHub workflow that adds "printed" to the log.

The writer and its guards

The routine's prompt is short on purpose. The real steps live in CLAUDE.md inside the repository, so improving the writer is a commit, not an edit to the routine. Fixed scripts do everything that must not depend on the model: which note is next, which date and issue number it gets, and the commit itself.

The table lists the guards in the order an issue meets them, with where each one runs and what it stops. The first three run before the issue is even pushed, the next two in Actions, and the last on the board next to the printer.

GuardWhere it runsWhat it stops
The issue is exactly the pick for its datebefore the pusha wrong note, number, date or print time
A full build in Chromebefore the pushan issue that would be refused after it
No token or key patterns, no signature linesbefore the pushsecrets in git, a commit that is not mine
Layout and PDF checksin Actionsa clipped line, a missing character, a fifth page
Not more than an hour late, one issue a datein Actionsa paper at lunch, two papers in a morning
state.json and the Date clockon the Picoa second copy, a paper before its time

When a guard refuses, the writer reads the error, fixes the issue and tries again, a few times at most. If it cannot fix it, it deletes the file and stops. Printing nothing is better than printing something wrong.

What broke, and what I learned

  • The cloud could not reach arXiv (5 October). The routine's sandbox allows a fixed list of hosts, and arXiv is not on it. Sources are now downloaded on my Mac and committed; the writer reads only local copies.
  • The Pico hung (10 October). The first video issue was queued at 05:19 and missed its time: the board had frozen some time after the previous morning's print and no longer showed up on the network. Unplugging it fixed it, and it printed at once. A watchdog is on the list.
  • Three issues were refused on their first try (11, 13 and 14 October). The cloud check did not lay the pages out, so a diagram with 6.5 px labels or a word wider than its column was only caught after the push. The routine's machine turned out to carry a Chromium, so the full build now runs before the push. I replayed the three refused versions: the old check accepted all three, the new one refuses all three with the error Actions gave. On 16 October the issue was accepted on its first push.
  • GitHub refused a push mid-run (15 October). The queue went out, then the archive push failed on GitHub's side and the run stopped before writing the log. The paper printed, but the log never heard of it. I replayed the run with its clock frozen, and every push now retries, without letting the archive block the log.
  • A good summary is not a lesson. Two weeks of well-written issues taught me less than I hoped, and jumping between topics day to day made them hard to follow. From 17 October the paper follows one thread: each issue opens with a problem to try before reading, leaves one step of an example for me, and ends with questions that come back with their answers the next morning. Saturdays are a review of the week.

A page of new methods

The paper teaches one thread, so new work needs a place of its own. Since 17 October every issue carries RADAR at the back: one to three pages of 5 to 17 new AI methods, models and tools from the night before.

At 03:30 a GitHub Action reads 23 feeds: Hugging Face's daily papers, trending papers and trending models, arXiv's new submissions in three categories, GitHub's trending repositories, 11 lab blogs, plus Hacker News and four AI newsletters, which only nominate. It stores each candidate with its text in the repository: the routine's sandbox cannot reach arXiv or most of these sites. At 05:15 the routine picks 5 to 17 of them from different areas and writes a card for each: what it is, how it works, when to use it, one number and a link to the code.

Every number on a card must appear in its source's text, and the sentence it comes from is kept in the issue file, so a check can find it there word for word. A work appears only once, as a card or in the list under the cards. If RADAR cannot pass its checks, the issue goes out without it.

The RADAR page of a Coffee Edition issue: five cards on new AI methods
The RADAR page of 17 October: five cards in two rows, and a list of five more works in the last cell. Read each card from the top: the area and a signal (upvotes on Hugging Face, or stars today), the method's name, what it is, how it works and when to use it; the boxed figure is the card's one number with what it measures; the code link and the source sit at the foot. The page is for skimming: it stays out of the recall questions, and a method worth more becomes a weekend issue of its own.

RADAR is in the kit too, off by default: "radar": true in printer.json turns it on.

Build your own

The setup is open source in coffee-edition-kit. Make a private copy from the template, put your notes in notes/, and each night the routine turns the next one into an issue. You need a Pico 2 W (tested, with MicroPython 1.28) or a Pico W (expected to work the same way), a network printer that accepts PDF, PWG raster or JPEG over IPP, a GitHub account, and a Claude plan with Claude Code routines. Mine is a Pico 2 W running MicroPython 1.28, powered by a phone charger, and an HP LaserJet M111w on the same Wi-Fi.

The fastest way in is SETUP_PROMPT.md: open your copy in Claude Code, paste it, and Claude walks you through the settings, the token, MicroPython, a self-test against a fake printer, the first print and the routine. The Pico has its own guide, from flashing the board to reading its log. The kit's sample issue is written from an open-access paper, so you can print it before you have notes of your own.

Build your own

github.com/ierolsen/coffee-edition-kit

Template, setup prompt for Claude Code, Pico guide and a sample issue you can print today. MIT licensed.

The takeaway

A paper that arrives on its own is a small system: a writer that can be wrong, a press that refuses to print when it is, and a courier that only ever pulls. Put a guard wherever a mistake can still be caught, and the printer only ever sees a page worth reading.