We have one 3D printer, a Bambu Lab H2C, and five people who want things printed. For a while the queue was a Google Doc. It held the ideas fine. What it didn't hold was everything around them: that somebody had already found a good model for the thing, that it was supposed to be purple, and that the purple ran out two prints ago. 3DPO, the 3D Printer Optimizer, is what replaced it. It's a small web app for the house: one shared queue, a queue for each of the kids, a filament inventory that keeps itself up to date, and, newest, a way to have Claude draw a part from a description. It has been running since the end of July.
3DPO's home screen: a fidget star printing with 38 minutes left, a kid's print waiting for a grown-up, the next three items in line, two filaments running short, and two recent prints.
Home for the grown-ups: what's printing, what's next, what needs you. All the screenshots here are from a fictional household.
## Status is derived, not declared The one idea that explains most of the design: nobody sets an item's status. In most kanban-style tools you drag a card to "Ready" when you think it's ready. In 3DPO an item is ready when it *is* ready, and it stops being ready the moment that stops being true. ```text idea → candidate → ready → selected → printing ⇄ printed ``` - An **idea** is a title and maybe a link. - It becomes a **candidate** when a model is picked. - It becomes **ready** when every color the print needs has a filament assigned. - It becomes **selected**, cleared to print, when those spools are actually on the shelf with enough left on them, and somebody has picked a build plate. Printing and printed are things that happen in the real world, so those are set by a person, or by the printer itself (more on that below). Everything before them is recomputed from the item's own data on every change. Raise the number of colors a print needs and it drops back to candidate on its own. Every move is written down, so an item's history can answer "why is this back at candidate?" The gate that earns its keep is the shelf. 3DPO mirrors Bambu's cloud Filament Manager, which tracks every spool in grams and subtracts after each print. When a print's slicer profile says it needs 63 g of a color, the item clears only if a spool of that color has 63 g left. It runs backwards too: a print that empties a spool overnight quietly sends every item that needed it from selected back to ready. The design doc has a line for the morning that first happens: "An item un-readying itself overnight is this working." A person still outranks the rule. Pressing **Filament check** means "I looked at the shelf," and no sync will ever take that back. The same subtraction drives the Supply page: what the queue needs, minus what the shelf has, rounded up into spools to order, with a link to each one's product page. Both the gate and the shopping list read one SQL view, so they can't disagree about whether we're out of purple.
The print queue as a table: each item with its picture, status, place in line, hours of printing ahead, color swatches, and due date.
The queue. Each row says where it's stuck and how many hours of printing are ahead of it. It never promises a time of day; the printer doesn't keep a schedule.
## The kids' queues The kids file their own ideas. A child's item works through the same gates but stops at ready, held there until a grown-up promotes it into the main queue. Promotion is the handover: after that it belongs to the household. The small decisions around the kids were the most fun to make: - **Hide, don't disable.** A grayed-out button with a tooltip is for a gate somebody can go and meet. A six-year-old can't go and become a grown-up, so for them the button isn't there at all. - **Surprises.** The kids can see each other's queues, because they asked to. An item marked as a surprise is invisible to every child except the one who filed it, in every list and by direct link. A sister who guesses the link gets a 404, not a 403, because "forbidden" would confirm the present exists. The place-in-line numbers skip it too, so a gap can't give it away. - **Three registers of wording.** One for grown-ups, one for a child who reads fine but has never heard the word "filament," and one for the youngest, who is still sounding words out. The pictures are big because a six-year-old recognizes the dinosaur before the word. - **Filing for someone who can't type yet.** A grown-up on a kid's tab can add an item as that kid. Their own name appears nowhere on it. - **Avatars are assigned, not chosen.** It's the one thing on a kid's account the siblings can see, and the app declines to host that argument.
The kids' queues: a tab per child with an emoji avatar, and two big picture cards, a name tag and a star, each with its colors and an Add to the queue button.
A tab per kid. The star has its colors and is waiting at ready for a grown-up; the name tag still needs its second color.
## Talking to the printer Bambu's cloud API is unofficial, so 3DPO treats it like the weather: read only, polled every five minutes, and allowed to fail. If Bambu is down, the mirror goes stale instead of erroring, and the printer stays in ordinary cloud mode. 3DPO never dials into the house. From the printer's task history it notices when a print starts and when it finishes. If exactly one cleared item can be the job on the plate, that item moves to printing, and later to printed, through the same guarded path as the buttons. Anything less certain becomes a question for a grown-up instead of a guess. The API has opinions. In October our account passed 50 spools and the sync quietly stopped finishing. It turns out the spool list ignores `offset` and `limit` entirely: ask for page two and you get page one again, forever. The fix walks the list by what's new instead. Timelapses are the one piece that doesn't run in the cloud. A small Go program on the Synology copies finished videos off the printer over FTPS and reports each one to the app, which attaches it to whatever was printing at the time. It has been archiving real videos since July; turning it loose on a schedule is going in stages, each one adding a single thing that can't be undone, like deleting from the printer. ## Parts drawn by Claude This is the newest piece, switched on in production on October 19. Some of what a household prints isn't on Makerworld: a hook that fits one particular shelf, a box for one particular drawer, a bracket with two holes at a measured spacing. Those are described in numbers, and a parametric CAD script is the honest way to represent them. The dimensions are variables, and "make the lip 2 mm taller" is a one-line change. So an item can carry a brief. Press **Generate** and the app starts a background job, a container that exists only while it works. The job hands the brief to Claude with exactly one tool: run this script. Claude writes Python against [build123d](https://github.com/gumyr/build123d), the job runs it and answers with a report (size, volume, whether it's a valid solid, whether it fits on the plate) and four rendered views. Claude looks at those, fixes what's wrong, and checks the result against the brief before it answers. What comes back is a numbered revision: the script, a preview, and STEP and STL files. Ask for a change and you get revision 2. When one is right, **Use this one** makes it a model in the catalog like any other, and the item walks the same gates as a link pasted from Makerworld. Claude never touches the queue; it writes a script, and the app decides what that means. Here's the top of the script for a pantry hook. Every number in the brief became a named constant: ```python BOARD_T = 19.0 BOARD_GAP = 19.5 # vertical room for the board under the top wall SADDLE_DEPTH = 40.0 # outer, front face of leg to back end of top wall T = 3.0 # wall thickness everywhere DROP_BELOW = 60.0 # leg/cradle bottom below shelf underside HANDLE_D = 25.0 SEAT_W = 25.5 # inner width of half-round seat ``` In the trial runs before building it, a revision took one to two minutes and cost between six and twenty-five cents. The best result of those runs was a refusal. The hook's brief, as written, had a back leg that would have had to pass through the shelf. Claude built everything else, left the leg off, and declined to call it done until someone said what was meant. The cheaper model, for a quarter of the price, built the impossible leg and confirmed it.
A design revision: the brief for a pantry broom hook, a rendered orange J-shaped hook, and Claude's reply explaining what matches the brief and why the back leg was left out, with STEP and STL downloads.
The hook's first revision. The brief, the render and Claude's reply are the real ones from the trial runs; the page around them is the fictional household.
After that it's back to the ordinary path: slice it in Bambu Studio, upload the sliced file, and its time, grams and colors become the design's print profile, so the shelf check and the shopping list use our own numbers. ## The small stuff - **It installs like an app.** On Android it shows up in the share sheet, so **Share → 3DPO** from the Makerworld app files a new item with the link already filled in. iOS doesn't give web apps a share target, so on an iPhone it's copy and paste. - **A phone gets the button that matters,** pinned to the bottom of the screen. - **When something on screen finishes printing, it says so:** "All done — 5h 12m, 94 g." - **Birthdays and holidays decorate the page**, unless the device says not to. Reduced motion is honored, there's no sound, and nothing springs on anyone in the middle of a task. - **Push notifications are built.** A kid hears their print is done; the grown-ups hear when a kid's idea is ready for them or the printer has a question. They're waiting on me to run one command.
An item on a phone: the pantry hook's picture, its place in line, the status rail at Selected, and a Start print button pinned to the bottom of the screen.
An item on a phone.
## Under the hood - **Three pieces.** A Go API that owns every rule, a Next.js client that holds none, and one Postgres database. The browser never calls the API directly: the client's own server proxies it and keeps the session in an httpOnly cookie, which is also why the API itself can stay private. - **One lock per item.** Every change to an item runs in a single transaction holding a row lock: apply the change, re-read what the gates depend on, write the new status and its history entry together. Two people editing the same item take turns instead of racing. - **Plain SQL.** Go's standard library for HTTP, pgx and hand-written queries for the database, no ORM. Migrations are embedded in the binary and applied at startup, so deploying the code is deploying the schema. - **The API spec is a contract.** A test walks the real router and fails if it and the OpenAPI spec disagree; the client's types are generated from the spec; another test holds the spec to the Go structs. - **Hosting.** Two Cloud Run services and a small VM running Postgres, shared with [Todos](/projects/todos/), for about $17 a month. No load balancer, no managed database. Merging to `master` deploys. - **Size.** About 24,000 lines of Go, 26,000 more of Go tests, and 38,000 of TypeScript, in roughly 735 commits since July 25. ## Built with Claude Code Most of the code was written by Claude Code. My part is closer to product owner and reviewer: I say what I want, Claude writes a plan with the open questions listed as "calls," I make the calls, and it builds. That only works because the repository is set up so a fresh session, with no memory of the last one, can find its way around and prove a change is safe: - **`AGENTS.md` is the map:** where each kind of truth lives, the commands, and the conventions a wrong guess would cost a review cycle. It points rather than repeats. - **`docs/PRINCIPLES.md` distills the product rules**, like "children: hide, don't disable" and "a person outranks the rule," so nobody has to read the history to avoid breaking one. - **Every shipped feature gets a log entry**: the plan as built, what was decided and why, what was deliberately left out, and how it was checked. There are 91 of them. - **Contract tests turn "remember to update X" into a failing build:** the routes against the spec, the migrations against the schema snapshot, the snapshot against its diagram. - **A separate list holds what only I can do**, the things that need a phone in my hand or a production change made by me. The comments explain why, in prose. That's the house style, and it's what makes the code readable cold, whether the reader is me or the next session. ## Where it's going - **A photo of the finished thing** on each printed item, the last feature in the current batch. - **What a print actually used.** Bambu reports each job's weight as sliced, not as measured; a job cancelled nine seconds in still claims all 111 g. So the supply forecast is the slicer's estimate all the way down, and I'd like it not to be. - **More than one printer, and maybe more than one family.** A few people I know have Bambu printers, and the setup would stretch. - **The timelapse job's last stages**, and push notifications, once I run that one command.