Bad listing photos in, professional ones out — one photo at a time.

There is no step where anyone decides a photo is bad enough to be worth fixing. If it reached the CRM and has never been mastered, it goes in the queue — 106 photos are in it today. They come out one at a time, because the only slow part is a human looking at each one.

Master takes a photo a host shot on a cheap phone and gives back the photo a professional would have taken in the same room. One rule beats all the others: same place, same reality. The picture gets better; the property never changes. Nothing added, nothing hidden, no room made bigger or newer than it really is.

What each step means

StepIn plain words
A home arrivesThree ways in, all of them ending in the CRM, and today they are very unequal. Scraped — 121 of the 148 homes on the board came from Airbnb; that is how most of the inventory got here. Found in person — someone stands in front of a property and types what they see into Slack #mkan, in whatever words come naturally. A reader picks the message up within two minutes, turns it into a home and a host, and replies in the thread with what it understood so it can be corrected. From the website — a host signs up on mkan.sd and adds their own home; it appears on the board within the hour, host and photos too. (A further 26 homes were typed in by hand long ago — the real owners we know personally. That was a one-off, not a lane.)
The count so farScraped 121 · found in person 1 · from the website 0. The Slack lane produced its first home this morning — 0005-01 · الطيب, with the host's phone number. The website lane started running today and is waiting for its first host.
Every photo joins the queueOnce an hour, every photo on every home in the Port Sudan book that has never been mastered is added. No opinion required, and asking for one was the mistake: almost every photo we hold is an anonymous file nobody has looked at. Adding to the queue is silent — nothing is sent to anyone, nothing is decided.
The book is the scopeThe portSudan object in the CRM — 34 homes with a listing code, the inventory actually being worked. It is a question of membership, never of quality: being in the book is the whole qualification. A home added to it is swept within the hour; one removed takes its untouched queued photos with it.
Adding a photo laterIf the host sends a photo the website doesn't have, drag it onto their home in Twenty and it joins the queue within seconds. Only ever drop in a photo that is genuinely new — dropping in one the website already shows adds it, and you end up with the old and the new side by side.
It waits its turnOne photo at a time leaves the queue and is handed to you in Slack. That number is the whole throttle, and it is one because a person can only look at one photo at a time.
Its own jobOne photo, one try, one row. The exact words sent to ChatGPT are saved on that row and can never change afterwards, so you can always see what a photo was asked for. If a better prompt ships while a photo is still waiting, the old job is retired and a fresh one takes its place — nothing is quietly rewritten.
YouThe photo is put on your clipboard and ChatGPT opens. Paste, enter, look, save. You never type the words — they're already in the ChatGPT project. About twenty seconds.
Same place, right room?Two questions. Nothing invented or hidden — and is this really the room it says it is. The very first photo that came back showed a different room, and only a human eye caught it.
LiveThe new photo takes the old one's place, gets a proper name — mkan/0001-01/bedroom-1.webp — and is served from the CDN. The original is never deleted; it keeps its old address forever.
It tries againSaying no needs a reason. That reason is the only thing that makes the next round better.
Nothing goes quietEvery morning at 10:00 a check tells Slack about anything wrong: a photo sitting with a human too long, a photo that quietly fell off a listing, a saved photo nobody read, or the queue not moving at all. A long queue is normal and is never reported — only a stopped one.

Every step is live, end to end — the last mile included: the scripts write to the CDN with this machine's own AWS key rather than the app's, which is scoped to host uploads and could reach neither the temp path nor the proper name. What is not proven yet is how many photos survive the check — see the measured trace below.

The parts

Seven parts. Each one does a single thing:

PartWhat it does
Twenty CRMwhere you put the photo in
The scripts (mkan)do all the boring work for you
The databaseremembers every photo and every try
ChatGPT / Geminimakes the better photo
Your inbox folderwhere you save the new photo
The CDNkeeps all the photos, old ones too
Slack #mkantells you what is going on

Set ChatGPT up once

The single biggest thing that makes this easy is a ChatGPT Project holding the standing instructions. Do it once and it pays on every photo forever.

pnpm master:prep --setup-chatgpt   # prints the text to paste

ChatGPT → Projects → New project → name it masterInstructions → paste what that command printed.

Why it matters so much:

  • The prompt stops travelling. It is saved in the project, applied to every image you send, so there is nothing to copy, paste or retype per photo.
  • That frees the clipboard for the photo. ChatGPT's Mac app cannot be handed an image file from the command line — it declares no image file types, so open -a ChatGPT photo.jpg does nothing. Pasting is the only way in. With the prompt standing, master:prep --standing puts the photo on your clipboard instead, and the job becomes ⌘V, Enter, save.
  • Every photo gets the identical prompt. No drift between photo 1 and photo 40, which is what makes the output consistent enough to trust without a separate review step.
  • It ends the questions. The instructions finish with "Return only the transformed photograph. Never ask follow-up questions" — so ChatGPT answers with an image, not a conversation.
  • It is the same trick anywhere. Gemini calls it a Gem, Claude calls it a Project. The standing instructions are identical; only the menu differs.

The one thing the project cannot know is which room it is looking at — that varies per photo, and it comes from the file name.

Naming the photos

Name each photo after its room, in plain lowercase English:

bedroom.jpg   bathroom.jpg   hall.jpg   kitchen.jpg   living-room.jpg

That name is read as a room hint and added to the prompt, so the generator knows it is lighting a kitchen and not a bedroom. Three rules, all of them tested:

DoDon'tWhy
bedroom.jpg0001-01-bedroom.jpgA name starting with digits is thrown away as a hint — it looks like a machine id, not a room.
bedroom-2.jpg for the second onebedroom2.jpgWith a dash the number is trimmed and the hint is still bedroom. Glued on, the whole name is rejected.
living-room.jpgIMG_4821.jpgDashes become spaces, so the hint reads living room. Camera names carry no room at all.

You never need to put the listing in the file name. The photo is dropped on a home record in Twenty, so the CRM already knows which home and which listing it belongs to. Your 0001-01 scheme identifies the listing; the file name only has to say the room. If you keep them in folders, keep the id on the folder — 0001-01/bedroom.jpg works, because only the last part is read.

State truth lives in mkan Prisma (MasteringRun — absence is the ORIGINAL state; retries append, history never overwritten). Twenty's home is the ops mirror (photoStage grew MASTERED, plus photosMastered / lastMasteredAt). The CDN is canonical and originals are immortal. Operator runbook: mkan/docs/image-mastering.md.

What a photo is called

original   cdn.databayt.org/mkan/temp/0001-01/hall.jpg
mastered   cdn.databayt.org/mkan/0001-01/hall.webp

The folder is the listing's own public code — the same NNNN-NN the CRM, the WhatsApp outreach and mkan.sd already share. The name is the room. An original never gets that name: it waits in temp/ under whatever the human called it, because the clean path is what mastering earns — the URL alone tells you whether a photo has been through the pipeline.

Three rules hold it together:

  • The name is allocated, never assumed. A second bedroom and a second attempt at the first one both become bedroom-2, and a freed name is never reused. That is not tidiness: overwriting bedroom.webp would let the CDN keep serving the reverted photo for a day — the exact image the revert existed to remove. Because keys can never be rewritten, new mastered objects are cached immutable.
  • The room comes from evidence, in order: what the operator said, then the original's own filename, then photo-N — which is honest about knowing nothing rather than guessing "bedroom". 1086 of 1095 photos are uuid-named today, so most of the corpus starts on that last rung.
  • Forward-only. Nothing already stored moves; the ugly URLs die as photos pass through.

Nothing gates it. Both of these paths sit outside mkan/uploads/*, where the app's own S3 key is scoped and correctly stays — the deployed app writes host uploads and nothing else. The mastering scripts run on one Mac, under a person, so they use that machine's AWS profile, the same way they already take Twenty's key from the Keychain and Slack's from Hermes' env. Widening the app's key would have granted the deployed app a permission it never uses.

Nothing new became deletable in the trade: the delete guard in s3.ts keys on the uploads/ prefix, not on who is asking. And if master:done ever does hit a permission wall it refuses with the fix and leaves the run untouched, rather than burning a render someone already made.

What it is worth — the pipeline against doing it by hand

Doing this by hand means: open ChatGPT, paste the photo, paste the prompt, save the render, open the host photos page, upload it, delete the old one. It works. It is also how 779 photos quietly become nobody's job.

By handThis pipeline
Human time per photo~2 min — mostly finding the photo and re-pasting the prompt~20 s — master:prep --standing puts the photo on the clipboard; ⌘V, Enter, save
Generatingthe humanthe human (identical — this is the whole ceiling)
Which photo is this?memorythe run id, the room hint, and the original revealed beside the render
A bad photoyou remember, or you don'trevert puts the original back and records why
What prompt made this?unknowable a week laterfrozen on the row, enforced by the database
A render nobody ingestedlostreported daily until someone acts
Undo, six weeks laterthe original is goneoriginals are immortal; master:revert is one command

Read the table honestly: on speed the pipeline wins a little, and only because the prompt stopped travelling. The generate step is human in both lanes, so the ceiling is the same — which is why the API flip, not more plumbing, is what changes the arithmetic. What the pipeline actually buys is memory and reversibility: every photo has a name, a reason, an original, and a way back. That is what makes it safe to point at 779 photos instead of five.

Progress — the real trace (measured 2026-08-26)

Live truth is pnpm master:status; this is what five days of it say.

ListingPhotosState
#1051 Kobar Residence 4 (acceptance batch)51/5 mastered — photo 2 UPDATED end-to-end and still live; photos 1, 3, 4 QUEUED; photo 5 rejected to trim the batch
#1180 جناح تنفيذي — السكة حديد (heirs set, room-named photos)90/9 live — photos 1 and 2 were applied on 08-25 and both reverted the same day; photo 1 is on attempt 2, the rest QUEUED

16 runs · 3 photos ever applied · 2 reverted · 1 net live photo · 779 in the backlog. The wiring is done and the loop has run end to end; what has not been demonstrated is yield. Two of the three photos that reached the live site came back down within days, and neither revert recorded a reason — so whether prompt v2 fixed what v1 got wrong is, in the data, unanswerable.

What is actually blocking, in order:

  1. Yield, not plumbing. 2 of 3 applied photos were reverted. Until a batch of four or five survives a day, queueing the other 112 listings would just manufacture work to undo. The fix shipped with this pass is small and procedural: master:revert now demands a reason, the way master:reject always has.
  2. Throughput is a human, and the arithmetic is unkind. One net live photo in five days against 779. Even at the tool's best pace — ~20 s of attention per photo, which the standing-prompt trick really does deliver — the backlog is a full day of someone doing nothing else, spread over however many days that day gets split into. The whole backlog on the API lane prices at ~$30 (legacy Nano Banana; $79 flash-2K, $134 pro-2K). That decision is on record as deferred, and it is the only lever that moves the ceiling.
  3. Slack read scopes — resolved. Verified live 2026-08-26: the kun bot holds groups:history + files:read, and both conversations.history and files.list answer on C0BS2NZE2AY. Every doc claimed this was still blocking; it has not been for days. The phone lane works.
  4. The queue→dispatch gap. queue and pull create runs; dispatch hands them out; nothing connects the two. Six runs sat QUEUED with no Slack task — five of them for 45 hours — while the stall clock, which only looked at a seven-day QUEUED threshold, said "all clear". Reconcile now flags an undispatched run after a day.
  5. The record was rewritten from outside. Three dispatched runs had their frozen prompt rewritten v1 → v2 on 2026-08-25 while their Slack tasks still quoted v1, and five runs show a status the scripts cannot produce (QUEUED, but carrying a Slack task — dispatched, then reset). Nothing noticed. A Postgres trigger now refuses to let a run's prompt change, and reconcile reports impossible states.

The backlog, measured (master:census, 147 listings): 112 listings / 779 low-quality photos ready to queue, 9 OK, 26 with no photos at all (24 of those published — a product gap outside this pipeline), 0 needing re-hosting.

Build ledger

All on mkan main, 2026-08-22 → 26:

CommitWhat landed
01f2efeThe loop: MasteringRun schema + master:queue/dispatch/done/reject/revert/status/reconcile, prompt v1 frozen per row, Twenty fields applied live, #mkan cockpit
7fd17d8 fa0f79bHonest verify URLs (busy listings 404 publicly); channel renamed to #mkan with the bot as member — dispatch unblocked
44863c9Twenty creds from Keychain on every apply (first real apply had skipped the rollup)
b4da4e3master:prep — prompt→clipboard, original→Finder, Gemini open: the no-spend, no-ToS floor
3cc84degift-handover.ts (funnel session): mastered photos become the host-outreach gift, read live from UPDATED rows
b0b6e0d 2c2246bHeirs photo sets named by room; filename room hints ground the prompt
9433b7fThe Slack return lane (--from-slack): thread attachment → run-id text → sole-waiting fallback — built because renders happen on the phone
457ca10Production hardening: pure core + 10 pinned invariant tests (incl. a real UUID→"room" prompt-leak bug), race-guarded state claim, 🫥 DRIFTED detection, launchd stall clock (daily 10:00), retries keep room hints, cache identity check, dispatch --repost
2c52245Prompt v2 — four loopholes v1 left open, closed (preservation beats the 4:3 ratio, no fabricated light, no geometry that enlarges a room, no fake HDR)
0b48968 50c7e83A mastered photo gets the URL a human would have chosenmkan/0001-01/bedroom.webp, folder = the listing's public code, name = the room; originals wait in temp/ under their human name; names allocated and never reused so a reverted photo cannot be served from a recycled key; mastered objects written immutable
553c986The loop stops losing work it already paid for (2026-08-26): a render made while the machine was still re-queueing is no longer refused — a dropped file is judged against its PHOTO's history, not the attempt in flight (~/Downloads keeps the strict bar); revert demands a reason; the frozen prompt is enforced by a Postgres trigger instead of a comment; reconcile sees undispatched runs, impossible states, and renders stuck in the inbox — eleven on its first run

Decisions on record

  • State home: mkan Prisma, not Twenty — Twenty mirrors, the app's array is what the site serves (settled by evidence, 2026-08-22).
  • Generate lane: human via a chat app while API billing is deferred — the model is per-run and the default is now chatgpt-image-2; the flip (master:auto) changes only the generate step, never the state machine or the human gate.
  • No browser/desktop automation of Nano Banana: no desktop app exists; driving the web app is possible but declined — spec-forbidden, main-Google-account ToS risk, fragile. Supervised prototype only on explicit override.
  • Stall clock is deterministic shell (launchd, daily 10:00), not an LLM cron.
  • Cockpit: private #mkan (C0BS2NZE2AY); the posting identity is the workspace kun bot (Hermes' token).

Next

Finish #1051 (three master:prep walks) → reject notes settle prompt v1 → v2 → queue #1127 (host 1004) and #1161 (host 1006), whose UPDATED rows light the gift line in host outreach → then Phase 2 (/admin/mastering grid fed by the census photo cache, Twenty Kanban auto-queue, Hermes operator skill) and Phase 3 (the billing /decide, approve-only human gate).