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.
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 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.
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.
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.
## 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.