Every draft you've ever generated. Star the ones that worked — they get sampled into future draft generations as voice examples, which is how the tool learns to sound like you over time.
Completed and signed off a Phase A product-analytics tracking plan — a concrete milestone of deciding *what* to measure before building the measurement.
This week I shipped a document, not code. And it took more decisions than I expected. The context: I'm building a property reconfiguration app, and I want to add product analytics — a usage dashboard that shows which features people actually use. Before touching the SDK, Claude and I wrote a tracking plan. Phase A was just deciding *what* to measure. Phase B, wiring it up, is gated on that list being confirmed. This week I confirmed it. The plan itself is 10 named feature events, base screen_view and session tracking, and an identify() call keyed on the auth user's ID (cleared on sign-out). Sounds straightforward. But three things shaped it in ways I didn't anticipate when I started. First: Expo Router doesn't have an equivalent of the automatic screen-view tracking I used in my last project (a Next.js app). I'd taken that for granted. Here, screen_view has to fire manually — and it has to emit the route *pattern*, not the resolved path, because the resolved path contains tenant record UUIDs. That's a privacy issue hiding in a convenience feature. [Will verify this holds in Phase B.] Second: several expensive operations in this app auto-fire on mount. The AI renderer runs roughly six times per analysis view. PDF generation triggers automatically. Land Registry lookups happen in the background. In my previous project, every tracked action was a deliberate button press, so this was easy. Here I had to make a rule: a feature event marks user intent. If the user didn't cause it, it doesn't get a feature event. Drawing that line clearly — before writing code — meant I didn't have to make that call twelve times in twelve different files. Third: the usage dashboard counts every non-base event name and surfaces the top eight, with no allowlist. That constraint came from the dashboard design, not the tracking plan. But it meant the named event set had to stay tight, or the dashboard would fill up with noise. The two designs had to talk to each other. The thing that surprised me most came at the end. Writing the tracking plan forced me to map what the app actually does, and that surfaced a gap I hadn't noticed: bug reports in this app never open a GitHub Issue. The dashboard has a "reported bugs" panel — but it can't see anything, because there's nothing to see. Separate problem to fix, but I only found it because I was doing this work. I'm a beginner at this. The whole codebase is AI-generated — Claude Opus 5 co-authored both commits this week. But "beginner" doesn't mean skipping the design step. If anything, writing the plan first meant I had one document to get right instead of ten instrumentation decisions to second-guess later. The signed-off event list is the contract. Phase B starts now.
Completed and signed off a Phase A product-analytics tracking plan — a concrete milestone of deciding *what* to measure before building the measurement.
1/ Before writing a single line of analytics SDK code, I made Claude help me write a document deciding *what* to measure — and sign off on it like a contract. Turns out that decision is way harder than it sounds. 2/ The app is a property reconfiguration tool. It has AI renders, PDF generation, Land Registry lookups — several expensive operations that fire automatically on mount. My old project tracked everything as button presses. Easy. This one doesn't work that way. 3/ The core question: if something fires 6 times automatically just because a screen loads, do you track it? The rule we landed on: a feature event marks *user intent*. Auto-fires are excluded. Sounds obvious in hindsight, but it took a while to get there. 4/ The other gotcha: Expo Router has no equivalent of the automatic screen-view tracking I used in my Next.js app. So screen_view has to fire manually — and has to emit the route *pattern* (/property/[id]) not the resolved path, because the resolved path leaks tenant UUIDs. [VERIFY before Phase B] 5/ The output is a signed-off event list: base screen_view + sessions, identify() keyed on the auth user ID, 10 named feature events, 2 lifecycle events. That list is now a contract. Phase B (actually wiring the SDK) is gated on it. 6/ The other thing I didn't expect: writing the plan exposed a gap I hadn't noticed. Bug reports in this app never open a GitHub Issue — so the analytics dashboard that's supposed to surface bug trends can't see them at all. Separate problem, but I wouldn't have found it without doing this first. 7/ Doing the measurement design before the measurement code felt backwards at first. It wasn't. The document forced decisions that would have been invisible if I'd just started instrumenting. All of this drafted with Claude Opus 5. I wrote zero code this week and still shipped something real. /
Wrote and signed off on a Phase A product-analytics tracking plan — deciding upfront what to measure before writing a line of tracking code.
Before I wrote any tracking code for my app, I wrote a document describing every event I want to track — gave it a name, defined what it means, and then signed off on it. Only after that does the SDK work start. I've never done this before. Every other time I've added analytics to something, I've just started firing events and figured it out as I went. The result was always a mess of inconsistently named events, ambiguous triggers, and charts I didn't trust. This time, Claude pushed me to think it through upfront. The output is what I'm calling a Phase A tracking plan — a contract for what the named event set is before a line of instrumentation exists. Phase B (the actual SDK integration) is gated on it being signed off. That's the commit message: "Phase B is unblocked; the §6 event list is the signed-off contract." Which sounds more formal than I am, but it helped. Three things came out of the planning process that I genuinely wouldn't have caught mid-build. The first was a framework gap. I'd tracked screen views before using a Next.js library that handled it automatically. Expo Router has no equivalent [VERIFY before SDK goes in]. Every screen view has to be fired manually, and it has to emit the route *pattern* — something like /property/[id] — not the resolved path. If I'd used the resolved path, every tenant record UUID would become its own unique "screen" in the data. That would've taken me a while to notice and longer to untangle. The second was an auto-fire problem. Several expensive operations in this app trigger on mount without any user action — the AI analysis renders multiple times per view, the PDF generator runs, Land Registry comparables get fetched. In a previous project everything tracked was a button press, so I'd never had to think about this. Here I needed an explicit rule: a feature event marks user intent, not component lifecycle. Without writing it down I'd have ended up with event counts that meant nothing. The third was about naming discipline. The analytics dashboard this feeds into counts every non-base event name and surfaces the top 8, with no allowlist and no filtering. So if the event set is bloated or inconsistent, the chart is useless. Sitting down to name all 10 feature events at once, before any of them exist in code, forced a level of care I wouldn't have had if I'd just added them one at a time. The whole codebase is AI-generated — Claude Opus 5 wrote the plan alongside me. What I'm finding is that the most useful thing about working this way isn't that it writes code faster. It's that when I ask it to think something through properly, it surfaces decisions I didn't know I had to make. I'm a beginner. I don't always know what I don't know. A document that forces me to decide things explicitly, before I'm committed to an implementation, is turning out to be worth more than I expected.
Wrote and signed off on a Phase A product-analytics tracking plan — deciding upfront what to measure before writing a line of tracking code.
1/ Before writing a single line of tracking code, I wrote a full analytics tracking plan and signed off on it. I've never done that before. Turns out there's a reason senior devs do it this way. 2/ The app is WyCo Command Deck — a property management tool. I wanted to understand which features people actually use. So Claude helped me draft a Phase A plan: define every event first, agree on it, *then* wire up the SDK. 3/ Three things came up during the planning that I wouldn't have caught mid-implementation: 4/ First: Expo Router has no equivalent of the auto-tracking I'd used in a Next.js app before. Screen views have to be fired manually — and I have to emit the *route pattern* (/property/[id]), not the resolved path, or every tenant UUID becomes its own "screen." [VERIFY before SDK goes in] 5/ Second: several expensive operations auto-fire on mount here. AI renders ~6x per analysis view. PDF generation. Land Registry lookups. If I'd tracked "event fired = user intent," I'd have garbage data. So we adopted a rule: a feature event marks user intent only. Button press, not mount. 6/ Third: the analytics deck counts every non-base event name and surfaces the top 8 — no allowlist, no filtering. That meant the named event set had to stay tight. Bloated naming = meaningless charts. Writing it out first forced that discipline. 7/ The whole plan is a signed-off contract now. Phase B (actually adding the SDK) is gated on it. I'm a beginner who hasn't shipped analytics before. Having a doc to point at — even just for myself — felt surprisingly clarifying. 8/ 100% of the code and docs in this project are AI-generated. I'm learning what good planning looks like by watching what Claude surfaces when I ask it to think something through. Sometimes the most useful output isn't code. It's a list of things I didn't know I needed to decide.
Lapsed sessions were throwing an error instead of gracefully redirecting to /signin — a small UX cliff that needed smoothing.
I found this one by doing something I probably should do more often: manually walking through a redirect gate on production with an intentionally stale session cookie. The setup: Stashery's middleware (src/middleware.ts) lets any request through as long as it's carrying a cookie. It doesn't validate the session at the edge — that's a deliberate trade to avoid a DB round-trip on every request. Fast middleware, deferred validation. The actual check happens deeper in, at the layout level. What I didn't notice until I tested it: if a session had lapsed, that stale cookie would ride all the way to src/app/(app)/layout.tsx, where withSession() would try to use it — and throw a plain, untyped Error straight through to an error page. So /dashboard with a stale session returned a 500. Meanwhile / and /signin returned 200. One route crashing, the others fine, and the difference was just whether you happened to be logged in a while ago. A session expiring is not an edge case. It's expected. It deserves the signed-out experience — not a crash page. The fix sounds simple: catch the error, redirect to /signin. But Claude (who wrote the actual code, as always) pushed back on a naive catch-all, and I think that was right. The layout now catches only a typed NotAuthenticatedError. Everything else rethrows. That distinction matters more than it first seems. If withSession() throws "Profile not found," that means the auth create hook failed during signup — something actually went wrong. A blanket redirect would quietly swallow that failure and dump a confused user at a sign-in page with no account. Typed errors let you be specific about what you're handling and what you're not. No redirect loop, either. /signin revalidates on load, finds no session, and renders cleanly. The thing I keep learning: the cases you actually care about and the cases that look similar are not the same case. An expired session and a broken signup can both produce an auth error. One deserves a graceful redirect. The other deserves to be loud. Writing that distinction down in the type system is the only way I'd trust myself to keep them separate. I found this bug because I was looking for something else entirely. That's probably how most UX cliffs get found.
Lapsed sessions were throwing an error instead of gracefully redirecting to /signin — a small UX cliff that needed smoothing.
1/ A stale session cookie was sending users to a crash page instead of /signin. One character difference in the outcome. Completely different experience for the user. Here's what was actually happening — and why the fix was more careful than I expected. 2/ The middleware (src/middleware.ts) lets any request through if it's carrying a cookie. It doesn't hit the DB to validate it. That's a deliberate call: keep a round-trip off the edge. Fast middleware, deferred validation. 3/ So a lapsed session cookie rides all the way to the layout (src/app/(app)/layout.tsx:35), where withSession() tries to use it — and threw a plain Error straight to the error page. A 500. For an expired session. Which is the most normal thing in the world. 4/ The fix wasn't just "catch the error and redirect." withSession() now throws a typed NotAuthenticatedError. The layout catches ONLY that, and redirects to /signin. Everything else still rethrows. 5/ That distinction matters. "Profile not found" means the auth create hook failed during signup. Silently redirecting that to /signin would bury a broken signup behind a login screen. Typed errors let you catch the thing you actually mean to catch. 6/ No redirect loop either — /signin revalidates on load, finds no session, and just renders. Found this while manually checking the B5 redirect gate on prod. A /dashboard request with a stale cookie was the only path returning 500. Co-authored with Claude Opus 5. I understood the problem. Claude wrote the code. 7/
Fixed a subtle auth flow gap: signed-in users hitting /, /signin, or /signup were landing on the wrong page instead of being sent straight into the app.
For three days I was convinced my app had a session bug. Users force-quitting and reopening the native app would land in a signed-out state. I dug into the database. All 7 session rows: unexpired, intact, rememberMe:true arriving correctly from the webview. The sessions were not the problem. The UI was lying to me. Here's what was actually happening. The app is a Next.js web app wrapped in a Capacitor shell — that's how it runs natively on mobile and desktop. Every time the Capacitor shell cold-starts, it opens at '/'. And '/' was a static marketing page. No session check. Just a headline and a "Sign in" call to action. So a signed-in user would force-quit, reopen, land on the marketing page, see "Sign in," and sign in again. From their perspective: they got logged out. From the database's perspective: nothing unusual happened at all. I only spotted it because desktop users never reported it. I couldn't figure out why — desktop is the same codebase. Then I remembered: a browser tab restores its last URL. The Capacitor shell doesn't. It always goes home. That asymmetry was the tell. The fix itself was straightforward. '/' now does a session check and redirects into the app if a valid session exists. /signin and /signup do the same — there's no good reason a signed-in user should be able to land on those pages. Claude (Opus 5) wrote the redirect gate logic, and it does a full Better Auth validation rather than just checking for cookie presence. That detail turned out to matter: middleware lets anything holding a cookie through to the page, and the page validates. The gate's test is strictly stronger than middleware's, so any redirect the gate issues can't get bounced back into an infinite loop. A stale cookie renders /signin cleanly instead of ricocheting to /dashboard and dying on the layout's session check. One thing I want to be honest about: I haven't proven this is fully fixed yet. The commit message even says it — "this change is also the experiment." If a force-quit still lands someone signed out after this, then the cookie is genuinely dying on quit and the investigation continues. I'll know after testing on device. The thing that stuck with me from this whole chase: I spent three days looking at sessions when the problem was a door. The sessions were never broken. I just hadn't thought about what the app was showing people the moment it opened.
Fixed a subtle auth flow gap: signed-in users hitting /, /signin, or /signup were landing on the wrong page instead of being sent straight into the app.
1/ For 3 days I thought users were getting signed out after force-quitting the app. The sessions were fine. All 7 of them. Unexpired, intact, rememberMe:true arriving from the webview. The UI was lying. 2/ Here's what was actually happening: A Capacitor shell (the native app wrapper) cold-starts at '/' on every launch. And '/' was a static marketing page — no session check, just a big "Sign in" button. So signed-in users saw "Sign in" and... signed in again. 3/ Desktop never surfaced it because a browser tab restores its last URL. The native shell doesn't. It always goes home. I was debugging a ghost. The bug was never about sessions at all. 4/ The fix: make '/' check for an active session and redirect into the app if one exists. Same for /signin and /signup — no reason a signed-in user should land there. Claude wrote the gate logic. It does a full auth validation, not just a cookie-presence check. 5/ That distinction matters more than I realized. Middleware lets anything with a cookie through and lets the page validate. The gate's test is strictly stronger, so a redirect it issues can't get bounced back into a loop. A stale cookie shows /signin. It doesn't ricochet to /dashboard and die. 6/ One honest caveat: this change is also the experiment. If a force-quit still lands someone signed out, then the cookie genuinely dies on quit and the real hunt starts. I'll know after testing on device. 7/ The hardest part of debugging isn't finding the bug. It's questioning whether you're even looking at the right thing. The sessions were never broken. I just wasn't looking at the door everyone was walking through.
Closed the 'already signed in' loop: visitors hitting /, /signin, or /signup while already authenticated now get sent straight to the app instead of seeing a login screen they don't need
For two days I was convinced my auth system had a session bug. The evidence looked damning: users (me, on my own app) getting kicked to the sign-in screen after every force-quit on mobile. Classic "you've been signed out" behavior. So I went digging. Pulled the session rows from the DB. All 7 unexpired. None deleted. Checked the sign-in logs — rememberMe:true arriving from the webview exactly as expected. The session was completely healthy. The auth system was not the problem. The problem was a static marketing page. My app is a web app wrapped in a Capacitor native shell. When the shell cold-starts — which is what a force-quit and relaunch is — it always opens at '/'. And '/' was a static page I'd built before I had any users, back when it was just marketing copy and a "Sign in" button. No session check. No redirect logic. Just: here's the homepage, stranger. A signed-in user lands there. Sees a "Sign in" call to action. Signs in. Everything feels normal again. This happened on every single force-quit. The reason I never caught it on desktop: a browser tab restores its last URL when you reopen it. So a desktop user who'd been in the app comes back to the app. The native shell has no "last URL" — it goes home every time. Same auth code, completely different experience. The fix was to add a session gate to '/', '/signin', and '/signup' — if you're already authenticated, you get sent straight into the app instead of seeing a page that assumes you're not. Claude wrote the whole thing. The gate does a full Better Auth session validation rather than just checking whether a cookie is present, which matters more than it sounds: middleware lets anything with a cookie through, so if the redirect logic only checked for a cookie, you could build an infinite loop where middleware admits you and the gate sends you back to sign in, forever. Full validation means a stale cookie correctly renders the sign-in page rather than bouncing endlessly. I'll be honest — I understood maybe half of that paragraph before the code shipped. I understood it properly after reading the commit message Claude wrote explaining its own reasoning. That's become a normal part of my workflow. The one thing worth saying clearly: this isn't proven fixed yet. It's a theory that fits all the evidence, and I've shipped the experiment. If a force-quit still lands signed-out, the cookie itself is dying on quit and the investigation starts over. I thought that was worth including rather than writing this as a triumphant bug-fix post. The takeaway, if there is one: when auth "feels broken," check what page the user actually lands on before assuming the auth layer is wrong.
Closed the 'already signed in' loop: visitors hitting /, /signin, or /signup while already authenticated now get sent straight to the app instead of seeing a login screen they don't need
1/ My app was "signing out" users on every force-quit. 7/7 session rows in the DB: unexpired, intact. rememberMe:true in the logs. The session was fine. The UI was lying. 2/ Here's what was actually happening: my Capacitor native shell cold-starts at '/' every launch. And '/' was a static marketing page. No session check. None. 3/ So a fully signed-in user lands on a page that says "Sign in." They sign in. Everything looks normal. Repeat every force-quit. 4/ Desktop never hit this because a browser tab restores its last URL. The native shell always goes home. Two completely different behaviors from the same auth code. 5/ The fix: make /, /signin, and /signup check for an existing session and redirect to the app if one exists. Three pages that were "just" static now do a real Better Auth validation on load. 6/ The subtle part — the gate does a full session validation, not just "does a cookie exist?" That matters because middleware is more permissive. If the gate only checked for a cookie, you could get into a redirect loop. Full validation means a stale cookie shows /signin instead of volleying forever. 7/ Is it fixed? Honestly: not proven yet. This is the experiment. If a force-quit still lands signed-out, the cookie itself is dying and I'm back to hunting. Shipping the theory first. 8/ The whole fix was written by Claude Opus 5. I understood maybe 60% of it before it shipped. The commit message taught me the other 40%. That's kind of the whole project. /
Fixed a subtle auth UX bug: lapsed sessions were throwing errors instead of quietly redirecting to /signin — a small change that makes the app feel much more solid
Last week I found a bug by accident. I was manually checking a redirect flow on prod when I noticed: hitting /dashboard with an expired session cookie returned a 500 error. Not a redirect. An actual crash. The sign-in page worked fine. The home page worked fine. But if you'd been logged in, walked away, and came back a day later — you got a broken app instead of a gentle nudge to sign back in. Here's why it happened, because the reason turned out to be more interesting than the fix. The middleware in this codebase (src/middleware.ts) is written to trust any request that carries a cookie, without actually validating whether that session is still alive. That's a deliberate call: doing a real DB lookup on every edge request adds latency and cost that isn't worth it for most requests. So the middleware waves it through. That means a stale cookie reaches the app layout (src/app/(app)/layout.tsx), where withSession() runs — and that function was throwing a plain, generic Error when it couldn't find a valid session. A plain Error has no type information. The layout had no way to distinguish "this session expired" from "the database is on fire," so it did nothing with it. The error bubbled all the way up to the error page. The fix was to make withSession() throw a typed NotAuthenticatedError instead. Then the layout catches *only* that specific type and redirects to /signin. Everything else rethrows. That last part is the bit I keep thinking about. It would have been easy — maybe even tempting — to just wrap the whole thing in a catch and redirect any error to sign-in. Simpler code. But that would silently bury real bugs. If the auth.ts create hook fails during signup, "Profile not found" gets thrown. Redirecting that to a sign-in page would hide a broken signup behind a perfectly calm UX. A user would just keep trying to sign in, confused, while the actual problem went unlogged. Typed errors let you be precise. You handle the things that are ordinary and expected — a session expiring is not a bug, it happens to everyone — and you let everything else surface. I want to be honest about something: I'm not a developer. This entire codebase was written by Claude Opus 5. I'm learning to read it, to understand the decisions behind it, and to ask the right questions when something breaks. The gap between "the bug is fixed" and "I understand why the fix is correct" is where most of my time is going right now. It's slower than just shipping. It's also the only part that actually compounds.
Fixed a subtle auth UX bug: lapsed sessions were throwing errors instead of quietly redirecting to /signin — a small change that makes the app feel much more solid
1/ My app was returning a 500 error whenever a session expired. Not a redirect. Not a graceful "please sign in again." A crash. Here's the subtle thing I learned fixing it. 2/ The bug: /dashboard with a stale cookie → 500 error page. Meanwhile / and /signin were fine. I only caught it while manually testing a redirect gate on prod. A user hitting this would just see a broken app. 3/ The middleware (src/middleware.ts:113) is written to trust any request that *has* a cookie — it doesn't validate the session, it just lets it through. That's intentional: validating on the edge means a DB round-trip on every request. Not worth it. 4/ So the stale cookie reaches the layout (src/app/(app)/layout.tsx:35), where withSession() runs — and threw a plain, untyped Error straight to the error page. The problem: a lapsed session isn't a crash. It's a normal, expected thing that happens to every user eventually. 5/ The fix: withSession() now throws a typed NotAuthenticatedError instead. The layout catches *only* that type and redirects to /signin. Everything else rethrows. That distinction matters. 6/ If I caught *all* errors and redirected, a broken signup hook would silently hide behind a sign-in page. "Profile not found" is a real bug — it should surface, not get swallowed. Typed errors let you be precise about what you handle. 7/ I didn't come up with this architecture. Claude Opus 5 wrote every line of this codebase. I'm learning to read it well enough to understand *why* it works. That gap — between "it's fixed" and "I understand why it's fixed" — is where the real learning is happening./
Pinned Vercel/cloud functions to the lhr1 region to enforce UK data residency — a concrete infrastructure decision with real compliance implications.
I thought my app was storing data in the UK. It wasn't. And the scary part is that a response header was giving me just enough false reassurance that I might never have noticed. A bit of context: I'm a non-technical founder building entirely with AI (Claude writes every line of code). One of the real constraints of the product I'm building is UK data residency — user data needs to stay in the UK. I'd deployed on Vercel, figured it would sort itself out, and moved on. It didn't sort itself out. When I finally looked at the raw response headers on my `/api/cron/generate` endpoint, I saw an `x-vercel-id` value that looked roughly like: `lhr1::iad1::…` I had no idea what that meant at first. So I asked Claude to explain it. Here's what I learned: the header has multiple segments. The first segment is the edge node — the CDN point-of-presence that's closest to whoever's making the request. Mine was `lhr1`. London. Looks great. But the edge node isn't where your serverless function actually executes. That's the second segment. Mine was `iad1`. US East. Every API call was being processed in Virginia because I'd never set a `regions` key in `vercel.json`, so Vercel just used the account default — which happens to be US East. The fix was genuinely trivial: add `{"regions": ["lhr1"]}` to `vercel.json`. One line. Claude wrote it in about three seconds once I explained what I needed. What I keep thinking about is how close I came to missing this entirely. If I'd only glanced at the header rather than reading it carefully — or if I'd checked a route that hits the edge cache rather than one that actually invokes a function — I'd have seen `lhr1` and assumed everything was fine. The first segment is not evidence. Only the second segment, on a route that genuinely calls a function, tells you where your compute is actually running. The thing about building with AI is that the code gets written fast. Decisions that used to force you to slow down and think — like "where will this actually run?" — can slip through because there's no friction. Nobody is writing the boilerplate and pausing to wonder whether the defaults are right. So now I try to treat compliance-adjacent things the same way I'd treat a security check: assume the defaults are wrong until I've confirmed they're right. That's probably good practice regardless of how you're building. But when the whole codebase is AI-generated, the defaults are everywhere, and they're invisible until you go looking. [VERIFY: whether pinning to lhr1 also applies to edge functions or only to serverless functions — the commit only addresses lambdas]
Pinned Vercel/cloud functions to the lhr1 region to enforce UK data residency — a concrete infrastructure decision with real compliance implications.
1/ I thought my app was storing data in the UK. It wasn't. Here's how I found out — and how one header almost tricked me into thinking everything was fine. 2/ I'm building in the UK and need UK data residency. Assumed Vercel would handle it. Never set a `regions` key in vercel.json, so functions silently inherited the account default: iad1. US East. 3/ The sneaky part: the `x-vercel-id` response header has multiple segments. The FIRST segment is the edge node — mine read lhr1 (London). Looks fine. But the edge isn't where your function runs. 4/ The SECOND segment is the actual lambda. Mine read `lhr1::iad1::…` — London edge, US function. Data was leaving the UK on every API call. I only caught it because I read the header carefully. 5/ Fix was a one-liner in vercel.json: {"regions": ["lhr1"]} Claude wrote it. But I had to know what to ask for — and I only knew what to ask for after understanding what that header was actually telling me. 6/ If you're building anything with data residency requirements, don't assume your cloud provider defaults to the right region. Check the second segment of x-vercel-id, not the first. The first will lie to you. 7/
Tightened the database connection from a loose SSL mode to sslmode=verify-full — the difference between 'probably encrypted' and 'actually verified encrypted'.
Until two days ago, my database connection was encrypted — technically. The data was moving through a TLS tunnel. But nobody was checking whether the server on the other end was actually Neon, the database host I intended to connect to. It could have been anyone with a certificate. The culprit was a single setting: ssl: "require" in postgres.js. That sounds like it should mean "be strict about SSL." It doesn't. The postgres.js library special-cases that string (and "allow" and "prefer") and maps them all to rejectUnauthorized: false — which tells Node's TLS layer to skip certificate verification entirely. Encrypted channel, unverified identity. The fix was switching to ssl: "verify-full". That string isn't on postgres.js's special-case list, so it falls through to Node's default TLS behavior, which does verify the certificate and check the hostname. One string, completely different security posture. Here's the part that would have bitten me if Claude hadn't flagged it: the explicit ssl option in code silently overrides whatever sslmode= you put in your DATABASE_URL connection string. The library resolves the explicit option first. So if I'd only updated the environment variable in Doppler and left the code alone, the change would have been completely inert. Both have to be updated and kept in sync — the URL and the code. The other thing I learned from this commit: a test that only checks the happy path proves almost nothing for security settings. Claude suggested a negative control — connecting with an empty CA store and confirming the connection gets rejected with a certificate error. Without that failure case, a successful connection could mean "verification is working" or it could mean "verification is off and the connection works for that reason." You need both. I'm writing this as someone who didn't know what rejectUnauthorized meant before this week. I still couldn't explain TLS certificate chains in detail without fumbling. But I now understand the gap between "this connection is encrypted" and "this connection is encrypted and I've verified who I'm talking to," and that gap was sitting in my production database config. The whole fix was written by Claude Opus 5. My job was reading the commit message carefully enough to understand what had actually been wrong — and why the obvious fix (changing the URL) wouldn't have worked.
Tightened the database connection from a loose SSL mode to sslmode=verify-full — the difference between 'probably encrypted' and 'actually verified encrypted'.
1/ My database connection was "encrypted" — but nobody was checking the certificate. That's not encryption, that's vibes. 2/ The setting was ssl: "require" in postgres.js. Sounds strict. It's not. The library maps "require" to rejectUnauthorized: false — meaning it encrypts the data but never verifies the server is who it claims to be. 3/ So: a man-in-the-middle could present any cert, the connection would succeed, and I'd have no idea. "Encrypted" in the same way a locked door is secure when the window's open. 4/ The fix: ssl: "verify-full". That string isn't special-cased by postgres.js, so it falls through to Node's TLS defaults — which do check the cert and the hostname. One string change, actual verification. 5/ Sneaky part: the explicit ssl option in code OVERRIDES whatever sslmode= you set in the connection string URL. Changing only the DATABASE_URL env var would have done nothing. Both have to match. 6/ Claude caught this during the fix and also suggested the negative control test: hand the connection an empty CA store and confirm it gets rejected. Without that, a passing connection proves nothing — you need to see it fail correctly too. 7/ I don't fully understand TLS certificate chains. But I now understand the difference between "probably encrypted" and "actually verified encrypted." That distinction is real, and it was missing from my app until yesterday. /
Pinned Supabase/edge functions to a specific region (lhr1) to meet UK data residency requirements — a real compliance constraint most solo devs don't think about until it bites them.
I shipped a one-line config fix this week that I'm embarrassed took this long to notice. My app handles UK user data. For the past however-long, every serverless function call was running in `iad1` — Vercel's US East region. I thought it was running in London. I was wrong, and I had evidence in my response headers the whole time that I was misreading. Here's what happened. Vercel lets you pin functions to a specific region via a `regions` key in `vercel.json`. I didn't have one. So my functions inherited my account default, which is `iad1`. The fix — once I understood the problem — was adding `"regions": ["lhr1"]` to pin everything to London. Done. But the part that's stuck with me is how I almost missed it entirely. When I was investigating, I looked at the `x-vercel-id` header on my API responses. The first segment read `lhr1`. London. I nearly closed the tab satisfied. Except the first segment is the edge node, not the function. The edge is where the CDN sits — it's always going to be close to the user. The *second* segment is where the lambda actually executed. Mine read `lhr1::iad1::…`. Edge in London, function in Virginia. Data touching US infrastructure. For UK data residency requirements, that's not compliant. I only caught the distinction because I pasted the header into a conversation with Claude and asked it to explain what each segment meant. I would have shipped the "fix" — added the regions key without really understanding what I was confirming — and had no idea whether it had actually worked. This is 100% an AI-generated codebase. Claude wrote the original Vercel config that was missing the regions key, and Claude helped me understand why the header I was looking at was misleading. I don't say that to deflect blame — I'm the developer, the oversight is mine. But it is a funny loop: the same tool that introduced the gap helped me close it. The broader thing I'm taking from this is that data residency is one of those requirements that feels abstract until it isn't. Most solo devs I know (myself included, until last week) treat it as an enterprise concern. You build the thing, you deploy it, it works, users are happy. The idea that "works" might mean "technically running in the wrong country" doesn't come up until a compliance question forces you to actually check. If you're on Vercel and you're handling data for users in a jurisdiction that cares about where processing happens [VERIFY: your specific legal requirements with an actual lawyer, not a commit message], go look at the `x-vercel-id` header on a route that invokes a real function — not a static page or edge middleware. Check the second segment. That's the one that tells you where your code ran. Four characters in a config file. Weeks of unknowing non-compliance. Good reminder that "it deploys" and "it's correct" are different things.
Pinned Supabase/edge functions to a specific region (lhr1) to meet UK data residency requirements — a real compliance constraint most solo devs don't think about until it bites them.
1/ My app was storing UK user data in a US data center and I had no idea. One commit message later, I understand why compliance is a job. 2/ I added a `regions` key to vercel.json to pin my edge functions to `lhr1` (London). Straightforward, right? The reason I needed to is the part that got me. 3/ Without an explicit `regions` key, Vercel inherits your account default. Mine was `iad1` — US East. So every function call was running in Virginia, not London. For a UK-facing product, that's a data residency problem. 4/ The tricky part: I almost convinced myself it was already fine. The *first* segment of `x-vercel-id` in the response headers showed `lhr1`. That's the edge node — it looked like London. But that's not where the function ran. 5/ The *second* segment is what matters — that's the lambda. Mine read `lhr1::iad1::…` Edge in London, function in the US. Data touching US infrastructure. Not compliant. Claude caught the distinction when I showed it the header. I would have shipped "fixed" and moved on. 6/ The fix was four characters in vercel.json: `"regions": ["lhr1"]` But knowing *why* those four characters matter — and which header segment to actually read — took a lot longer. 7/ If you're building anything that handles European user data and you're on Vercel: go check your x-vercel-id headers on a route that actually hits a function, not just the edge. The first segment will lie to you.
Discovered the app was connecting to the database without verifying the TLS certificate — fixed by enforcing sslmode=verify-full
My database connection was encrypted this whole time. It was also completely unverified, and I had no idea those were two different things. Here's what was happening: the connection to my Neon Postgres database was using ssl: "require" in postgres.js. That sounds safe. It's the word "require," after all. But postgres.js treats that as a special case and quietly sets rejectUnauthorized: false underneath. Which means: yes, the traffic is encrypted, but no, nobody is checking whether the certificate on the other end is legitimate or whether the hostname matches. An attacker in the right position could present any certificate and the connection would accept it. I only found this because Claude flagged it during a code review. I was not looking for it. I did not know enough to look for it. The fix itself was small — switch ssl: "require" to ssl: "verify-full". postgres.js doesn't special-case that string, so it falls through to Node's TLS defaults, which do the certificate and hostname verification you'd actually want. One word in a config object. But there was a trap I almost fell into. I'd also updated the DATABASE_URL in Doppler to include sslmode=verify-full in the connection string. That change alone would have done nothing — the explicit ssl: option in code overrides whatever's in the URL (postgres.js resolves them in a specific order, and the code-side option wins). I would have made that URL change, tested the connection, seen it work, and concluded I'd fixed the problem. I hadn't touched the part that mattered. The thing that made me confident the fix was real: I ran the connection again with an empty CA store on purpose. If certificate verification was genuinely enforced, it should fail with UNABLE_TO_GET_ISSUER_CERT_LOCALLY. It did. Without that negative control, a passing test proves nothing — you could have a broken cert check and still get a successful connection. That's exactly the situation I was already in. I can't say with certainty what the real-world risk was against Neon's specific infrastructure [VERIFY]. But "encrypted but unverified" is a known category of misconfiguration, and the config was wrong regardless of whether anyone was in a position to exploit it. The whole codebase is AI-generated — Claude wrote the original connection code and Claude caught the problem with it. That's a strange thing to sit with. But the takeaway I'm actually walking away with is about the negative control test: a passing connection is not evidence of correct security configuration. You have to test the failure case too, or you're not testing anything.
Discovered the app was connecting to the database without verifying the TLS certificate — fixed by enforcing sslmode=verify-full
1/ My app was "encrypting" its database connection the whole time. Turns out it was encrypted but completely unverified — no certificate check, no hostname check. Anyone who could intercept the connection could have presented any cert and we'd have accepted it. 2/ The culprit: postgres.js treats ssl: "require" as a special case and silently sets rejectUnauthorized: false. "Require" means "encrypt." It does NOT mean "verify who you're talking to." I didn't know those were two different things. 3/ The fix was switching to ssl: "verify-full", which postgres.js doesn't special-case. So it falls through to Node's TLS defaults — which actually check the certificate and hostname. One word change. Enormous difference in what it means. 4/ The sneaky part: I also had sslmode=verify-full in the connection string URL. That did nothing. The explicit ssl: option in code overrides whatever's in the URL. I would have committed that URL change, seen a passing connection, and thought I'd fixed it. 5/ The only reason I caught this: I ran the same connection with an empty CA store deliberately. If the cert check was real, it should fail with UNABLE_TO_GET_ISSUER_CERT_LOCALLY. It did. Without that negative test, a working connection proves nothing. 6/ I can't verify whether this was actually exploitable against Neon's infrastructure specifically — but the config was wrong, and "wrong but probably fine" isn't a place I want to stay. Claude caught this in a code review. I wouldn't have known to look. 7/
Added PLAN.md and epics.md so an AI 'Command Deck' can read the project's phases, epics, and stories — using structured docs as a machine-readable interface, not just human notes.
I've been building a property reconfiguration app, and the whole codebase is AI-generated — written by Claude, session by session. To keep things coherent across sessions, I use what I call a Command Deck: an AI-assisted tool that reads structured project docs and surfaces a backlog. This week I discovered the Deck was essentially flying blind. The backlog column was showing a list of known bugs and nothing else. No phases, no epics, no stories. I dug into why, and the answer was simple: the repo had no PLAN.md and no epics.md. The Deck reads those specific files to build the backlog view. They didn't exist, so it fell back to KNOWN-ISSUES.md and called it a day. So I wrote them. Which sounds easy until you realize the project has 265 commits and no single source of truth. I had to reconstruct everything from NOTES.md, KNOWN-ISSUES.md, PROJECT.md, and the commit history itself. The result: 10 phases, epics A through J, 81 stories. Epics A through H are a record of work that already shipped, marked as complete. Epics I and J are live. One story is current — story 71, a scorecard feature for a user named Vicky. I didn't know those numbers before I did this. That felt strange to admit. While I was in there, I found two things in PROJECT.md that were just wrong. It said the app was "running on the Vercel + Supabase stack" and that a Neon database migration was "planned for when Ben is ready." The migration actually completed on June 9th. No one had updated the docs — because the docs were for me, and I knew what had happened. That's the thing I keep turning over. When docs are just for humans, being a little stale is a minor inconvenience. When an AI is reading those docs to understand the current state of the project and decide what to surface, stale details become actual bugs. The Deck thought the migration hadn't happened. It was confidently wrong. Writing PLAN.md and epics.md forced a kind of precision I'd been avoiding. Not because the format is hard — it's just markdown tables — but because naming 81 stories and deciding which phase they belong to makes you confront how much ambiguity you'd been carrying around in your head. I verified it worked by running the Deck's own readBacklog() function against the repo. It came back clean: 10 phases, 10 epics, 81 stories, 1 current. The Deck finally knows what the project is. The takeaway, if there is one: if you're using AI tooling to navigate your project, your docs aren't notes anymore. They're the interface. Write them accordingly.
Added PLAN.md and epics.md so an AI 'Command Deck' can read the project's phases, epics, and stories — using structured docs as a machine-readable interface, not just human notes.
1/ My project dashboard showed 6 phases and had no idea the database migration had already shipped. The AI tooling I use to manage the project was reading docs that were just... wrong. 2/ The fix: write PLAN.md and epics.md in a specific structure so the "Command Deck" — the AI layer that reads the backlog — can actually parse what's going on. Not docs for me. Docs for the machine. 3/ What that required: reconstructing the full project history from NOTES.md, KNOWN-ISSUES.md, PROJECT.md, and 265 commits. 10 phases. Epics A through J. 81 stories total. I didn't know those numbers before I wrote the files. 4/ Epics A–H are already shipped, so they're marked as such. Epics I–J are live. Exactly one story is current: story 71, the Vicky scorecard feature. The Deck now shows that — instead of surfacing a list of known bugs and nothing else. 5/ There were two outright false claims in PROJECT.md: it said the app was "running on Vercel + Supabase" and that a Neon migration was "planned for when Ben is ready." The migration finished on June 9th. Nobody updated the docs. 6/ When you write docs for a human, stale details are annoying. When you write docs for an AI that makes decisions based on them, stale details are bugs. That's the reframe that made this feel worth doing carefully. 7/ Verified it worked by running the Deck's own readBacklog() function against the repo. Came back: 10 phases, 10 epics, 81 stories, 1 current. The whole codebase — including the Deck itself — is Claude-generated. I just write the docs and read the output. /
Reformatted planning docs specifically so an AI (Claude Command Deck) could read them — the meta-layer of building an AI-generated project with AI tooling
Yesterday I did something I've never done before: I reformatted my documentation specifically so an AI could read it. Not a human. An AI. I'm building PanelProBuild with 100% AI-generated code — every line written by Claude. I also use a tool called Claude Command Deck that gives me a dashboard view of the project: phases, backlog, open bugs, current story. It's supposed to be the cockpit for the whole build. Except this week it showed an empty backlog. No phases. No epics. No stories. Nothing. The project was registered. The folder check passed. I hadn't broken anything obvious. So I dug into the Command Deck's parser to figure out what it actually expected. Turns out: format mismatch, three ways at once. My PLAN.md used bold paragraphs to mark phases — like `**Phase 1 — Foundation**`. The parser wanted `##` or `###` headings. Since none matched, it parsed zero phases and returned an empty tree. My KNOWN-ISSUES.md used a numbered list; the bug parser only matches unordered bullets (`-`, `*`, `+`) starting at column zero, so it found zero bugs. And the epics file the deck reads for story-level detail? It didn't exist at all. None of this was wrong, exactly. The docs were perfectly readable to me. They just weren't readable to the thing that needed to read them. The fix was documentation-only — no source code touched. I converted the phase headings, switched the bug list to bullets, and reconstructed 8 epics and 60 stories from the shipped log and my notes to build the missing epics file from scratch. Claude Opus 5 did most of the reconstruction; I guided the structure and verified the output by running the deck's own readBacklog() function against the files. After: 5 phases, 8 epics, 60 stories, 1 current, 9 bugs. Dashboard populated correctly. The part I keep turning over is the comment I added to each of the three files. Something like: "This format is load-bearing. The Claude Command Deck's backlog parser reads this file. Do not change the heading structure without updating the parser contract." I've written a lot of code comments. I've never written one that was essentially a note to a future AI saying *hey, another AI is watching this file, please don't break the handshake.* That's the meta-layer nobody warned me about when I started building in public with AI tooling. It's not just "AI writes my code." It's that the AIs in the system sometimes need to communicate with each other through artifacts I'm responsible for maintaining. I'm the one holding the format contract. I don't know if that's a cautionary tale or just what this kind of build looks like at this stage. Probably both. But it was a genuinely weird afternoon, and I thought it was worth writing down.
Reformatted planning docs specifically so an AI (Claude Command Deck) could read them — the meta-layer of building an AI-generated project with AI tooling
1/ I spent an afternoon reformatting my planning docs — not for me, but so an AI could read them. That sentence is doing a lot of work. 2/ My project dashboard (Claude Command Deck) showed PanelProBuild with an empty backlog. No phases. No stories. No bugs. The project was registered. The folder was verified. Everything looked fine. 3/ Root cause: pure format mismatch. PLAN.md used bold paragraphs for phase headings. The parser wanted `##`/`###` markdown headings. So it parsed 0 phases and returned an empty tree. I hadn't written docs wrong. I'd just written them for humans. 4/ Three files needed surgery: - PLAN.md: bold paragraphs → `## Phase N — Title` headings with status markers - KNOWN-ISSUES.md: numbered list → bullet list (the parser only matches `-`/`*`/`+` at column 0) - docs/specs/panelprobuild/epics.md: this file didn't exist at all. Had to reconstruct 8 epics and 60 stories from the shipped log. 5/ The fix was docs-only. No source code touched. But here's the part I keep thinking about: I added a comment to each file explaining that its format is "load-bearing" and which parser reads it. I've never written a comment like that before. Usually comments explain what something does. This one explains who else is watching. 6/ After the fix: 5 phases, 8 epics, 60 stories, 1 current, 9 bugs parsed correctly. Verified by running the deck's own readBacklog() against the files. Claude Opus 5 wrote the restructured docs. Claude Command Deck now reads them. I mostly watched and debugged the mismatch. 7/ Building an AI-generated project with AI tooling means the AIs sometimes need to talk to each other — and I'm the one making sure the format contract holds. Not what I expected "building in public" to look like, but here we are./
Declared a formal dev/prod environment split as a launch gate — a concrete architectural decision marking the project's shift toward production-readiness.
There's a commit in my repo from this week that touches zero source files. It's a one-line change to a project markdown doc, and it might be the most significant thing I've pushed in months. The line: declaring an env split — a proper dev/prod environment separation — as a formal launch gate for PanelPro. That probably sounds mundane if you've shipped software before. For me it wasn't. Up until now, the project has lived in a single environment. The database I poke at while building is the database. There's no production server to worry about, no real users to protect, no consequences if I fat-finger a query. It's been liberating in the way that only applies when nothing is real yet. Writing "env_split (dev/prod-server launch gate)" into the project doc changed the feeling of the whole thing. It means the app doesn't ship until there are genuinely two separate environments — different servers, different configs, different databases. It means I have to actually understand the difference between a thing running on my machine and a thing running for someone else. It means "done" now has a harder edge. I'm a beginner. I want to be clear about that. Every line of code in this codebase was written by Claude — I'm the one steering, asking questions, trying to understand what I'm looking at, but I am not a developer in any traditional sense. So a lot of what I'm doing right now is learning what "production-ready" actually means by slowly building a list of things that have to be true before I can honestly say it. The env split is one of those things. [VERIFY: I should confirm exactly what the dev/prod config differences will need to cover — environment variables, database connections, server URLs at minimum — before I mark this gate closed.] What I didn't expect is how much it would shift my mental model just to write the requirement down. The project went from "a thing I'm building" to "a thing I'm building toward shipping." Those feel different. If you're also pre-launch and doing this for the first time: I'd recommend naming your gates explicitly, even if they seem obvious. Especially if they seem obvious. The act of writing "this has to exist before we go live" makes it real in a way that a vague sense of "I should probably do that" never does.
Declared a formal dev/prod environment split as a launch gate — a concrete architectural decision marking the project's shift toward production-readiness.
1/ My side project just hit a milestone I didn't expect to feel this significant: I wrote down "dev/prod environment split" as a formal launch gate. That's it. That's the tweet. Except it's not, because this one line changed how the whole project feels. 2/ For months, everything ran in one environment. The database I tested against was the database. There was no "prod" to protect. It was just... a thing on my laptop. 3/ Declaring the split as a gate means: the app doesn't ship until dev and prod are genuinely separate. Different servers, different configs, different everything. It sounds obvious. It probably is obvious, if you've shipped before. 4/ I haven't. So writing it down as a named requirement — something that has to be *done* before launch — is the first time the project has felt like software that might actually run for real users. 5/ Worth noting: 100% of the code in this codebase was written by Claude. I'm not a developer. I'm learning what "production-ready" even means by building toward it, one declared gate at a time. 6/ The commit is a one-liner in a project markdown file. No code changed. But the shape of what "done" means changed, and that felt worth marking. What did your first "this is getting real" architectural decision look like? /
Declared a formal dev/prod environment split as a launch gate — a small but concrete architectural decision marking the project's first step toward production readiness.
There's a commit in my repo from this week that has zero lines of application code. It's a single update to a project.md file, and it declares one rule: no production deploy until there's a dev/prod environment split in place. That's it. That's the whole commit. I want to write about it anyway, because it felt like a turning point — and I want to be honest about why it took me this long to get here. I've been building Build-in-Public Studio with 100% AI-generated code. Claude writes everything; I describe what I want, review what comes back, and steer the direction. It's been a genuinely fast way to build, and I think it's the interesting part of this whole project. But one thing AI-assisted development doesn't automatically give you is a forcing function around production readiness. The code works. It runs locally. That's often where the thinking stops. The env split question kept coming up in the back of my mind and kept getting deferred. "I'll think about that when it matters." The problem is it always matters a little too late — after you've accidentally pointed a test run at a real database, or deployed something that behaves differently than you expected because an environment variable wasn't set right. [VERIFY: whether the current setup has any env-specific config at all yet — worth checking before the next Claude session.] What finally pushed me to write it down wasn't a disaster. It was just a quiet moment where I asked: what would have to be true before I felt okay putting real users on this? The env split was the first answer. So I wrote it into the project file as a named gate — env_split — and now it has to be done before a production server goes up. The act of naming it changed something. "I should probably set up environments at some point" and "env_split is a declared launch gate" are technically the same intention, but they don't feel the same. One is a vague plan. The other is a thing I can check off. If you're building your first real project, I'd say this is worth stealing: keep a project.md or equivalent where you write down your own gates. Not a backlog, not a to-do list — just the handful of conditions that have to be true before you ship. Writing them down makes them real. And it gives you something honest to post about, even in a week where the commits are thin.
Declared a formal dev/prod environment split as a launch gate — a small but concrete architectural decision marking the project's first step toward production readiness.
1/ Before I could ship anything, I had to answer a basic question I'd been ignoring: "where does this actually *run*?" The answer forced my first real architectural decision. 2/ I've been building Build-in-Public Studio entirely with AI-generated code (Claude writes it, I steer it). Works great for moving fast. Less great for knowing when something is "ready." 3/ So I declared a formal launch gate: no production deploy until there's a proper dev/prod environment split. Sounds obvious. It wasn't obvious to me until I wrote it down. 4/ The commit is literally one line in a project.md file. No new code. Just a rule I'm holding myself to. But naming it — env_split — made it real in a way that "I should probably do that eventually" never did. 5/ For anyone else building their first thing: the gap between "it works on my machine" and "it works for users" is exactly this stuff. Not glamorous. Just necessary. Now I go build the thing the gate is waiting on. /
Cleaned up PROJECT.md to match a self-imposed contract and retired the deploy_url field — a small act of keeping the project's own documentation honest.
PROJECT.md had a `deploy_url` field. The project has no deploy URL. It isn't deployed yet. That's a small thing. One field, pointing at nothing. But every time I opened that file — which is often, because it's supposed to be the source of truth for what PanelPro actually is — it was quietly asserting something false. A URL that doesn't exist yet, sitting there like a placeholder that forgot it was a placeholder. Early on I made a rule for myself: PROJECT.md is a contract, not a wishlist. It should describe the project as it stands right now, not as I imagine it one day. The reasoning was partly practical — Claude reads this file constantly when I ask it to generate or modify anything, so if the file is wrong, the AI is working from a wrong mental model. But it's also just... a discipline thing. If I let the docs drift from reality in small ways, I'll stop trusting them, and then I'll stop maintaining them, and then I've got nothing. So this week I cleaned it up. Retired the `deploy_url` field. Made the file match the contract I'd set for it. The actual change took Claude maybe ten seconds. The commit message is "chore(project-md): conform to PROJECT.md contract; retire deploy_url" — which is, objectively, a boring commit. Nothing shipped. No feature added. A line removed. But I think this kind of boring commit is actually where the project's integrity lives. The whole codebase is AI-generated. I don't write the functions, I don't write the tests. What I do write — really write, in my own voice with my own judgment — is the documentation that tells Claude what to build and why. If that layer gets sloppy, everything downstream gets sloppy. Keeping a one-line field honest isn't glamorous. It's also not nothing. The takeaway, if there is one: when you're building with AI, your docs aren't just for humans anymore. They're inputs. Treat them like code — which means keeping them true.
Cleaned up PROJECT.md to match a self-imposed contract and retired the deploy_url field — a small act of keeping the project's own documentation honest.
1/ A 100% AI-built project can still lie to you. Mine was lying in its own documentation. 2/ PROJECT.md had a `deploy_url` field. The project isn't deployed yet. So every time I opened that file, it was quietly asserting something false. 3/ I set a rule early on: PROJECT.md is a contract. It describes what the project *is*, not what I hope it'll be. A field pointing to nowhere breaks that contract. 4/ The fix was one line — retire the field. Claude made the change in seconds. But deciding to make it? That took noticing the lie in the first place. 5/ When your whole codebase is AI-generated, the docs are often the only thing written in *your* voice. Keeping them honest might be the most important work you do all week. 6/
Introduced a dev/prod environment split as a formal launch gate — the project now has a declared boundary between 'building' and 'shipping'.
I added a single line to my project file this morning. It declared an `env_split` — a formal boundary between my dev environment and a prod server. And it's the most grounded I've felt about this project in weeks. That probably sounds backwards. No new feature. No user-facing change. Just a configuration label that says "these two things are different now." But here's what I realized: before today, there was no declared moment where *building* ends and *shipping* begins. Everything just ran. The same environment I was using to experiment and break things was theoretically the same thing I'd hand to a user someday. That blurry line was quietly making me anxious without me naming it. The whole codebase for PanelPro is 100% AI-generated — I'm not a developer, Claude writes every line. So I lean hard on structure and decisions to feel like I have a handle on the project. Adding this env split wasn't something Claude prompted me to do. It came from me sitting with the project and noticing there was no formal gate between "this is me figuring it out" and "this is real." Now there is. Dev is where I mess around. Prod is the thing I'll point at actual humans. I haven't launched yet — I'm not even sure when I will [VERIFY: need to actually set up the prod server config before this gate means anything technically] — but I know what launching *means* now in a way I didn't yesterday. A launch gate sounds like something a team of engineers sets up in a sprint planning meeting. It turns out it's just a line in a file and a decision about what you're committing to. The act of drawing the line matters more than the line itself. If you're building something and you haven't done this yet — even informally — it's worth five minutes. Pick a moment that counts as "shipped." Write it down somewhere in the project. Future you will know what crossing it means.
Introduced a dev/prod environment split as a formal launch gate — the project now has a declared boundary between 'building' and 'shipping'.
1/ I added one line to my project file today and it somehow made the whole thing feel more real than any feature I've shipped so far. 2/ The line declared an "env_split" — a formal dev/prod boundary. Before this, everything just... ran. There was no concept of "this is me building" vs "this is someone else using it." 3/ Now there is. Dev is where I break things. Prod is the gate I haven't walked through yet. That distinction didn't exist in my codebase 24 hours ago. 4/ The whole project is AI-generated — I don't write the code, Claude does. But this decision? No prompt produced it. I just got tired of there being no declared moment where "building" ends and "shipping" starts. 5/ A launch gate sounds like a big-company thing. Turns out it's just a line in a file and a commitment to yourself that some environments are for users, not experiments. 6/ I don't know when I'll flip it to prod. But now I know what flipping it means. That's not nothing. 7/
Conformed to a PROJECT.md contract and retired the deploy_url field — a small but concrete act of treating internal docs as a real spec to follow.
This week I deleted one field from a config file. The commit message called it "conforming to the PROJECT.md contract," and I've been thinking about that phrasing ever since. The field was `deploy_url`. It was stale, pointing nowhere useful, and nothing in the active codebase was reading it. Removing it was the right call. But the reason I'm writing about it isn't the deletion itself — it's the sequence that led to it. I didn't notice the field was wrong and then go update PROJECT.md to match. I checked the code against PROJECT.md and found the field wasn't supposed to be there. Then I removed it. That order is everything. Build-in-Public Studio is a solo project where 100% of the code is written by Claude. I write the specs; the AI writes the implementation. That's the deal. And it works — surprisingly well, most of the time — but it has a failure mode that I keep bumping into: the spec drifts. I'll ask Claude to add something quickly, it does, and PROJECT.md doesn't get updated. Or I'll update PROJECT.md with a plan and then forget to actually enforce it when the code comes back. What I'm slowly learning is that there's a real difference between documentation and a contract. Documentation describes what exists. A contract is what you check against. For a long time, PROJECT.md was documentation — I'd update it after the fact to reflect whatever the code happened to do. Descriptive, not prescriptive. The shift toward treating it as a genuine spec — something the code has to conform to, not the other way around — is recent and still not fully habitual. But small moments like this one are how it gets reinforced. I caught a stale field because I was looking at the code through the lens of "does this match the contract?" rather than "does this seem fine?" If you're building with AI-generated code, you probably already know the model will do what you ask with impressive reliability. What it won't do is maintain the coherence of your project's intent over time. That's your job. And the only way I've found to do it is to have a document that's actually authoritative — one you're willing to enforce, not just maintain. Retiring `deploy_url` was a five-minute task. But it happened because PROJECT.md was a contract, not a README. That's the thing worth keeping.
Conformed to a PROJECT.md contract and retired the deploy_url field — a small but concrete act of treating internal docs as a real spec to follow.
1/ The commit message was "conform to PROJECT.md contract." Not "update config." Not "misc cleanup." *Contract.* That word choice was intentional — and it changed how I thought about the whole thing. 2/ I'm building Build-in-Public Studio. The whole codebase is written by Claude. I write the specs; the AI writes the code. That only works if the spec is treated like a real document, not a suggestion. 3/ This week I retired the `deploy_url` field from the project config. It was stale, unused, and quietly wrong. Small change. One field. But I didn't just delete it — I checked it against PROJECT.md first, then removed it because the contract said it shouldn't be there. 4/ That ordering matters more than it sounds. When you write the spec *after* the code, you're writing documentation. When you check the code *against* the spec, you're enforcing a contract. Completely different posture. 5/ I didn't always do this. Early on, PROJECT.md was a file I updated to reflect whatever the code happened to do. Descriptive, not prescriptive. The shift to treating it as a source of truth is recent — and honestly still in progress. 6/ If your AI is writing the code, the spec is the only thing *you* actually own. Might as well treat it like it matters. /
Added an env_split gate to separate dev from prod-server launches — a deliberate infrastructure boundary for a solo project run entirely by AI-generated code.
This week's commit was one line in a project config file. It declares something called `env_split` — a gate that separates how the app launches in development versus on a production server. That's the whole change. I'm writing about it anyway, because for a beginner running a fully AI-generated codebase, it represents something that took me a while to understand even needed to exist. Here's the context: every line of code in this project was written by Claude. I'm not a developer. I describe what I want, Claude builds it, I read the output and try to understand it well enough to know what to ask for next. That's the loop. The thing about that loop is that it works surprisingly well for features — UI, logic, data flow. But infrastructure decisions are quieter. They don't announce themselves. You don't notice you're missing an environment boundary until dev and prod start behaving differently in ways you can't immediately explain. That's roughly what happened here [VERIFY: need to pin down the exact symptom — was this a config leak, a wrong launch command, something else?]. The environments weren't cleanly separated. One launch path was doing double duty. It worked until it didn't. The fix — asking Claude to declare an explicit `env_split` gate — took minutes once I knew what to ask for. The gate checks the environment and routes the launch accordingly. Dev goes one way, prod-server goes another. They no longer share assumptions. What I keep thinking about is that the AI didn't surface this need unprompted. It wrote clean code within whatever context I gave it. The infrastructure smell was invisible to the process until I noticed the symptom and went looking. So if you're building with AI-generated code and you're also a beginner, here's the thing I'd pass along: the AI is very good at solving problems you can describe. Your job is to develop enough awareness to notice the problems that don't announce themselves. Boring, explicit boundaries — dev vs. prod, this service vs. that one — are worth declaring early, even when everything seems to be working fine. The `env_split` gate is not glamorous. It's a one-line config declaration. But it's the first real infrastructure boundary in this project, and now I know why that matters.
Added an env_split gate to separate dev from prod-server launches — a deliberate infrastructure boundary for a solo project run entirely by AI-generated code.
1/ I added a single gate to my codebase this week: if dev, launch one way. If prod-server, launch another. That's it. One decision. But it's the first time the project has a real infrastructure boundary. 2/ The whole codebase is AI-generated — I've never written a line of this myself. Claude wrote it. I describe what I want, it builds it. So when I say "I added a gate," I mean I asked for one and understood why it needed to exist. That's my job now. 3/ Before this, dev and prod were kind of the same environment wearing different hats. One misconfigured launch and you're either testing against live data or shipping broken local config to a server. The env_split gate makes that mistake harder to make. 4/ I'm a beginner. I didn't know to think about this until I was staring at a deployment that behaved differently than expected [VERIFY: need to document the exact failure mode that surfaced this]. The fix was small. The lesson was: infrastructure boundaries should be boring and explicit. 5/ If you're also building with AI-generated code — you still have to know *what* to ask for. "Make dev and prod not step on each other" is a real requirement. The AI doesn't invent that need. You do. 6/
Shipped Reddit drafting as a first-class output: per-subreddit journey drafts in their own tab, plus a user-managed subreddit catalog so new subs can be added without touching code.
Reddit is now a proper third output in my build-in-public tool, sitting alongside X threads and Indie Hackers posts. Four commits, one day, zero lines of code written by me. Here's what the week actually looked like — including the parts that weren't obvious until after. The first commit added Reddit drafting at all: pick up to 3 subreddits per moment, Claude writes a tailored draft for each one. Different headline, different framing, that sub's self-promo rules shown as a pre-post checklist. r/SaaS gets a different post than r/SideProject even if the underlying moment is identical. The prompt enforces the same honest-journey format I use for everything else — real numbers, what went wrong, one specific insight, no forced CTA. The second commit was embarrassingly obvious in retrospect. Reddit drafts were rendering *below* the X/IH tabs, which meant they visually attached to whatever tab was active. If you were on the X tab, the Reddit draft looked like part of the X post. I hadn't noticed until I actually tried to use it. Promote Reddit to its own tab: problem solved, one afternoon wasted. The third commit made the per-moment sub picker less clunky. Instead of choosing subreddits every time you generate, you pick your targets once in Settings. Every normal "Generate" run then auto-drafts Reddit posts as a second pass after the main generation finishes. If the Reddit pass fails, that step is marked failed — but it never blocks the rest of the queue. The per-moment picker stays as a manual override. The fourth commit is the one I think was most worth doing, and I almost skipped it. The subreddit list was hardcoded — a TypeScript union type of slug strings, a config file with rules, a CHECK constraint in the DB. Adding a new subreddit meant a code change and a deploy. That's wrong. It's user data pretending to be configuration. So I moved it to the DB: a subreddits table, a Settings page with full CRUD, slug validation and deduplication in the data layer. No FK so removing a sub never orphans historical drafts. The four default subs seed once on first run and then it's yours to manage. The thing I keep running into with this project: the specs matter more than I thought. All four commits were written by Claude Opus 4.8 — I don't write the code. But the open questions in those commit messages (cron generation deferred, delete behavior for removed subs, the 3-sub cap) are decisions I had to make in writing before Claude could make the right tradeoffs in code. The spec *is* the work. [VERIFY] I haven't shipped to real users yet, so I don't know if the per-sub tailoring actually produces meaningfully different posts or if it's cosmetic. That's the next thing to find out.
Shipped Reddit drafting as a first-class output: per-subreddit journey drafts in their own tab, plus a user-managed subreddit catalog so new subs can be added without touching code.
1/ Reddit is now a first-class output in my build-in-public tool — alongside X threads and Indie Hackers posts. It took 4 commits over one day. Here's what that actually looked like. 2/ The first problem: Reddit drafts were rendering *below* the X/IH tabs, so they visually merged into whatever tab was active. Easy to miss. Looked like part of the X thread. Fix: promote Reddit to its own tab. Small change, obvious in hindsight. 3/ Each moment now lets you pick up to 3 subreddits. Claude writes a separately-tailored draft per sub — different headline, different framing, that sub's self-promo rules as a pre-post checklist. r/SaaS gets a different post than r/SideProject. Same moment, different story. 4/ Then the per-moment picker felt clunky. So I added auto-generation: pick your target subs once in Settings, and every "Generate" run drafts Reddit posts automatically as a second pass. A Reddit failure marks that step failed but never blocks the main queue. 5/ The part I didn't expect to care about: hardcoded subreddit configs are a maintenance trap. The original code had a fixed slug union type — adding a new sub meant a code change, a deploy, the whole thing. That's a bad place to put what is essentially user data. 6/ So the last commit moved subreddits into the DB. Add, edit, or remove subs from Settings. No code change. The migration drops the old CHECK constraint and adds a light format check instead. No FK either, so removing a sub never orphans existing drafts. 7/ All of this — the tabs, the per-sub prompts, the auto-generation, the DB catalog — was written by Claude Opus 4.8. I don't write the code myself. I write the specs and review the output. That's the whole experiment. 8/ What I actually learned this week: the hardest part of "AI writes the code" isn't getting it to write code. It's writing specs precise enough that the right tradeoffs get made without you in the loop. The open questions in the commit messages are mine, not Claude's.
Fixed an infinite-hang bug on per-repo content generation by adding timeout guards — a classic async pitfall where a missing escape hatch meant the app just... waited forever.
There's a specific kind of bug that's worse than a crash: the kind where nothing happens. No error, no message, just a spinner that keeps spinning while you slowly realize the app has given up without telling you. That's what I had this week. Clicking "generate" on a single repo would kick off the action, hit GitHub to sync commits, call Anthropic to draft posts — and then just... wait. Forever. The client had no idea anything was wrong. Neither did I, for longer than I'd like to admit. When I dug into it (well, when I described the symptom to Claude and asked it to dig into it), the cause was almost embarrassingly simple. The "generate all" action already had timeout logic — sync raced against 10 seconds, generation raced against a longer ceiling, the whole thing had an escape hatch. But the per-repo action — the one you use when you just want to regenerate one specific repo — had none of that. It called the same underlying functions with no timeout at all. If GitHub was slow or Anthropic stalled, the action just sat there holding its breath. The fix involved a few layers. A new `withTimeout` helper that actually clears its timer when the promise resolves (a detail I would have gotten wrong). Sync now races against 10 seconds — if it loses, the app falls back to cached commits instead of failing hard. Generation races against 90 seconds, then rejects with a real error message that includes the repo name. Every Anthropic client in the codebase — there turned out to be four of them — now has `timeout: 60_000` set explicitly rather than relying on whatever the default is [VERIFY that Anthropic's SDK default is actually "wait forever"]. The part I found most useful: per-stage timing logs. Sync, identifyMoments, draftMoment — each one now logs how long it took. The next time something hangs, I'll know which of the three network calls is responsible instead of staring at a featureless spinner. On the client side, the "generate now" button has a 120-second ceiling per repo. A repo that fails or times out gets marked failed, and the loop continues to the next one. One bad repo no longer holds the whole queue hostage. None of this code is mine in the traditional sense — Claude Opus 4.8 wrote all of it. What I contributed was noticing the symptom, describing it clearly enough that the diagnosis made sense, and knowing to ask "where else in the codebase does this pattern need to apply?" That last question is where a lot of bugs like this hide. The pattern existed. It just hadn't been applied consistently. The takeaway I keep coming back to: async code doesn't fail loudly by default. It just waits. If you don't build in escape hatches, you're not building reliable software — you're building software that's polite about its failures until it isn't.
Fixed an infinite-hang bug on per-repo content generation by adding timeout guards — a classic async pitfall where a missing escape hatch meant the app just... waited forever.
1/ My app had a bug where clicking "generate" on a single repo would just... hang forever. Spinner spinning. Nothing happening. No error. Just eternal optimism from the client that something was coming. 2/ The root cause was embarrassingly simple: I had timeout guards on the "generate all" action but forgot to add them to the per-repo action. So if GitHub or Anthropic stalled, there was no escape hatch. The app just waited. 3/ The fix, courtesy of Claude Opus 4.8: - sync raced against a 10s timeout (non-fatal — falls back to cached commits) - generation raced against 90s, then rejects with a real error message - every Anthropic client now has timeout: 60_000 set explicitly 4/ Also added per-stage timing logs (sync → identifyMoments → draftMoment) so if it hangs again, I'll at least know *where* it stalled instead of staring at a spinner wondering which of three network calls betrayed me. 5/ The client-side "generate now" button now has a 120s ceiling per repo. A timed-out repo gets marked failed and the loop moves on. No more one bad repo holding the whole queue hostage. 6/ I didn't write a line of this. Claude wrote the withTimeout helper, wired it everywhere consistently, and kept tsc + lint clean. My job was noticing the symptom and describing it clearly enough that the fix made sense. The async pitfall isn't obscure. Forgetting to apply a pattern you already have elsewhere — that's just what shipping alone looks like. 7/
Chased down two separate JSONB-to-array coercion bugs in the same morning: source_ref and prepost_checklist both silently broke because Postgres JSONB wasn't being cast to an array on read — a quiet data-shape mismatch that killed source context entirely.
This whole codebase is AI-generated — Claude writes the code, I steer it. That setup works great until a bug requires you to actually understand what's happening at the database boundary. This week I got a crash course. It started with a Vercel build failure: `(a.prePostChecklist ?? []).join is not a function`. The `?? []` was supposed to be a safety net. If prePostChecklist was null or undefined, fall back to an empty array. Seemed solid. Except the value wasn't null or undefined. It was the string `'["item 1","item 2"]'`. A JSON string. Not an array. And `.join` doesn't exist on a string the way you'd want it to. The root cause: postgres.js, the database client, returns JSONB columns as raw JSON strings in certain query contexts — not as parsed JavaScript objects or arrays. The TypeScript type said `string[]`. The runtime delivered a `string`. TypeScript never complained because the mismatch only shows up when real data flows through. Once I understood that, the fix was straightforward: parse the string at the point you read from the database, check that it's actually an array, and return null if it's neither. Claude extracted that into a shared helper called `coerceStringArray`. Build passed. Then I thought to check a related column — `source_ref` on the moments table — while I was already thinking about JSONB coercion. Same bug. But no crash. No build failure. Nothing. The only way I found it was running a probe query against real data and noticing that `source_refs` came back empty on every single moment. That meant SHA badges were blank across the UI, the regenerate-draft feature had no commit context to work with, and on-demand Reddit drafts were being generated without any of the commit history attached to each moment. The bug had just been sitting there, quietly returning `[]` because an `Array.isArray`-only guard will silently yield an empty array when you hand it a string. The thing that stuck with me: the first bug crashed loudly at build time, so it was easy to find and fix. The second bug failed silently at runtime, probably for a while, and I only caught it because I went looking. I don't know how many other JSONB columns have the same latent problem. That's the uncomfortable part of inheriting a codebase — even one you technically "built" — without fully understanding every boundary where types get enforced (or don't). The takeaway I'm logging: coerce JSONB arrays at the read boundary, every time. Don't trust the declared TypeScript type. Don't trust `?? []`. The database doesn't know what shape your code expects, and the client won't always tell you when it's handing you something different.
Chased down two separate JSONB-to-array coercion bugs in the same morning: source_ref and prepost_checklist both silently broke because Postgres JSONB wasn't being cast to an array on read — a quiet data-shape mismatch that killed source context entirely.
1/ Vercel build failed with: "(a.prePostChecklist ?? []).join is not a function" That ?? [] was supposed to be a safety net. It wasn't. Two hours later I understood why. 2/ The root cause: postgres.js returns JSONB columns as JSON *strings*, not parsed arrays. So `prepost_checklist` arrived as the string `'["item1","item2"]'` — not an array. TypeScript didn't catch it. The type said `string[]`. The runtime disagreed. 3/ Fixed that. Felt good. Ran a probe on a related column, `source_ref`, while I was in there. Same bug. Silent though — no crash, no build failure. Just an Array.isArray guard that quietly returned [] every single time. Source context: gone. SHA badges: blank. Reddit drafts: missing all commit history. 4/ The difference between the two bugs: - prepost_checklist crashed loudly at build time → easy to find - source_ref failed silently at runtime → had been broken for who knows how long Silent failures are scarier. You don't know what you don't know. 5/ The fix for both: coerce at the read boundary. Parse the string, check Array.isArray, return null if it's neither. One shared helper, `coerceStringArray`, now imported everywhere JSONB arrays are read. Claude wrote the helper. I wrote the probe query that confirmed source_refs went from 0 to populated. 6/ Lesson I'm logging as BIPS-L7: "Coerce JSONB arrays at the read boundary. Don't trust the declared type. Don't trust ?? []." I have no idea what other columns have this same latent bug. That's the uncomfortable part.
Built a 'Humanize' button in two phases in one sitting: first a humanize() module wired into the scanner, then surfaced as an on-demand button on every draft card — showing how AI-generated code gets shipped in tight, incremental slices.
Last night I shipped a complete feature — spec to working UI — in about 45 minutes. I want to be honest about what that means, because it's weirder than it sounds. The feature is a "Humanize" button. Build-in-Public Studio drafts posts for you, but AI-drafted posts have a particular texture that's easy to clock. The Humanize button is supposed to sand that down. Every draft card in the app now has one, and clicking it rewires the copy through a separate humanize() module before you ever see it. Here's the part I find interesting: I didn't build it as one thing. I built it in two explicit phases, with a commit at the end of each. Phase 1 was purely logic — the humanize() module itself, wiring it into the scanner, and a TypeScript resolver hook that caused me a couple of minutes of confusion [VERIFY — worth a future post if this was actually painful]. Nothing in the UI knew any of it existed yet. Phase 2 was purely surface — take that working module and give the user a button to trigger it on demand. One commit, done. The thing is, I didn't plan this two-phase structure because I'm disciplined. I planned it because that's what writing a spec first does to you. Before any code, I wrote a short doc commit describing what the humanize() module should do and why. That doc became the shape of the work. Claude implemented against it, and the phases emerged naturally from "get the logic right" then "expose the logic." All of the code is Claude's. Every line. I am not a developer. What I wrote was the spec — the what, the why, the constraints. Claude handled the how, and it handled it fast enough that I could hold the whole feature in my head across both phases without losing the thread. The takeaway I keep coming back to: when you're working this way, the spec isn't a nice-to-have. It's the only thing you actually control. If the spec is vague, the implementation wanders and you end up with commits that don't tell a story. If the spec is tight, you get two clean phases, a legible git history, and a feature that does exactly the one thing you asked for. I didn't expect "write better specs" to be the main skill I'd be developing here. But that's what's happening.
Built a 'Humanize' button in two phases in one sitting: first a humanize() module wired into the scanner, then surfaced as an on-demand button on every draft card — showing how AI-generated code gets shipped in tight, incremental slices.
1/ I shipped a full feature in two phases inside a single 45-minute sitting — and I didn't write a single line of the code myself. Here's how that actually works in practice: 2/ The feature: a "Humanize" button that rewires AI-drafted posts to sound less like a press release. Every draft card in Build-in-Public Studio now has one. 3/ Phase 1: I had Claude write a humanize() module, wire it into the scanner, and sort out a TypeScript resolver hook. Three commits, ~35 minutes. The logic existed but nothing in the UI touched it yet. 4/ Phase 2: Surface the button on every draft card so the user can trigger it on demand. One more commit, ~10 minutes. The UI talks to the module. Done. 5/ The thing I keep noticing: AI-generated code almost forces you into tight incremental slices. You spec a small thing, you ship it, you spec the next small thing. There's no "I'll just refactor this whole layer real quick." 6/ I wrote the feature spec as a doc commit before any code. That's the part I actually authored — the what and why. Claude handled the how. 7/ If you're a non-coder building in public, this is the workflow worth stealing: write the spec yourself, let the AI implement in phases, commit after each phase. You end up with a legible history even if you can't read the code. 8/
Completed Phase 1 of Bloomr in one day: data layer (Neon + postgres.js + Resend), auth, waitlist page, and docs — all from scratch, all AI-generated
Yesterday I finished Phase 1 of Bloomr in a single day. Data layer, auth flows, a waitlist page with UTM attribution, and a full set of docs. Every line of code was written by Claude. I wrote none of it — because I don't really know how to. That's the whole experiment here. I'm not a developer pretending to be humble. I genuinely can't read a postgres.js connection config and tell you if it's right. What I can do is describe what I want, review what comes back, and push until it matches the spec. Here's what Phase 1 actually contained: a Neon Postgres data layer with postgres.js for app queries and a separate pg pool just for Better Auth (which needs its own adapter — that was a wrinkle Claude flagged and handled). Auth covers email/password with email verification, password reset via Resend, and GitHub OAuth. There's edge middleware protecting the dashboard routes, plus a second server-side session check behind it — defense in depth, Claude called it. The waitlist page captures UTM parameters from the URL on every signup and upserts on email so there are no duplicates. I even have a doc with per-channel links for X, four subreddits, and Indie Hackers, plus a reporting query to make the distribution targets falsifiable rather than vibes. The part that genuinely surprised me was the documentation Claude wrote alongside the code. SETUP-Phase-1.md is a step-by-step guide specifically for the things only I can do — setting up Neon, configuring Doppler for secrets, wiring GitHub OAuth credentials, running migrations, pointing DNS, doing a smoke test. It distinguished between "Claude can write this" and "Ben has to go click things." That separation was useful and I didn't ask for it explicitly. One thing I want to be straight about: the code is marked "code-complete, pre-deploy." I haven't deployed it yet. There's a KNOWN-ISSUES.md with a middleware deprecation warning, a missing reference doc for one dependency, and an npm audit flag. None of them are blockers, but they're real and logged. Shipping in public means saying that out loud. The takeaway from Phase 1 isn't "AI is magic." It's more specific than that: if you can write a detailed spec, think clearly about what you want, and stay in the loop on decisions, you can move fast on the scaffolding even if you can't code it yourself. The interesting question is what happens in Phase 2, when it's not scaffolding anymore — it's the actual product logic. That's where I expect the difficulty to change shape.
Completed Phase 1 of Bloomr in one day: data layer (Neon + postgres.js + Resend), auth, waitlist page, and docs — all from scratch, all AI-generated
1/ I shipped Phase 1 of Bloomr in a single day. Data layer, auth, waitlist page, and docs. All from scratch. Every line of code written by Claude. I didn't write any of it. I'm not a developer. Here's what that actually looked like. 2/ The stack came together in this order: - Neon (Postgres) + postgres.js for app queries - Better Auth for email/password + GitHub OAuth - Resend for transactional email (verification, password reset) - shadcn/ui with a custom green theme - Waitlist page with UTM attribution baked in 3/ The waitlist form was one of the parts I cared most about. Every signup captures utm_source, medium, campaign, content from the URL — then upserts to dedupe on email. So I'll actually know which channel drove signups, not just guess. 4/ Auth was the part I expected to be messy. It... wasn't? Login, signup, forgot password, reset password, GitHub OAuth, email verification, edge middleware protecting routes, server-side session check as a second layer. Claude held all of it in one context window. [VERIFY: this was Claude Opus 4.8 with 1M context] 5/ The docs Claude wrote alongside the code are what surprised me most. SETUP-Phase-1.md is a step-by-step guide for the parts *only I can do* — Neon config, Doppler secrets, DNS, smoke test. It knew what to hand off and what to document. 6/ One honest thing: the code is marked "code-complete, pre-deploy." I haven't deployed it yet. There are known issues logged too — a middleware deprecation warning, a missing reference doc, an npm audit flag. It's not perfect. It's just done enough to ship. 7/ The whole point of building this way is to see how far a non-developer can get with AI as the coder. Phase 1 answer: pretty far, pretty fast. Phase 2 is the actual product. That's where it gets harder. 7/
Made a concrete decision to lock pricing in GBP and committed it across every source of truth — a small but deliberate 'no ambiguity' moment
At some point this week I noticed Bloomr's pricing docs said USD. Bloomr is a UK product. I'm based in the UK. I have no clear memory of where USD came from — probably an early assumption that never got challenged, then copied forward into every new document. It was wrong across four places at once: the spec, the build plan, the distribution plan, and the CLAUDE.md rules file that governs how the AI approaches the codebase. Each one said USD. Each one was wrong. The fix itself was straightforward. I made the call — GBP, obviously — and asked Claude to reconcile all four in a single pass. Commit 912484b. Done in minutes. But there was one genuinely fiddly part. Bloomr is a subscription analytics tool, which means it handles customer MRR data. That data lives in Stripe accounts that belong to Bloomr's users, and those users might be anywhere. Their MRR displays in *their* Stripe currency — not mine. So I had to be clear: only Bloomr's *own* subscription pricing changes. Customer-facing MRR figures, thresholds, tier caps — those stay in whatever currency the connected Stripe account runs on. Two different things that look similar at a glance. Getting that distinction right in the docs matters more than it sounds. If the AI is going to work from these files for the next several months of builds, any ambiguity in there compounds. One fuzzy rule becomes ten inconsistent implementations. So "docs: resolve pricing currency to GBP across all sources of truth" isn't just tidying — it's load-bearing. The thing I keep noticing as a non-developer building with AI: the actual writing and reconciliation is fast. What isn't fast — what I can't outsource — is knowing what the right answer is. Claude can update four documents simultaneously and keep the subtle customer-vs-platform distinction intact throughout. It cannot tell me which currency my business runs in. That one's mine. Small decision. Glad it's locked.
Made a concrete decision to lock pricing in GBP and committed it across every source of truth — a small but deliberate 'no ambiguity' moment
1/ I had USD hardcoded as the pricing currency across 4 different docs. Bloomr is a UK product. I am a UK person. I have no idea how that happened. 2/ Yesterday I did a single pass and locked everything to GBP — the spec, the build plan, the distribution plan, the AI rules file. One currency. One source of truth. No more drift. 3/ The tricky part: customer MRR data inside Bloomr still displays in *their* Stripe currency (whatever country they're in). That stays as-is. It's only Bloomr's *own* subscription prices that were wrong. Subtle distinction, but it matters. 4/ The commit is co-authored by Claude Opus 4.8. Literally every line of this codebase was written by AI. My job was noticing the inconsistency existed and deciding what the answer was. 5/ That's probably the most underrated part of building with AI: the decisions still land on you. Claude can reconcile 4 docs in one pass. It cannot tell you which currency your business should run in. 6/
Built and deployed a holding page with waitlist capture and channel attribution before the product itself exists — intentional distribution-first sequencing
The holding page went live today. The product does not exist yet. That's intentional, and I want to write down why before I convince myself it was obvious. I've watched myself — and plenty of other first-timers — fall into the same pattern: spend three months building, then spend three panicked weeks trying to figure out where to tell people about it. Distribution becomes an afterthought because it's uncomfortable to think about before you have something to show. So you defer it. And then you scramble. I didn't want to do that this time. So before I wrote a single line of product code, I built the waitlist page. It's sitting at the root route of the app right now. It captures email and — this is the part I'm actually proud of — whatever UTM parameters come in from the URL. utm_source, utm_medium, utm_campaign, utm_content. Each signup gets tagged with where it came from, deduped on email so the same person clicking twice doesn't inflate my numbers. I also wrote a 90-day distribution plan and committed it to the repo. Not as a motivational document — as a constraint. The plan names specific channels (X, four subreddits, Indie Hackers) and specific targets, and it's linked from CLAUDE.md so it sits next to the code. There's a reporting query in docs/utm-links.md that turns the database rows into per-channel counts. The targets are falsifiable now. That felt important. Here's what I'm still uncertain about [VERIFY]: whether the UTM normalization logic handles edge cases cleanly — things like UTM params that get mangled by link shorteners, or users who click multiple links before signing up. The last-touch attribution I've got is a start, but it might mislead me if someone finds the page from two different places. Something to watch. Everything in the codebase — including all of this waitlist infrastructure — was written by Claude Opus 4.8. I'm not a developer. I wrote the spec, described what I needed, and Claude built it. That's the whole model for this project. I'm documenting it openly because I think a lot of people are in the same position I was: you have the idea, you understand the user, you just can't write the code yet. The part that surprised me about doing distribution-first: it made the product feel more real, not less. When you have a page that can accept signups, you have something at stake. If the list is empty in 30 days, that's a real signal I have to reckon with. Better to get that reckoning now than after six months of building something nobody was waiting for.
Built and deployed a holding page with waitlist capture and channel attribution before the product itself exists — intentional distribution-first sequencing
1/ I built the waitlist page before the product exists. Not because that's clever advice I read somewhere — because I needed a forcing function to actually think about distribution before I was too deep in the code to care. 2/ The page is live at the root route. It captures email + whatever UTM params are in the URL, dedupes on email, stores a normalized source field. That last part matters: I can now see *which channel* a signup came from, not just that a signup happened. 3/ I wrote UTM links for every place I plan to post: X, 4 subreddits, Indie Hackers. Each one hits the same endpoint, tags itself differently. There's a reporting query in docs/utm-links.md that turns those rows into channel-level counts. The distribution targets are now falsifiable. 4/ The whole thing — page, form, server action, docs — was built by Claude Opus 4.8. I wrote the spec. Claude wrote the code. I'm a non-technical founder learning to build in public this way. The attribution plumbing I just described? I couldn't have written it myself six months ago. 5/ The sequencing feels right: know where your people are before you build the thing you're asking them to want. If the waitlist stays empty for 30 days, that's data. Better to know now than after six months of building. 6/
Shipped a full auth stack (login, signup, password reset, GitHub OAuth) in a single session — all AI-generated, zero prior auth experience
A week ago I couldn't have told you the difference between a session cookie and a JWT. Yesterday I shipped a full auth stack — login, signup, password reset, email verification, and GitHub OAuth — in a single session. I want to be straight about what "I shipped" means here: every line of code was written by Claude (Opus 4.8). I'm a beginner. This is a 100% AI-generated codebase, and that's not a disclaimer I'm burying — it's the whole point of what I'm building in public. What I actually did was work through a detailed spec with Claude, then watch it assemble pieces I didn't know existed. Better Auth for the auth layer. Resend for transactional email. A postgres.js client configured specifically for Neon's connection pooler (prepare: false — something I would have gotten wrong immediately on my own). Edge middleware that gates the protected routes, plus a server-side session check in the layout as a second layer. Claude called that "defense in depth." I had to look up what that meant. The detail that stuck with me most was the migration setup. Better Auth manages its own core tables through its own CLI migration. My app migration runs after. Two separate systems, sequenced. I never would have designed it that way — I would have jammed everything into one migration file and probably broken something. Reading the commit notes after the fact felt like studying someone else's architecture decisions, except I'd been in the room the whole time. One thing I made a deliberate call on: Stripe Connect didn't ship. It's flagged Phase 2. That decision came from the spec Claude helped me write earlier in the project — we'd already agreed on what was in scope for Phase 1. In the moment, I didn't have to figure out what to cut. Past-me had already done it. I don't know if everything here is production-hardened. The withSession() tenancy helper — which attaches an authenticated userId to every user-scoped query instead of relying on row-level security — felt like a considered tradeoff for the Neon pooler setup, but I'm still learning enough to say that with full confidence [VERIFY]. What I can say is that it's coherent, it's documented in the code, and I understand the reasoning well enough to explain it. The takeaway, if there is one: you don't need to know how to build something to make real decisions about it. Knowing what to defer, what to ask, and how to read what gets generated back — that's a real skill, even if it's not the one I expected to be developing.
Shipped a full auth stack (login, signup, password reset, GitHub OAuth) in a single session — all AI-generated, zero prior auth experience
1/ I had zero auth experience. Yesterday I shipped login, signup, password reset, and GitHub OAuth in a single session. Every line of it was written by Claude. Here's what that actually looked like. 2/ The stack that landed: - Better Auth for the auth layer (email/password + GitHub OAuth) - Resend for transactional email (verification + reset flows) - Neon + postgres.js for the data layer - Edge middleware + server-side session check for defense in depth 3/ Two separate migration systems run in sequence — Better Auth owns its core tables via its own CLI, then my app migration runs on top. I would never have known to do that without Claude. I didn't even know what "defense in depth" meant before reading the commit notes back. 4/ The part I found most interesting: Claude added a withSession() tenancy helper instead of relying on Postgres row-level security. Every user-scoped query carries the authenticated userId at the app layer. Simpler to reason about, apparently safer on a Neon pooler. [VERIFY] 5/ What I didn't ship: Stripe Connect. It's flagged Phase 2. Knowing what to defer is its own skill — and honestly, that call came from the spec Claude helped me write, not from me in the moment. 6/ The whole codebase is AI-generated. I'm a beginner who can't write this stuff from scratch. But I can read it, ask questions about it, and make decisions about what gets built next. That's the build. More next week. /
Added Reddit as a first-class drafting target alongside X and Indie Hackers — per-sub journey drafts in their own tab, shipped as Phase 1.5c.
Reddit was always part of the plan for Build-in-Public Studio — write your moment once, get drafts for every platform you want to post on. But when I first shipped it, I cut a corner on the UI. Reddit rendered as a section *below* the X and Indie Hackers tabs, which meant it visually attached itself to whatever tab was active. If you had X selected (the default), the Reddit drafts just sat underneath and read like they were part of the X post. It was also easy to scroll past entirely. The fix was obvious once I saw it in use: Reddit needed to be a proper tab, not a dangling section. So that's what I built. The moment card now has three tabs — X thread, Reddit, Indie Hackers — and Reddit behaves exactly like the other two. The more interesting part was what Reddit drafting actually does. When you open the Reddit tab on a moment, you pick up to three subreddits from a curated list (r/SaaS, r/indiehackers, r/SideProject, r/microsaas right now). Claude writes a separate draft for each one you select. Not the same post three times with the sub name swapped — genuinely separate drafts, tuned to the culture of each community. The format I landed on: honest headline, real numbers, what went wrong, one specific insight, no forced CTA. Each draft also shows that sub's self-promo rules as a checklist you work through before posting. I don't want people using this to spam communities. The whole point is posts that actually fit where they're going. A few other details worth noting. There's a status dot on the Reddit tab — teal if any draft in there is approved, a neutral color if drafts exist but none are approved yet, nothing at all until you generate the first one. It sounds like a small thing but when you're juggling five or six moments it matters to be able to see at a glance what still needs attention. The whole Reddit section reuses the same edit / regenerate / approve / reject / history / ratings infrastructure that already existed for X and IH. No new npm dependencies. No auto-posting — the furthest it goes is a "Copy + Open in Reddit" button that drops you on the submit page with your text ready. The actual post is still your call. One thing I keep running into as I build this: the gap between "this feature exists" and "this feature is actually usable" is almost always a UI problem, not a logic problem. The Reddit generation was working fine. It just looked wrong. Twenty-four hours of iteration to move something from below the tabs to inside the tabs — and now it makes sense. All of this code was written by Claude (Opus 4.8, 1M context). I'm not a developer. I'm figuring out what's possible when you treat AI as the builder and yourself as the one who notices what's broken.
Added Reddit as a first-class drafting target alongside X and Indie Hackers — per-sub journey drafts in their own tab, shipped as Phase 1.5c.
1/ Reddit was a second-class citizen in my app. It rendered as a section *below* the X/IH tabs — so it visually looked like part of the X post. Easy to miss. Wrong. Took one day to fix it properly. 2/ Build-in-Public Studio now has three tabs: X thread → Reddit → Indie Hackers. Each moment you're drafting gets its own Reddit tab. You pick up to 3 subreddits, Claude writes a separately-tuned draft per sub. Not one post copy-pasted three times. 3/ Why per-sub drafts? r/SaaS, r/SideProject, and r/indiehackers all have different cultures, different self-promo rules, different tolerances for "look at my thing." One draft fits none of them well. So each sub gets its own. 4/ The format Claude writes to: honest headline, real numbers, what went wrong, one specific insight, no forced CTA. Each draft also shows that sub's self-promo rules as a pre-post checklist. I'm not trying to get anyone banned. 5/ The tab gets a status dot — teal if any Reddit draft is approved, neutral if drafts exist but none approved, nothing until you've generated the first one. Small detail. But at a glance you know what still needs attention. 6/ The whole thing reuses edit / regenerate / approve / reject / history / ratings that already existed for X and IH drafts. No new npm dependency. No auto-posting. Copy + "Open in Reddit" is as far as it goes — the submit button is still yours to press. 7/ 100% of the code is AI-generated (Claude wrote all of it). I'm a non-technical founder learning what's actually possible when you just... keep asking it to build the next thing. This was Phase 1.5c. Still a lot of 1.5-somethings before 2.0.
Built a user-managed subreddit catalog so new subs can be added without touching code — plus per-project Reddit sub settings that auto-generate on the dashboard.
When I built the first version of Reddit posting into this tool, I hardcoded four subreddits into a TypeScript file. A union type. A fixed list of slugs that lived in `reddit-subs.ts` and required a code change — and a full redeploy — to update. I shipped it anyway, told myself I'd fix it later, and moved on. "Later" arrived this week. The problem with hardcoded config is that it feels like a product until someone wants to add a subreddit you didn't anticipate. Then it's obviously just a file. So I spec'd out what a real solution would look like: a subreddits table in the database, a Settings UI for managing it, and enough plumbing to make the rest of the app read from the catalog instead of the hardcoded list. The migration drops the old fixed-slug CHECK constraint and replaces it with a light format check — just enough to catch obvious garbage, not so strict it rejects valid sub names I haven't thought of. No foreign key between subreddits and drafts, which was the most deliberate call in the whole design: if you delete a subreddit, historical drafts that targeted it still render fine. The sub just stops appearing as a future option. Orphaning old data to enforce referential integrity would've been the wrong tradeoff here. On top of that, I wired up per-project subreddit selection. Instead of picking target subs on every single moment, you set them once per project in Settings. Then when you hit Generate on the dashboard, it auto-drafts Reddit posts for all your chosen subs in a second pass — after the main generation succeeds, so a Reddit failure can never block the queue for your other moments. The whole thing — the migration, the data layer, the Settings UI, the generation plumbing — is Claude-written code. I wrote the spec. I made the design calls. I decided "no FK so deletes don't orphan drafts." Claude did the implementation. That's actually what I want this project to demonstrate: that a non-engineer can ship real product decisions by being precise about constraints, not by knowing TypeScript. One thing I punted deliberately: the cron path. Scheduled Reddit generation is deferred. I wanted to validate the manual flow first — make sure the per-project settings and auto-generation actually work the way I designed them before adding the complexity of background jobs. There's an open question logged. It'll get its own spec when I get there. The takeaway, if there is one: the hardcoded config you ship on day one is fine. The mistake is treating it as permanent.
Built a user-managed subreddit catalog so new subs can be added without touching code — plus per-project Reddit sub settings that auto-generate on the dashboard.
1/ I hardcoded 4 subreddits into a config file and called it a "subreddit system." This week I finally fixed that embarrassment. 2/ Before: subreddits lived in `reddit-subs.ts` as a TypeScript union type. Adding r/indiehackers meant editing code, pushing a deploy, and hoping nothing broke. That's not a product. That's a config file with delusions. 3/ Now there's a subreddits table in the DB. Add, edit, remove from Settings — no code touch, no deploy. The curated defaults seed once on first run and stay out of the way after that. 4/ The trickiest design call: no foreign key between subreddits and drafts. So if you delete a sub, it never orphans historical drafts. They still render. The sub just stops appearing as a target going forward. 5/ The second commit wired up per-project sub selection. Pick your target subs once in Settings. Every Generate run then auto-drafts Reddit posts for those subs — no per-moment picking required. 6/ The whole thing is 100% AI-written code (Claude Opus 4.8). I didn't write a single line of the migration, the data layer, or the Settings UI. What I did write: the spec. The constraints. The "no FK so deletes don't orphan drafts" decision. That's what building with AI actually looks like day to day. 7/ Still deferred: the cron path. Right now Reddit auto-generation only runs on the dashboard Generate button, not on scheduled runs. Punted that deliberately — wanted to validate the manual flow first. One thing at a time. 7/
Infinite hangs on per-repo generation — added timeout guards to kill the silent freeze that had no error, no feedback, just nothing.
There's a specific kind of bad that's worse than an error message: the infinite hang. No red text, no stack trace, just a spinner going forever while you wonder if you broke something or if you should wait longer or if the universe has simply decided to ignore you. That was my "generate drafts" button for per-repo generation. Click it. Spinner starts. Nothing happens. Ever. I didn't notice it immediately because the all-repos path worked fine — it had a 10-second race on the GitHub sync baked in. But when I wired up per-repo generation, I called the same underlying functions (`syncOneRepo` + `generateDrafts`) without any of those guards. So if GitHub was slow, or Anthropic was slow, or anything in that chain stalled, the server action just... held. No rejection, no fallback, no feedback to the client. The client spinner ticked indefinitely. Which is, genuinely, the worst possible UX. The fix came in layers. Claude (who wrote all of this code — that's not a footnote, it's the whole point of what I'm building) added a `withTimeout` helper that wraps a promise and clears its timer properly on resolution. Sync now races against 10 seconds — if it loses, it's non-fatal and falls back to cached commits. Generation races against 90 seconds and rejects with a readable message: "generation timed out for <repo>". Every Anthropic client across the codebase now has `timeout: 60_000` set at the SDK level, which I probably should have done from the start [VERIFY whether the SDK respects this uniformly]. On the client side there's now a 120-second ceiling per repo. A failed or timed-out repo gets marked as failed and the loop continues to the next one. One bad repo no longer kills the whole run. The part I'm most glad about, honestly, is the per-stage timing logs. Sync, identifyMoments, draftMoment — each one logs how long it took. The next time something hangs, I'll know exactly where it stalled instead of staring into the void. The takeaway I keep arriving at: a loud failure is a gift. An error message tells you something. A silent hang tells you nothing and costs you time and makes you question your choices. If your async code can stall, wrap it. Make it fail fast and fail loudly. Future you will be grateful.
Infinite hangs on per-repo generation — added timeout guards to kill the silent freeze that had no error, no feedback, just nothing.
1/ My app had a bug where clicking "generate" would just... hang. Spinner spinning. No error. No timeout. No feedback. Forever. Turns out I had zero timeout guards on the per-repo generation path. 2/ The all-repos action had a 10s race on GitHub sync. But the per-repo action? Called the same underlying functions with nothing. A stalled GitHub or Anthropic call just held the process hostage indefinitely. 3/ The fix, which Claude wrote: - a `withTimeout` helper wrapping the sync (10s, non-fatal — falls back to cached commits) - generation itself raced against 90s, rejects with "generation timed out for <repo>" - every Anthropic client now gets `timeout: 60_000` set at the SDK level 4/ Also added per-stage timing logs: sync / identifyMoments / draftMoment each log how long they took. So next time something hangs, I'll at least know *where* it stalled instead of staring at a spinner. 5/ On the client side: 120s ceiling per repo. If a repo fails or times out, it gets marked failed and the loop moves on to the next one. No more one bad repo killing the whole run. 6/ The thing I keep relearning: silent failures are worse than loud ones. An error message is a gift. An infinite hang is a trap. 7/ All of this is 100% AI-generated code — I described the bug, Claude diagnosed the missing timeout paths and wrote the fix. I read it, understood it (mostly), and shipped it. That's the whole workflow. /
Discovered that JSONB columns in the database were silently breaking array reads — two separate fixes in one morning to coerce source_refs and prepost_checklist back into arrays on read.
This morning I chased what looked like one bug and found two. Same root cause, different columns, different symptoms. Neither one threw an obvious error until the second one finally blew up the Vercel build. Here's what happened. The /settings page failed to prerender. The error was `(a.prePostChecklist ?? []).join is not a function`. Which is a strange thing to see when the TypeScript type for that column is `string[]`. It's declared as an array. It should be an array. The `?? []` fallback is right there. Except — the column is JSONB in Postgres. And postgres.js, the query library this project uses, returns JSONB columns as raw JSON strings in this context. So what actually arrived at the render site wasn't `["item1", "item2"]`. It was the string `'["item1", "item2"]'`. TypeScript had no way to know. The type annotation describes what you intended, not what the database driver actually handed you at runtime. The fix was straightforward once I understood it: parse the string at the read boundary, guard with `Array.isArray`, fall back to null if something unexpected comes through. One helper function, applied at the choke point in subreddits.ts. Then I wrote the commit message and noted: "moments.source_ref has the same latent bug." I went and looked. It did. Same column type, same driver behavior, same Array.isArray-only guard that silently swallowed the problem instead of surfacing it — returning an empty array every single time instead of erroring. No build failure. No log line. Just... blank SHA badges on every moment card. Zero commit context being passed to Claude on every AI regeneration. Broken quietly, for an unknown amount of time. After the fix — extract a shared `coerceStringArray` helper, apply it at the three read sites for moments — source_refs went from 0 to 1–5 populated commits per moment. Claude had been flying blind on context it was supposed to have. The thing I keep thinking about: the silent failure was worse than the loud one. The build explosion on /settings was annoying, but it told me immediately that something was wrong. The empty source_refs just... worked fine, in the sense that nothing crashed. It returned a valid empty array. The code handled it gracefully. The feature was just subtly broken in a way I wouldn't have caught without going looking. I added a note to the project's lessons file — an actual file in the repo called BIPS-L7, which is where I document patterns like this. The note is: coerce JSONB arrays at the read boundary. TypeScript types are not enforced there. The declared type is an intention, not a contract. 100% of this code was written by Claude (Opus 4.8 with 1M context). I'm not a developer. What I'm learning to do is read the output carefully enough to catch when the AI's confidence about a type doesn't match what the database is actually doing. That gap — between the type system and the runtime — is apparently somewhere I need to keep watch.
Discovered that JSONB columns in the database were silently breaking array reads — two separate fixes in one morning to coerce source_refs and prepost_checklist back into arrays on read.
1/ Vercel build failed. Error: ".join is not a function" on a column TypeScript swore was string[]. The type annotation was correct. The actual data coming out of Postgres was not. 2/ The column is JSONB. postgres.js returned it as a raw JSON string — '["item1","item2"]' — not a parsed array. TypeScript had no idea. The declared type said string[]. The runtime value said "lol, no." 3/ .join() on a string throws. So the build exploded during static prerendering, trying to render /settings against the prod DB. Classic "the type is a promise, not a guarantee." 4/ Fixed it: parse the string at the read boundary, guard with Array.isArray, else null. One choke point in subreddits.ts, three call sites covered. Then I checked the commit message: "note that moments.source_ref has the same latent bug." 5/ Sure enough. A second JSONB column. Same pattern, different table. source_refs had been silently returning [] on every DB read. SHA badges blank. Commit context missing from every AI regeneration. Broken for who knows how long — no error, just... empty. 6/ The fix was the same fix. Extract a shared coerceStringArray helper, apply it at the three moments read sites. After: source_refs went from 0 → 1–5 populated commits per moment. Context Claude had been missing the whole time. 7/ The lesson I wrote down (this is a real note in the codebase now): "Coerce JSONB arrays at the read boundary. TypeScript types are not enforced there." One JSONB column hides. Two means it's a pattern worth documenting. 8/
Committed a lesson doc (BIPS-L6) about a lint-gate failure — react-hooks v7 warnings that had to be cleared — and noted known issues in the repo for future reference. Treating the repo itself as a learning log.
This week I hit a lint gate failure and spent time actually fixing it instead of suppressing it. That sounds small. It didn't feel small while I was in it. The project uses ESLint's react-hooks v7 rules, with `set-state-in-effect` and `purity` both set to "error." When I migrated to ESLint 9's flat config format a few sessions back, six warnings got left behind — logged in KNOWN-ISSUES.md, flagged for a dedicated cleanup pass. This session was that pass. The no-suppression rule is self-imposed and intentional. The whole codebase is written by Claude (Opus 4.8, for this session). If I let the AI suppress warnings instead of fixing them, I lose the signal entirely. The lint gate is one of the few mechanisms I have to make sure the generated code is actually correct, not just working-for-now. So: six violations, no escape hatch. Here's what had to actually change. The theme toggle had a setState call inside an effect used as a mount guard — a pattern that technically works but trips the rule. Claude replaced it with `useSyncExternalStore`, which handles the hydration case natively and removes the effect entirely. Same behavior, no rule violation. Three components (the generate buttons and batch-form) had a `setElapsed(0)` call inside a timer effect. Moving it to the click handler instead — where reset logically belongs anyway — cleared the violation without touching the timer behavior. The most interesting one was a "set default date on mount" effect in batch-form. The effect existed because computing next Monday client-side causes a hydration mismatch. The actual fix was to compute it server-side in the page component and pass it down as a prop. No effect needed at all. Cleaner, and the mismatch risk is gone. I also committed a lesson doc (BIPS-L6) alongside the fix. That's a habit I've been building: every time something trips me up or forces me to understand something I didn't before, it gets written down in the repo. CLAUDE.md carries the cross-project lessons. KNOWN-ISSUES.md tracks things I've seen and chosen not to fix yet — which is different from things I haven't noticed. The takeaway I keep coming back to: writing it down changes the relationship to the problem. I went into this session thinking it was a routine cleanup. Documenting it made me articulate *why* each fix worked, not just that it did. That's the rep. I don't fully understand `useSyncExternalStore` yet [VERIFY: worth a dedicated deep-dive], but I understand it better than I did before I had to explain it in a lesson doc. The repo is the learning log. The code is almost beside the point.
Committed a lesson doc (BIPS-L6) about a lint-gate failure — react-hooks v7 warnings that had to be cleared — and noted known issues in the repo for future reference. Treating the repo itself as a learning log.
1/ My lint gate refused to pass until I fixed 6 react-hooks v7 warnings. No suppressions allowed. Here's what actually had to change — and why it was worth it. 2/ The rule: `set-state-in-effect` and `purity` both at "error". That means no suppressing warnings with a comment and moving on. Fix the code, or the build doesn't ship. 3/ The fixes were more interesting than "just add a dep array": - theme-toggle: swapped a mount guard for `useSyncExternalStore` (no setState in effect, same hydration behavior) - timer reset: moved `setElapsed(0)` out of the effect and into the click handler 4/ The sneaky one: a "set default date on mount" effect in batch-form. Moved the computation server-side instead and passed it as a prop. No effect. No hydration mismatch. Cleaner all around. 5/ I'm not an expert on any of this. Claude wrote the fixes. But I had to understand them well enough to document them — and that's the whole point of the lesson doc I committed alongside. 6/ The repo has a KNOWN-ISSUES.md. Not a shame file — a signal that I saw the problem, logged it, and chose when to fix it. That's a different thing than ignoring it. 7/ 100% of this codebase is AI-generated. The lint gate isn't there to catch my typos. It's there so the AI can't quietly ship impure components while I'm not looking. /
Shipped Reddit drafting end-to-end in a single day: per-sub journey drafts, per-project subreddit settings on the dashboard, and a user-managed subreddit catalog so new subs can be added without touching code.
Yesterday I shipped Reddit drafting start to finish in a single day. Not "started Reddit drafting" — the whole thing: per-sub tailored posts, auto-generation from the dashboard, and a user-managed subreddit catalog that doesn't require a code change to update. Four commits across about 15 hours. I want to write down how that actually happened, because from the outside "shipped a full feature in a day" sounds cleaner than it was. The first thing I merged was embarrassing in retrospect. Reddit wasn't a real tab — it was a floating section underneath the X and Indie Hackers tabs, visually attached to whichever tab was active. If you were on the X tab (the default), the Reddit section just... sat there below it, looking like part of the X post. I'd been living with that for a while. Fixed it before anything else. Sometimes the right first move is just cleaning up the thing that's been bugging you. The actual drafting commit came next. The core idea: pick up to 3 subreddits per moment, and Claude writes a separately-tailored post for each one — not the same post copied three times. Each sub has its own self-promo rules, so the UI shows a checklist before you post. Copy+Open sends you straight to r/that-sub/submit. I was skeptical the per-sub tailoring would matter much. It does. r/SaaS and r/indiehackers read differently and have genuinely different rules about self-promotion. One draft doesn't cover both. The third commit wired it to the main dashboard flow. Instead of generating Reddit posts one moment at a time, you set your target subreddits once per project in Settings, and the normal Generate run handles Reddit automatically as a second pass. If the Reddit pass fails, it marks that step failed — but it never blocks the rest of the queue. That was a deliberate choice. Reddit drafting is useful, but it's not worth holding up everything else. The fourth commit is the one I'm most happy with. Subreddits were hardcoded in a TypeScript config file. Wanting to add r/startups meant editing code and redeploying. That's fine when you're the only user, but it's not fine for long. So I moved the catalog to a database table, added a Settings UI to add/edit/remove subs, and made the seed idempotent so the default four subs stay intact on existing installs. No migration pain, no FK constraint orphaning old drafts if you delete a sub. Every line of code here was written by Claude — Claude Opus 4.8, specifically. I don't write code. What I wrote was four spec documents, one per commit, each describing what I wanted and why. Claude implemented them. I reviewed diffs, tested the UI, and wrote the next spec. I'm still figuring out what the limits of that workflow are. Complex refactors across many files seem harder [VERIFY] — the fourth commit touched 9 files and I had to be more careful about the spec. But for a focused feature with a clear boundary, it held up better than I expected. The takeaway I keep coming back to: the per-sub tailoring wasn't in my original plan for this feature. I added it because I actually use Reddit and I know one generic post doesn't work across subs. Building your own tools means you build for the real friction, not the imagined friction. That's the only edge a solo developer has.
Shipped Reddit drafting end-to-end in a single day: per-sub journey drafts, per-project subreddit settings on the dashboard, and a user-managed subreddit catalog so new subs can be added without touching code.
1/ I shipped Reddit drafting end-to-end in one day. Zero Reddit code existed at 8am. By 9:30pm: per-sub drafts, dashboard auto-generation, and a full subreddit catalog — no code change needed to add a new sub. Here's how the day went. 2/ First commit (6:20pm): Reddit wasn't even a proper tab. It was a floating section below the X/IH tabs, visually glued to whatever tab was active. It read like part of an X post. Promoted it to a real third tab before touching anything else. 3/ Second commit (8:35pm): the actual drafting. Each moment → pick up to 3 subreddits → Claude writes a separately-tailored post per sub. Not the same post copied. Each one gets the sub's self-promo rules as a pre-post checklist. Copy+Open goes straight to r/<sub>/submit. 4/ Third commit (7:30am next morning): wired it to the dashboard. Set your target subs once per project in Settings. Hit Generate — it drafts Reddit posts for all your moments automatically. Reddit failure doesn't block the rest of the queue. 5/ Fourth commit (9:35am): the one I'm most happy with. Subreddits were hardcoded in a config file. Adding r/startups meant a code change + redeploy. Now there's a DB catalog. Add, edit, remove subs from Settings. No deploy needed. 6/ The whole thing is 100% AI-generated — Claude wrote every line. I don't write code. What I do write is specs. Each of these four commits had a spec doc I wrote first, then handed to Claude. Whether that workflow scales to something more complex, I genuinely don't know yet. [VERIFY] But for a focused feature day, it held up. 7/ If you're building in public and targeting Reddit, the per-sub tailoring matters more than I expected. r/SaaS and r/indiehackers have different tones and different self-promo rules. One draft doesn't cover both. That's the thing I'd have skipped if I'd built this faster. Glad I didn't. /
API calls were hanging indefinitely with no error — tracked it down to missing timeout guards on the per-repo generate endpoint. Added guards to kill the infinite hang.
There's a special kind of frustrating bug where nothing crashes. The app just... waits. Forever. That's what I ran into this week with the per-repo draft generation in Build-in-Public Studio. You click "generate now" for a single repo, the spinner starts, and nothing happens. No error in the console. No timeout message. Just a client-side loading state ticking away while the server quietly hung waiting on a GitHub or Anthropic API call that was never going to come back. The root cause was embarrassingly simple once I found it: I had two code paths for generating drafts, and only one of them had timeout protection. The "generate all repos" action — the one I built first — races the GitHub sync against a 10-second timeout. I added that early on after hitting a stall. But when I later built the per-repo action, I just... didn't carry the pattern over. The per-repo path called syncOneRepo and generateDrafts with no timeout guards at all. If either GitHub or Anthropic got slow or stalled, the action hung indefinitely and the client had no way to know. I described the symptom to Claude — spinner runs forever, no error, happens on the per-repo path but not generate-all — and Claude spotted the asymmetry immediately. Showed me the two action files side by side and pointed to the gap. The fix ended up being a few layers: A new withTimeout helper wraps the per-repo action. The GitHub sync races against 10 seconds — non-fatal, so a timeout just falls back to cached commits. Draft generation races against 90 seconds and rejects with a readable error. Every Anthropic client now also has timeout: 60_000 set at the SDK level, which is a backstop I should have had from the start. And on the client side, there's a 120-second ceiling per repo — a failed or timed-out repo gets marked failed and the loop moves on to the next one instead of blocking everything. I also added per-stage timing logs: sync, identifyMoments, draftMoment each log how long they took. Next time something hangs, I'll see exactly which stage stalled instead of having to guess. The thing I keep relearning here is that silent failures are the hardest failures to debug. A crash is almost a gift — it points at something. An infinite spinner points at nothing. Timeouts feel like defensive over-engineering until the first time you actually need one, and then you realize they're not really about performance at all. They're about keeping your system legible. Worth noting: I don't write this code myself. Every line of the fix — the withTimeout helper, the race logic, the SDK config — was written by Claude (Opus 4.8 in this case). My job was to describe the symptom clearly enough that the right fix became obvious. That's the whole experiment this project is running: can a non-programmer build and maintain a real production app by pairing with AI on everything? So far the answer is yes, with the caveat that "describing the symptom clearly" is its own skill that takes practice.
API calls were hanging indefinitely with no error — tracked it down to missing timeout guards on the per-repo generate endpoint. Added guards to kill the infinite hang.
1/ My app had a bug where the spinner just... never stopped. No error. No timeout. No crash. Just a loading indicator running forever while the server waited on GitHub or Anthropic to respond. Here's what was actually happening. 2/ I have two ways to generate drafts: "generate all" and "generate for one repo." The generate-all path had a 10s timeout racing against the GitHub sync. The per-repo path had... nothing. I just forgot to add it. 3/ So if GitHub stalled, the per-repo action hung indefinitely. The client had no idea. The spinner kept spinning. The user (me) kept waiting. Claude caught the asymmetry when I described the symptom. I hadn't even looked at the two code paths side by side. 4/ The fix layered a few things: - a withTimeout helper wrapping the per-repo action (clears its own timer so it doesn't leak) - sync raced against 10s (non-fatal — falls back to cached commits) - generation raced against 90s, rejects with "generation timed out for <repo>" - every Anthropic client now gets timeout: 60_000 set at the SDK level 5/ Also added per-stage timing logs — sync / identifyMoments / draftMoment — so next time something hangs, the logs show exactly where it stalled instead of me guessing. 6/ The thing I keep relearning: silent hangs are worse than loud errors. A crash tells you something. An infinite spinner tells you nothing. Timeouts aren't just good practice — they're the difference between a debuggable system and a mystery. 7/ All of this is 100% Claude-written code. I don't write the implementation myself — I describe the problem, Claude writes the fix, I review and ship. That's the whole project: can a non-coder build and maintain a real app this way? So far, yes. 7/
Discovered that JSONB columns in the database were silently returning data in the wrong shape — a scalar or object instead of an array — and had to add coercion on read in two separate places to fix it. Classic 'the database gave me something I didn't expect' beginner trap.
My Vercel build failed with this error: `(a.prePostChecklist ?? []).join is not a function`. That made no sense to me. I had typed the column as `string[]` in my schema. I was using the `?? []` fallback right there in the expression. Arrays have `.join`. This should be fine. It wasn't fine, because it wasn't an array. What was actually coming out of the database was `'["item one", "item two"]'` — a JSON string that *looks* like an array, not a parsed JavaScript array. postgres.js was returning my JSONB column as a raw string in this context. My TypeScript type said `string[]`. The database said `string`. TypeScript believed me and never complained. Then `.join()` hit a string at runtime and crashed. The fix was mechanical once I understood the problem: parse the string if it's a string, check `Array.isArray` on the result, return the filtered array or null. Claude extracted that into a shared `coerceStringArray` helper in `src/lib/json.ts` so I'd only ever write it once. But here's the part that bothered me more: while fixing that crash, Claude noticed a second JSONB column — `moments.source_ref` — had the exact same problem. Same root cause, same bad assumption. Except this one *never crashed*. There was an `Array.isArray`-only guard that silently returned `[]` whenever the string came back from the database. No error. No warning. Just empty arrays on every DB read, which meant blank SHA badges on every moment card, and the "regenerate draft" feature was running with zero commit context every single time. [VERIFY: exact duration this was silently broken is unclear from the commits] One bug announced itself loudly and stopped the build. The other one sat there quietly degrading the product. The thing I'm taking away from this: TypeScript types do not protect you at the database boundary. You can declare a column as `string[]` all you want — that declaration lives in your application code. What actually comes back from the database depends on the driver, the query shape, sometimes the version of the library. JSONB is stored as JSON, and whether your driver deserializes it for you is not guaranteed. You have to coerce at the read choke point, explicitly, every time. I wouldn't have known to look for the second bug. I wouldn't have known to extract a shared helper instead of just fixing the one site I'd found. I wouldn't have known to mark all the DB-reading pages as `force-dynamic` to stop them from prerendering against the production database at build time. All of that was Claude. The whole codebase is AI-generated — that's not a caveat, it's the point of the project. I'm a beginner learning what can go wrong by watching how Claude fixes things and then asking why. This was a good week for that.
Discovered that JSONB columns in the database were silently returning data in the wrong shape — a scalar or object instead of an array — and had to add coercion on read in two separate places to fix it. Classic 'the database gave me something I didn't expect' beginner trap.
1/ My Vercel build failed with: "(a.prePostChecklist ?? []).join is not a function" That's not possible. I literally typed `string[]` in the schema. The array is right there. It wasn't an array. 2/ postgres.js was returning my JSONB column as a raw JSON *string* — `'["item one", "item two"]'` — not a parsed array. TypeScript said string[]. The database said string. Nobody argued. .join() exploded at runtime. 3/ The fix sounds simple: parse the string, check Array.isArray, coerce or return null. One helper function. But I'd already shipped the same bug in a second column — moments.source_ref — which was silently returning [] on every DB read. No crash. Just... blank SHA badges and missing commit context. Invisible for weeks. [VERIFY: exact duration unknown] 4/ Two separate JSONB columns. Two separate places where "the TypeScript type said array" and "the database returned something else" and the mismatch just... sat there. The first one crashed a build. The second one quietly degraded the whole feature. 5/ What I learned: TypeScript types don't enforce what comes out of a database. JSONB is stored as JSON — getting it back as a parsed value or a string depends on the driver, the query, sometimes the context. You have to coerce at the read boundary, every time. Claude extracted a shared coerceStringArray helper into src/lib/json.ts. One place to fix. No more copies. 6/ I wrote zero of this code myself. Claude Opus caught the second latent bug while fixing the first one and named the pattern in a lesson file so I'd recognize it next time. That's the whole project: I'm learning what can go wrong by watching Claude fix it and asking why. /
Wrote a 90-day distribution plan and wired it into CLAUDE.md so the AI assistant has the go-to-market context baked in from the start.
This week I wrote a 90-day distribution plan for Bloomr. That's not the interesting part. The interesting part is where I put it. I linked it from CLAUDE.md. If you haven't built with an AI assistant as your primary coder, CLAUDE.md is basically the file Claude reads at the start of every session. It's your persistent context layer — architecture decisions, conventions, what's in scope, what's off-limits. Most people treat it as a technical document. I've started treating it as something closer to an onboarding doc for a very fast, very literal contractor who forgets everything between shifts. So when I finished the distribution plan, the obvious move was to stick it in docs/ and move on. Instead I asked: does Claude need to know this? And the answer was pretty clearly yes. Here's why. The plan specifies that Phase 1 is almost entirely word-of-mouth, seeded through a specific local community in Johnson County. That's not a detail you'd infer from the codebase. But it has real implications for what features get built first, what the onboarding flow should feel like, what "good enough to ship" actually means for this phase. If Claude doesn't know we're trying to get 20 real humans in one town to use this thing before we do anything else, it'll optimize for the wrong things — or at least ask questions I'd have to re-answer every session. I'm not a technical founder. I can't look at a PR and spot a bad abstraction. What I can do is write context. And one thing I've noticed over a few months of building this way is that the quality of the AI's output tracks pretty closely with the quality of the context I give it. Vague brief, vague result. Specific brief with real constraints and real goals, noticeably sharper result. The 90-day plan is probably wrong in places — I'd want to revisit it after the first real month of user data [VERIFY]. But it's written down, it's linked from the file Claude reads every time, and that means it's part of the conversation whether I remember to bring it up or not. The takeaway, if there is one: if you're building with AI tools and you're not technical, writing good context *is* your leverage. It's the thing you can actually get better at. I'd rather have a mediocre distribution plan that's wired into the AI's working memory than a perfect one sitting in a doc nobody reads — including the AI.
Wrote a 90-day distribution plan and wired it into CLAUDE.md so the AI assistant has the go-to-market context baked in from the start.
1/ I wrote a 90-day distribution plan for my app — then immediately put it inside CLAUDE.md so the AI building the thing would know about it from the start. Not a doc that sits in a folder. A doc that shapes every conversation going forward. 2/ CLAUDE.md is the file Claude reads at the top of every session. It's how you give an AI coder persistent context: what the project is, what the constraints are, what matters. Most people put architecture notes there. I added go-to-market. 3/ The reasoning: if the AI is writing 100% of the code, and the code serves a distribution strategy, then the AI probably needs to know the distribution strategy. Otherwise you get technically correct work that's pointed at the wrong thing. 4/ Small example of why this matters — the plan specifies that Phase 1 growth is word-of-mouth through a specific local community (Johnson County). That's not obvious from the code. But it should change what features get prioritized. 5/ I'm a non-technical founder. I can't review PRs. What I *can* do is write context. Turns out writing context is a real skill. The better I get at explaining what I'm building and why, the better the AI output gets. 6/ The 90-day plan is rough and probably wrong in places [VERIFY after first 30 days]. But it's written down, it's linked, and the AI has it. That felt like a more important unlock than any single feature this week. 6/
Committed to GBP pricing across all docs and config files before writing a single line of product code — a small decision that forced real thinking about who the product is for.
Before I'd written a single line of product code for Bloomr, I made a change that touched every document in the repo: I swapped USD for GBP, everywhere, in one pass. That sounds like a footnote. It turned out to be one of the more clarifying things I've done so far. The backstory is embarrassing in a very familiar way. When I was setting up the initial docs — the spec, the build plan, the pricing tiers — I never consciously chose a currency. USD just appeared, the way USD always appears when you're copying patterns from American SaaS content. Nobody decided it. It was the default, and defaults are invisible until something forces you to look at them. What forced me to look was getting to the point where I was about to start on the actual subscription config. Claude (Opus 4.8, which is writing 100% of this codebase — that's the whole premise of this project) was ready to start building. But I realised that if I let it proceed with USD baked into the source-of-truth docs, I'd be getting USD baked into every downstream decision too. The model inherits what the docs say. It doesn't second-guess the currency any more than it second-guesses the variable names. So I stopped and made the call: GBP. I'm based in the UK, building for UK-first, and the founding pricing should reflect that. Commit 912484b, docs only, no product code changed. The reconciliation itself was interesting because it wasn't quite as simple as a find-and-replace. Bloomr's own subscription prices — what customers pay to use the product — are GBP. But the MRR figures Bloomr *displays* to those customers come from their connected Stripe accounts, which could be in any currency. Those had to stay untouched. So there's actually a meaningful distinction living in the codebase now: Bloomr's prices in GBP, customer data in whatever Stripe says. I hadn't thought that through until I sat down to fix it. That's the thing about a stray assumption — it doesn't just affect the line it's on. It prevents you from thinking clearly about the thing underneath it. The takeaway I keep coming back to: a currency symbol buried in a config file is a product decision in disguise. It encodes who the product is for, where they live, what they'll see at checkout. Getting that wrong early doesn't break anything immediately. It just means every subsequent decision is built on a slightly wrong foundation. Now it's right. And I can actually start building.
Committed to GBP pricing across all docs and config files before writing a single line of product code — a small decision that forced real thinking about who the product is for.
1/ Before writing a single line of product code for Bloomr, I spent time changing one thing across every doc and config file: USD → GBP. It sounds trivial. It wasn't. 2/ The codebase had silently assumed USD from the start. Pricing tiers, founding prices, the subscription config — all dollars. Nobody decided that. It just... happened. 3/ Fixing it meant touching CLAUDE.md, the product spec, the build plan, and the distribution plan in one pass. Claude (Opus 4.8) did the reconciliation. I made the call. 4/ The interesting part: customer MRR figures stay in whatever currency their Stripe account uses. Only Bloomr's OWN subscription prices are GBP. That distinction matters and I hadn't thought it through until I was forced to. 5/ A currency assumption buried in a doc is a product decision in disguise. Who is this for? Where do they live? What do they expect to see on a checkout page? Committing to GBP before the first feature branch made me answer that properly. 6/ The whole codebase is AI-generated — Claude writes every line. But "which currency" is exactly the kind of thing Claude can't decide for me. It just inherits whatever I put in the docs. Garbage in, garbage out. Except it's not garbage, it's just... the wrong country. 7/
Built and deployed a holding page with waitlist capture that tracks which channel each signup came from — a concrete, deliberate distribution move on day one.
The first real commit to bloomr isn't a feature. It's a waitlist page — but one built with a specific obsession baked in from the start: knowing where every single signup came from. Here's the thing I kept coming back to while planning this. Distribution is the part most solo builders (myself included, in past projects) treat as an afterthought. You build, you ship, you post a few places, you get some signups, you feel good. Then you try to remember what you posted and where, and you can't really reconstruct it, and so the next week you just do the same thing again and hope. I didn't want that. So before I wrote any product code, I wrote the distribution plan — a 90-day doc that lives in the repo, linked from CLAUDE.md so it's hard to ignore. And then I built the holding page to make that plan falsifiable. What that means in practice: every link I share has UTM parameters in it. There's a unique link for X, for each of the four subreddits I'm targeting, for Indie Hackers. When someone clicks one and signs up, their email lands in the database alongside a normalized `source` field. The waitlist form pulls the UTM values straight from the URL via a server action, dedupes on email, and stores where they came from. There's also a reporting query sitting in docs/utm-links.md. Not a dashboard — just a query. Something I can run and immediately see: 12 signups from r/SaaS, 3 from X, 1 from IH. That's the thing that makes the distribution targets real rather than aspirational. I want to be upfront about what I don't know yet. [VERIFY: I need to confirm whether the upsert is keeping the first source or overwriting with the latest — the intent was first-write wins, but I haven't stress-tested it.] Small thing, but it changes what the data actually means. The entire codebase so far is written by Claude — Claude Opus 4.8, to be specific, with 1M context. That's the whole premise of how I'm building bloomr. I'm not a strong developer. I can read code, I can reason about architecture decisions, I can write a clear brief. Claude does the implementation. The UTM server action, the upsert logic, the holding page layout — I described what I wanted, Claude asked one clarifying question about deduplication, and then it built the whole thing. The reason I'm starting with the waitlist-before-features approach isn't because some framework told me to. It's because I know from experience that if I skip it, I'll build in the dark. At least now, when I find out nobody's signing up, I'll know which channel to stop wasting time on first.
Built and deployed a holding page with waitlist capture that tracks which channel each signup came from — a concrete, deliberate distribution move on day one.
1/ Before writing a single feature, I built the waitlist page to track *where* every signup comes from. Day one of bloomr. The whole codebase is written by Claude. Here's what that first commit actually looks like. 2/ The holding page has a form. But the form isn't just "enter your email." Every link I share — on X, on 4 subreddits, on Indie Hackers — has its own UTM parameters baked in. utm_source, medium, campaign, content. All of it lands in the database row alongside the email. 3/ So when someone signs up, I know: was it the r/entrepreneur post? The X thread? The IH comment? Not in a "I'll figure it out later" way. There's a reporting query sitting in docs/utm-links.md right now that makes the distribution targets falsifiable. 4/ I've seen too many builders post everywhere, get 40 signups, and have no idea what worked. Then they do it all again next week the same way. 5/ The UTM logic is a server action that upserts on email — so if you click two of my links before signing up, you don't get double-counted. First source wins. [VERIFY: confirm first-write vs last-write behavior in the upsert] Claude wrote all of it. I told it what I wanted. It asked one clarifying question about deduplication logic, then built the whole thing. 6/ I don't know if this app will work. But I'll know exactly which channel told me it wasn't working first. That feels like the right thing to have in place before anything else./
Shipped a full auth stack (login, signup, password reset, GitHub OAuth) in one session — entirely AI-written, as a self-described beginner who couldn't have done this alone.
Last night I shipped a complete auth stack for Bloomr in a single session. Login, signup, password reset, GitHub OAuth, email verification — the whole thing. And I want to be straight with you: I could not have written any of it myself. I'm a beginner. Not "beginner" as in "I've been coding for two years and I'm being modest." Beginner as in, before I started this project, I couldn't have explained what edge middleware does or why you'd want two separate database clients in the same app. I still can't fully explain all of it. But it's running, and it's mine, and every line was written by Claude Opus. Here's what the session actually produced. There's an edge middleware layer that does a fast cookie check to block unauthenticated users from hitting /dashboard, /goals, /settings, and /connect. Then the server layout does a real session validation on top of that — defense in depth, two gates instead of one. I didn't design that pattern. I described what I wanted to protect, and Claude designed it. Reading the commit notes afterward was how I learned it was even called "defense in depth." The data layer is the part that surprised me most. Better Auth — the auth library I'm using — needs its own Postgres adapter, which means it uses the pg package. But all my app queries run on postgres.js, which handles connection pooling differently (lazy connections, no prepared statements, because Neon's pooler needs it that way [VERIFY]). Claude held that distinction clean across the entire session. Two clients, two purposes, no bleed-over. I didn't know enough to ask for that specifically. It just did it. What I did contribute: the product decisions. Bloomr needs GitHub OAuth because the target users are in tech. Password reset has to be email-backed, not magic-link-only, because some people just want the familiar flow. The green accent color. The name. The spec I've been writing for weeks that gave Claude enough context to make sensible calls on everything I hadn't thought to specify. That's the thing I keep coming back to. There's a real gap between "person with a vision" and "person who can ship," and for most of my life that gap felt uncrossable without years of engineering skill I didn't have. What I'm finding out — slowly, publicly, with a lot of reading commit notes after the fact — is that the gap is narrower than I thought if you're willing to be honest about where you're standing. I'm documenting all of it. The wins, the parts I don't fully understand, the moments where I ask Claude to explain what it just built. If you're curious what it looks like to build something real this way, I'll keep showing the seams.
Shipped a full auth stack (login, signup, password reset, GitHub OAuth) in one session — entirely AI-written, as a self-described beginner who couldn't have done this alone.
1/ A few months ago I couldn't have told you what "middleware" meant. Last night I shipped a full auth stack — login, signup, password reset, GitHub OAuth, email verification — in a single session. Every line of it written by Claude. Here's what that actually looked like. 2/ The commit list reads like something a senior engineer would spend a week on: - Edge middleware gating protected routes - Better Auth + GitHub OAuth - Resend-backed password reset flow - Zod + react-hook-form validation - Two-database setup (postgres.js for app queries, pg for Better Auth) 3/ That two-database thing is worth pausing on. Better Auth needs its own Postgres adapter (pg). All my app queries run on postgres.js. Claude held that distinction across the whole session without me asking it to. I didn't even know why it mattered until I read the commit notes after. 4/ The session also set up defense in depth I wouldn't have thought of: Edge middleware does a fast cookie check to block unauthenticated requests early. The server layout does a real session check anyway. Two gates, not one. I didn't design that. I just described what I wanted to protect. 5/ What I *did* do: write the spec. Decide the product needed GitHub OAuth. Choose Resend for email. Pick the green accent color. Name the project Bloomr. The gap between "person with a vision" and "person who can ship" just got a lot narrower. I'm living in that gap right now. 6/ I'm building Bloomr fully in public. 100% AI-generated code, documented honestly — including the parts I don't fully understand yet. If you're curious how far a non-engineer can get with Claude as a co-author, follow along. I'll keep showing the seams. 7/
Session 11.5: fixing PDF content errors and cross-reference mismatches in an AI-generated plan generator — a specific, fiddly debugging session with a concrete outcome.
Session 11.5 of building PanelPro in public. No new features. Just a debugging session that turned out to be two separate categories of wrong at once. The project is an AI-generated plan generator for DIY panel building. 100% of the code is written by Claude — that's the whole point of the build. My job is to review, direct, and learn. This session taught me something specific about what reviewing AI output actually looks like when the output is a PDF. **Cluster A: the labels lied** Somewhere back in Session 4, I reorganised the eaves convention. Front = HIGH, back = LOW. The geometry updated. The dimension callouts updated. The gutter slope arrow updated correctly. But a handful of label strings — the ones in the roof panel layout, the eaves detail wall label, the construction note — still said "front-low / back-high." The old wording. The code compiled. The PDF rendered without error. It just told the reader the wrong thing. A builder following this plan would see the slope arrow pointing one way and the caption above it pointing the other. String bugs are invisible to tests that only check whether a function returns without throwing. You have to actually read the output. **Cluster B: the cross-references were frozen in time** This one was more embarrassing in retrospect. Every "see page N" reference in the document was a hard-coded integer — written when the doc was 24 pages. The doc is now 31 pages. So "Base options: see page 23" was pointing at the wrong page, and had been since the document grew past that. The page footers were already correct — they derive from a running counter. The body text just... wasn't. The fix Claude wrote is a `computePageRefs()` function that walks the same draw sequence the renderer uses and returns the actual resolved page number for each section. Now every cross-reference derives its number from the same source of truth as the footer. It can't freeze again. **What I actually learned** I've been thinking about AI code review as mostly a logic problem — does the algorithm do the right thing, are the types right, does it handle edge cases. This session reminded me that there's a whole other layer: does the output *say* the right thing. Labels, captions, notes, cross-references. Stuff that's easy to write once and never look at again. Two fail-first tests now cover both clusters. One checks that every label agrees with the numeric heights and that flow arrows point at the low wall. One reads each section's real footer page and asserts every cross-reference matches it. Both were red before the fix. 315/315 green after. KNOWN-ISSUES item 6: resolved. The plan is more trustworthy now, even though nothing structural changed. Sometimes that's the whole session.
Session 11.5: fixing PDF content errors and cross-reference mismatches in an AI-generated plan generator — a specific, fiddly debugging session with a concrete outcome.
1/ My AI-generated plan generator was lying in two different ways at once — and I didn't notice for weeks. Both bugs were invisible until someone actually read the PDF. 2/ Bug cluster A: label strings in the roof layout and eaves detail still said "front-low / back-high" — the old convention from before Session 4 rearranged everything. The geometry was correct. The dimensions were correct. The words were wrong. 3/ So a builder reading the PDF would see a gutter slope arrow pointing the right way, a construction note saying water drains to the back wall — and a caption above it contradicting both. String bugs are sneaky because the code compiles fine. The PDF renders fine. It just says the wrong thing. 4/ Bug cluster B was worse in a different way: every "see page N" cross-reference in the document was a hard-coded number. Frozen at the 24-page version of the doc. The doc is now 31 pages. So "see page 17" pointed nowhere useful. 5/ The fix for B wasn't just updating the numbers — that would freeze them again. Claude wrote a computePageRefs() function that walks the same draw sequence the PDF renderer uses and returns each section's actual page number. Now "see page N" derives the number every time. 6/ Two fail-first tests to lock it in: one checks that every label agrees with the numeric heights and that the flow arrow points at the low wall. One reads each section's real footer page and checks every cross-ref matches. Both were red before the fix. 315/315 green after. 7/ The whole session was string-level — no geometry, no BOM, no draw order touched. But it took real focus to find, and the plan is genuinely more trustworthy now. 100% of this code is written by Claude. I'm learning what "reviewing AI output" actually means in practice. It means reading the PDF.
Users can now add subreddits to their catalog without a code change — a small settings feature that removes a whole class of dev-required work
Until yesterday, every subreddit in my app was hardcoded. Not hardcoded in a config file someone could edit easily — hardcoded as a TypeScript union type. `SubSlug` was a literal type that listed every valid subreddit by name. Want to add r/SideProject? Open `reddit-subs.ts`, add it to the union, fix the type errors that cascade, redeploy. Every single time. I'd been living with that because it worked well enough when I only had four subreddits. But "works well enough" has a shelf life, and I finally hit it. The fix is a settings page where you can add, edit, or remove subreddits from a catalog — no code change, no deploy, no TypeScript. It took one commit to ship, and that commit touched 9 files. The whole codebase is AI-generated — Claude wrote this, like it's written everything else in the project. My job was to describe what I wanted, read what came back carefully, and ask questions when I didn't understand something. I learned what "idempotent" actually means from reading Claude's inline comments on the seed function. The part that made me think hardest was deletion. If you remove a subreddit from the catalog, what happens to drafts that were already generated for it? There are a few ways to handle this: block the delete, cascade-delete the drafts, or just... let the drafts exist without a live subreddit attached. I went with the third option — no foreign key constraint, so removing a sub never orphans old drafts. They still render. They just can't be selected for new work. That felt right for a tool where the history of what you posted matters. There's also a seed step for the original four subreddits I'd curated. On first run, they get loaded into the new database table with `ON CONFLICT DO NOTHING`, so it's safe to run repeatedly without duplicating anything. Flag-guarded so it only runs once. I would not have known to structure it that way on my own — I would have just run an insert and hoped for the best. Zero new dependencies. TypeScript and lint came back clean on the first try, which almost never happens when you're touching 9 files. The takeaway for me: hardcoded things feel fine until the moment they don't. The cost of making something configurable is almost always worth paying earlier than you think — especially when you have an AI that can write the data layer while you focus on understanding what the data layer should actually do.
Users can now add subreddits to their catalog without a code change — a small settings feature that removes a whole class of dev-required work
1/ Before today, adding a subreddit to my app meant editing a TypeScript file, redeploying, and hoping I didn't break anything. Now it's a settings page. No code. No deploy. Here's why that took more work than it sounds. 2/ The old system had subreddits hardcoded in `reddit-subs.ts` — a fixed TypeScript union type called `SubSlug`. Every valid subreddit was literally baked into the type system. Adding r/SideProject meant a code change. Always. 3/ The fix: move subreddits into a database table. Obvious in hindsight. But "obvious" meant: a new migration, a full data layer (get/create/update/delete), slug validation, deduplication, and swapping out `SubSlug` references across 9 files. Claude wrote all of it. I read all of it. 4/ The tricky part was deletion. If you remove a subreddit, what happens to drafts that were already generated for it? I didn't want a foreign key that would block deletes or orphan rows. Solution: no FK constraint. Removed subs still render their historical drafts. They just can't be selected going forward. 5/ There's also a seed step — the original four curated subreddits get loaded into the DB on first run, once, with ON CONFLICT DO NOTHING so it's safe to run repeatedly. Flag-guarded. Idempotent. I learned what idempotent means from reading Claude's comments. 6/ The whole thing is zero new dependencies. `tsc` and lint clean on the first try, which almost never happens when you're touching 9 files at once. Small feature. Bigger than it looked. The kind of thing that makes a product feel real instead of hardcoded.
Reddit drafting shipped end-to-end: per-project subreddit settings, auto-generation on the dashboard, and drafts in their own dedicated tab
Reddit is now a proper third platform in Build-in-Public Studio. This week I shipped it end-to-end across four commits, and I want to write down what that actually looked like — because "shipped Reddit support" is a useless summary of what happened. The first problem was embarrassingly obvious once I saw it. Reddit drafts were rendering as a loose section *below* the X and Indie Hackers tabs. Which meant they visually attached to whatever tab was active — usually X, since that's the default. So a Reddit draft looked like it was part of the X thread. Easy to miss, confusing to read. The fix was to make Reddit a real tab: X → Reddit → Indie Hackers. That's it. But it changed how the whole thing feels. The second problem was that subreddits were hardcoded. There was a `reddit-subs.ts` config file with a fixed list of slugs, and a database CHECK constraint to match. Want to add r/microsaas? Edit the file, redeploy. That's fine when you're just prototyping, but it's the kind of thing that quietly breaks your own workflow. So I moved subreddits to a user-managed catalog in the database. Add, edit, or remove them from Settings with no code change. The schema migration drops the old constraint and adds a light format check instead. Removing a subreddit never orphans existing drafts — they still render, they just don't have an active entry in the catalog anymore. The third problem was friction in the generation flow. You used to pick subreddits per-moment, every time. Now you set your target subs once per project in Settings, and the normal Generate run auto-drafts Reddit posts for all of them as a second bounded step. If the Reddit pass fails, it marks that step failed — but it never blocks the main generation queue. The per-moment override is still there if you want it. Each draft is written separately per subreddit. Claude reads that sub's self-promo rules and writes accordingly — honest headline, real numbers, one specific insight, no forced CTA. Copy+Open drops you straight into r/<sub>/submit with the right URL. The detail I didn't expect to care about: the status dot on the Reddit tab. Teal if any draft is approved or posted. Neutral gray if drafts exist but none are approved. Nothing at all until the first draft is generated. It's a tiny thing. But it makes the tab feel like it belongs rather than like a feature that got bolted on. All of this — the schema migrations, the data layer, the generation logic, the timeout handling, the UI — was written by Claude Opus 4.8. I'm a beginner. I didn't write the code. What I'm learning is how to describe a problem well enough that the code writes itself. That's the actual skill I'm building here.
Reddit drafting shipped end-to-end: per-project subreddit settings, auto-generation on the dashboard, and drafts in their own dedicated tab
1/ Reddit went from "tacked on below the X tab" to a proper third platform in Build-in-Public Studio this week. Four commits. End to end. 100% AI-written code. Here's what actually shipped: 2/ First problem: Reddit drafts were rendering as a loose section *below* the X/Indie Hackers tabs. So they visually looked like part of the X post. Easy to miss. Confusing to read. Fix: Reddit is now its own tab. X → Reddit → Indie Hackers. 3/ Second: subreddits were hardcoded in a config file. To add r/microsaas you'd edit `reddit-subs.ts` and redeploy. Now there's a subreddits catalog in the DB. Add, edit, remove from Settings. No code change needed. 4/ Third: you used to pick subreddits per-moment, manually. Now you set target subs once per project in Settings, and the normal Generate run auto-drafts Reddit posts for all of them. Reddit failure never blocks the main generation queue. 5/ Each draft is tailored per subreddit — Claude reads that sub's self-promo rules and writes accordingly. Honest headline, real numbers, one specific insight. No forced CTA. Copy+Open drops you straight into r/<sub>/submit. 6/ The part I didn't expect to care about: the little status dot on the Reddit tab. Teal if any draft is approved/posted. Neutral if drafts exist. Nothing until the first one. Tiny detail, but it makes the tab feel like it belongs. 7/ All of this — schema migrations, data layer, UI, generation logic, timeout handling — written by Claude Opus 4.8. I didn't write a line of the code. I'm learning what's possible by watching what it builds. 7/
Infinite hang on per-repo generation killed by adding timeout guards — the kind of silent failure that's brutal to debug
There's a special kind of awful in a bug that gives you no information. No error message, no stack trace, no log line. Just a spinner that spins forever while you sit there wondering if you should wait longer or if something has clearly gone wrong. That was my per-repo generation button. Click it, watch the spinner, wait, wait more, eventually give up. Every time. It took me longer than I'd like to admit to understand what was actually happening. The short version: one part of my codebase — the "generate all" action — had a 10-second timeout racing against the GitHub sync call. The per-repo action, which I'd wired up separately, had no timeout at all. So when GitHub or Anthropic had a slow moment, the whole thing just... hung. Forever. The server-side request stalled, the client spinner kept going, and nothing anywhere said anything was wrong. Claude (the AI that wrote this codebase — that's kind of the whole premise of this project) fixed it by writing a `withTimeout` helper. It wraps any async call and rejects after a specified duration. Sync gets 10 seconds, non-fatal, falls back to cached commits. The actual generation step gets 90 seconds, and if it blows past that you get a real error: "generation timed out for <repo>". Every Anthropic client call now also has a 60-second ceiling baked in at the client level. The browser adds its own 120-second ceiling per repo on top of that, and if any single repo fails or times out, it marks that one as failed and moves on to the next instead of stopping the whole run. The other thing that got added: per-stage timing logs. Sync, identifyMoments, draftMoment — each one logs how long it took. That's the fix for the next time something hangs. Instead of staring at a spinner with no information, I'll at least be able to see which stage it stalled at. The takeaway I keep running into, building this without really knowing what I'm doing: **silent failures are the hardest bugs to debug.** An error message is genuinely a gift. It tells you something. An infinite hang tells you nothing, and you end up spending all your time just trying to figure out what question to even ask. Every call to an external service — GitHub, Anthropic, anything — probably needs a timeout. I didn't know that from first principles. I know it now because I got stuck staring at a spinner for way too long. [VERIFY whether there are other code paths I missed that still have no timeout — I think this covered them all, but I'm not certain.] Whole fix was one commit. No new dependencies. The kind of change that looks small in the diff and feels enormous when you actually test it and the spinner stops spinning.
Infinite hang on per-repo generation killed by adding timeout guards — the kind of silent failure that's brutal to debug
1/ My app had a bug where clicking "generate" would just... spin forever. No error. No timeout. Just an infinite loading state until you gave up and refreshed. Took me embarrassingly long to understand why. 2/ The root cause: one code path had a 10s timeout on GitHub sync. A different code path — the per-repo one — had no timeout at all. So if GitHub or Anthropic stalled, the request hung forever. Silently. 3/ The fix Claude wrote: a `withTimeout` helper that wraps any async call and rejects after N seconds. - Sync: 10s (non-fatal — falls back to cached commits) - Generation: 90s (fatal — rejects with "generation timed out for <repo>") - Every Anthropic client now has a 60s hard ceiling too 4/ Also added per-stage timing logs: sync → identifyMoments → draftMoment. So next time something hangs, I can actually see *where* it stalled instead of staring at a spinner and guessing. 5/ The client-side piece: a 120s ceiling per repo in the browser. If a repo fails or times out, it marks that one failed and keeps going through the rest. Before: one stalled repo killed the whole run. Now it just skips it. 6/ What I keep learning: silent failures are the hardest bugs to debug. An error message is a gift. An infinite hang gives you nothing. Every async call that touches an external service probably needs a timeout. I just didn't know that until I got burned by it. 7/
JSONB fields stored as objects in Postgres were silently breaking source context and checklists — two separate coercion fixes landed the same morning to restore array reads
The Vercel build failed with: `(a.prePostChecklist ?? []).join is not a function`. That `?? []` was supposed to be the fallback. The thing you write so this *can't* happen. But it failed anyway, and I spent a while staring at it before Claude traced the actual cause. postgres.js — the database client we're using — returns JSONB columns as JSON strings, not parsed arrays. So `prepost_checklist` was arriving as the string `'["draft intro", "check flair"]'` instead of an actual array. TypeScript had declared it `string[]`. Nothing at the boundary enforced that. The `?? []` check only fires if the value is null or undefined — a non-empty string passes right through, then crashes on `.join`. The fix for that one was mechanical once you understood it: coerce at the read boundary in `subreddits.ts`. Parse the string, confirm it's actually an array, otherwise return null. Three functions needed the patch. Done. But then the commit note said: *"moments.source_ref has the same latent bug."* I hadn't noticed. `source_ref` is also a JSONB column, also returning as a string, and the guard in place was `Array.isArray` only — which silently produced an empty array on every single DB read. No error, no crash, just quietly wrong. The SHA badges showing which commits were linked to a moment? Blank. The context fed into AI-regenerated drafts? Empty. On-demand Reddit drafts missing their commit and note material entirely. All of it broken, probably for a while, in a way that was easy to miss because nothing threw. Fix #2: extract a shared `coerceStringArray` helper into `src/lib/json.ts` so there's one canonical place this logic lives and we stop copying it. Apply it at all three `source_ref` mapping sites. After the fix, `relevantCommits` went from 0 matches per moment to 1–5 matches per moment. [VERIFY exact range — this came from a ground-truth probe during the fix] Both fixes landed the same morning. The thing I keep sitting with: TypeScript is supposed to catch type mismatches. But there's a gap between what the type system believes and what actually comes out of the database driver at runtime. The type says `string[]`. The driver hands you a string. TypeScript shrugs. This is apparently a known behavior with JSONB in postgres.js, and now it's in our bug log as a named lesson: *coerce JSONB arrays at the read boundary, every time, no exceptions.* I'm a non-technical founder. I did not know JSONB did this. Claude wrote both fixes, traced the connection between two bugs, and documented the root cause clearly enough that I actually understand it now. That's not just code generation — that's the thing that makes building this way viable for someone like me.
JSONB fields stored as objects in Postgres were silently breaking source context and checklists — two separate coercion fixes landed the same morning to restore array reads
1/ Vercel build failed. The error: "(a.prePostChecklist ?? []).join is not a function" That ?? [] was supposed to be the safety net. It wasn't. 2/ Root cause: postgres.js returns JSONB columns as JSON *strings*, not parsed arrays. So prepost_checklist arrived as '["item one", "item two"]' — a string that looks like an array. The type says string[]. Postgres disagrees at runtime. 3/ Fix #1: coerce at the read boundary in subreddits.ts. Parse the string, guard with Array.isArray, else null. Three spots: getSubreddits, getSubredditBySlug, createSubreddit. Then the commit note says: "moments.source_ref has the same latent bug." 4/ Sure enough. source_ref is also JSONB. Also returning as a string. The Array.isArray-only guard was silently yielding [] on every DB read. Result: blank SHA badges, missing commit context, on-demand drafts with no source material. Broken for who knows how long. 5/ Fix #2: extract a shared coerceStringArray helper in src/lib/json.ts. Apply it at all three source_ref mapping sites. After the fix, relevantCommits went from 0 → 1-5 matches per moment. Both fixes landed the same morning. Claude wrote both. I did not know JSONB did this. 6/ The lesson that's now living in our bug log: coerce JSONB arrays at the read boundary, every time, no exceptions. TypeScript won't save you here. The declared type is string[] but nothing enforces that at the DB layer. The string walks right past it. 7/ I'm a non-technical founder building this entire codebase with Claude. Moments like this are why "AI just writes the code" undersells what's actually happening — it's also reading the runtime evidence, connecting two bugs, and writing the postmortem. Still figuring out what that means. Building in public while I do.
Session 11.5: tracked down and fixed incorrect cross-references in AI-generated plan PDFs — a concrete example of catching subtle content errors in generated output.
The whole codebase is AI-generated — Claude writes everything, I review and steer. That's the project. And mostly it works surprisingly well. But this session was a reminder that "it compiles and looks right" is not the same as "it's correct." I was going through the plan PDFs the system generates and noticed something off. A cross-reference in one section was pointing to the wrong part of the document. Not a broken link, not a missing page — just quietly, confidently wrong. The kind of thing that looks fine on a skim but falls apart the moment a real user tries to follow it. Once I saw one, I started checking others. There were [VERIFY: multiple] instances of the same class of error spread across the generated output. The AI had apparently built the references based on an earlier draft structure that had since shifted, and nothing in the generation pipeline caught the mismatch. The frustrating part is that this isn't a dramatic failure. There's no stack trace, no error message. The PDFs rendered fine. The content read fine. The errors only surface if you're actually following the document as a user would — tracing a reference, expecting it to land somewhere meaningful. A pure code review wouldn't catch it. What I had to do was go through the output manually, cross-check the references against the actual section structure, and flag the ones that had drifted. Then work with Claude to regenerate the affected sections with the corrected pointers. The insight I'm taking from this: AI-generated content errors aren't always loud. The subtle ones — internally consistent but factually wrong relative to the final structure — are the ones that will embarrass you in front of a real user. For any generated document with internal references, you need a verification pass that simulates how a reader actually navigates it, not just a check that the output exists and looks plausible. Still a beginner at all of this. But I'd rather catch it in session 11.5 than after someone hands the PDF to a client.
Session 11.5: tracked down and fixed incorrect cross-references in AI-generated plan PDFs — a concrete example of catching subtle content errors in generated output.
My solar panel planning app generates multi-page PDF documents — full construction plans with dimensions, build stages, material lists, elevation diagrams. The whole thing is AI-generated code; I write zero of it myself. I'm not a developer. This week I closed a bug that had probably been sitting there for months without either of us noticing. The document started out at 24 pages. Over several sessions it grew to 31. Throughout the body text there are cross-references: "see page 17 for the stages overview," "base options are covered on page 23," that sort of thing. Those page numbers were hard-coded literals. When the document grew, the footers updated automatically — they derive from a running counter — but the body references didn't follow. They just... froze. So "see page 17" now points to the middle of something unrelated. The actual stages overview is on page 24. Nobody reading the PDF would necessarily know the references were wrong. They'd just flip to page 17, find something unexpected, and assume they misread. That's the thing I keep learning about AI-generated content: the errors that are hardest to catch aren't the ones that break something visibly. They're the ones that look fine. The document rendered perfectly. All 31 pages were there. The cross-references sounded authoritative. The numbers were just wrong. There was a second cluster of bugs in the same review. Elevation captions and label strings were still using the old "front-low / back-high" convention from before Session 4, when the UI had a different orientation model. The actual geometry had been updated — front is now HIGH, back is LOW, water flows toward the back eaves — but several hand-authored label strings hadn't followed. The gutter slope arrow, the construction note, the eaves-detail wall label. All describing water flow in the wrong direction. Same category of problem: one part of the system updated, the content that references it didn't. The fix Claude built for the page references is the part I find most satisfying. Instead of correcting the hard-coded numbers to the current values (which would just freeze again next time the doc grows), it introduces a computePageRefs() function that walks the same dynamic sequence the renderer uses. Every cross-reference now derives its page number. It structurally cannot go stale. And there are now two fail-first tests. One asserts that the label convention is consistent with the numeric height values — if they ever disagree, the test breaks. One reads each section's actual footer page and asserts every cross-reference resolves to it. Both were proven red before the fix, green after. 315 tests passing. One KNOWN-ISSUES item closed. The takeaway I'm sitting with: when you're working with AI-generated output, careful review of the content — not just "does it run," but "does it say the right thing" — is a real and non-trivial job. The code can be clean, the tests green, the document rendering perfectly, and you can still have a PDF that's been pointing readers to the wrong pages for months. Catching that is the work. I'm getting better at it slowly.
Session 11.5: tracked down and fixed incorrect cross-references in AI-generated plan PDFs — a concrete example of catching subtle content errors in generated output.
1/ The AI generated a 31-page PDF plan document for my solar panel app. It also silently embedded the wrong page numbers throughout it — for months. 2/ The doc started at 24 pages. It grew to 31. But all the "see page N" references in the body text were hard-coded literals, frozen at the old page count. "See page 17" — that section is now on page 24. "See page 10" — now page 14. Nobody caught it. Not me, not the AI, not the tests. 3/ That's the thing about AI-generated content errors: they look fine at a glance. The document rendered, the pages existed, the references sounded authoritative. The numbers were just... wrong. 4/ Cluster B of the fix: a new computePageRefs() function that walks the same dynamic sequence the renderer uses, so every "see page N" derives its number rather than hard-coding it. It literally cannot re-freeze now. 5/ There was also a separate label bug (Cluster A): strings in elevation captions still said "front-low / back-high" — the pre-Session-4 convention — while the actual geometry had already flipped to front=HIGH / back=LOW. Water flows toward the low eaves. The gutter is on the back wall. The labels said the opposite. 6/ Both bugs lived in the same category: the AI writes content that references other content, and when either side changes, the references don't automatically follow. Geometry updated. Labels didn't. Page count grew. Cross-refs didn't. 7/ The fix I'm most happy with isn't the correction itself — it's the two fail-first tests that now assert consistency. Label must agree with the numeric height value. Cross-ref must resolve to the section's actual footer page. Claude wrote those tests too. 8/ 315/315 green. One KNOWN-ISSUES item closed. I'm a non-coder building 100% with AI. This session was a reminder that reviewing generated output carefully is its own skill — maybe the main skill. /
Added Reddit as a third drafting platform (per-subreddit journey posts), expanding beyond X and Indie Hackers, then immediately had to fix tab routing to keep all three separate.
Yesterday I added Reddit as a third platform to Build-in-Public Studio, the tool I'm building to help solo developers draft posts about their work. By that evening I had already shipped a fix because the first version was broken in a way I only noticed once I actually looked at it. The feature itself is: you pick up to three subreddits — r/SaaS, r/indiehackers, r/SideProject, r/microsaas — and Claude writes a separately-tailored draft for each one. Journey format, honest headline, real numbers, what went wrong, one specific insight. Each draft also comes with that subreddit's self-promo rules as a pre-post checklist, because the last thing I want is to auto-generate something that gets a builder banned for spam. No auto-posting. Probably never will be. The whole codebase is 100% AI-generated by Claude. That's not a caveat — it's the entire point of the project. I'm not a developer. I'm learning as I go, and Claude is writing all the code while I figure out what I actually want the thing to do. What happened with the Reddit launch is a decent illustration of how that dynamic works. Claude got the logic right on the first pass: per-sub drafts, the checklist, the subreddit rules config, reusing the existing edit/regenerate/approve flow. Solid. But it rendered Reddit as a section below the X and Indie Hackers tabs rather than as its own tab. So visually, Reddit just hung off the bottom of whatever tab was active, which was usually X. It read like a confused extension of the X post. I didn't catch it during prompting. I caught it when I opened the browser and something felt wrong. That moment — "this doesn't look right" — is apparently still my job, even when I'm not writing a single line of code myself. The fix was one more prompt and one more commit: promote Reddit to a proper third tab, add a status dot that mirrors the behavior of the X and Indie Hackers dots, remove the standalone below-tabs section. X thread → Reddit → Indie Hackers, three equal platforms. The thing I keep noticing is that Claude handles complexity well — the database schema change, the per-sub caching, the ESLint migration that got bundled into the same commit [VERIFY: that was a pre-existing issue it cleaned up along the way]. What it doesn't have is a sense of what something looks like to a human using it for the first time. That gap is where I actually contribute something. Shipping a feature and then fixing how it presents the same night isn't a failure mode I'm embarrassed about. It's just what building looks like when you're learning what you want by seeing what you built.
Added Reddit as a third drafting platform (per-subreddit journey posts), expanding beyond X and Indie Hackers, then immediately had to fix tab routing to keep all three separate.
1/ I added Reddit as a third drafting platform to my build-in-public tool yesterday. Then immediately broke the UI and had to fix it the same night. 2/ The first version rendered Reddit as a section *below* the X / Indie Hackers tabs. So it visually stuck to whatever tab was active — usually X — and just looked like a weird appendage to the X post. 3/ Easy to miss, easy to confuse. The fix was obvious in hindsight: Reddit needed to be its own tab, not a footnote. X thread → Reddit → Indie Hackers. Three first-class platforms. 4/ The whole codebase is AI-generated (Claude). What's interesting is that Claude got the feature logic right on the first pass — per-subreddit drafts, self-promo rule checklists, separate tone per sub. The *layout* is what it missed. 5/ I'm not a developer. I can't always tell when a UI decision is wrong until I look at it in the browser and something feels off. That "feels off" instinct is apparently still my job, even when Claude writes all the code. 6/ Current Reddit setup: pick up to 3 subreddits (r/SaaS, r/indiehackers, r/SideProject, r/microsaas), get a separately-tailored draft for each one, with a per-sub checklist before you post. No auto-posting, never will be. 7/ The tab also gets a status dot — teal if any Reddit draft is approved, neutral if drafts exist, nothing until you've generated one. Small detail, but it keeps the three platforms visually consistent. 8/ Shipping a feature and then immediately fixing how it looks the same night is probably the most honest summary of how I build things right now. /
Added a /api/health endpoint purely to diagnose a DB connection issue — a concrete example of how AI-generated code still produces real production debugging sessions.
There's a version of the "AI writes all my code" story that sounds like smooth sailing — you describe what you want, Claude produces it, you ship it. No friction. That's not what this week looked like. I ran into a database connection issue. Something was failing, but I didn't know where in the chain. Was it the connection string? The pool configuration? An environment variable that wasn't making it through? I had no idea, and staring at the logs wasn't telling me much. So I asked Claude to add a /api/health endpoint — a simple diagnostic route that would attempt a DB connection and report back on what it found. One commit: "debug: add /api/health endpoint to diagnose DB connection." This is a completely ordinary debugging move. Senior engineers do this. Junior engineers learn to do this. It's not clever, it's just methodical. And the fact that I needed it is the whole point of what I want to say here. People sometimes assume that because Claude generates my code, I'm somehow insulated from the normal messiness of software development. I'm not. The bugs still show up. The DB still goes sideways. The environment variables still get misconfigured. What's different is that when I need a diagnostic tool, I can describe it and have it exist in a few minutes rather than looking up the syntax for the fourth time. The debugging session itself? Still mine. Still required me to read the output, form a hypothesis, and decide what to do next. Claude can write a health endpoint, but it can't watch the response come back and recognize what the error actually means in context. That part I have to do. I think this is the more honest version of the "beginner building with AI" story. It's not that the hard parts disappear. It's that the hard parts turn out to be the parts that were always hard — understanding what's broken, not writing the code to check.
Added a /api/health endpoint purely to diagnose a DB connection issue — a concrete example of how AI-generated code still produces real production debugging sessions.
1/ I write zero code myself. Claude writes 100% of it. And I still spent a chunk of my morning debugging a database connection issue like everyone else. 2/ The symptom: something was wrong with the DB connection. I didn't know if it was the connection string, the pool, the env vars, or something else entirely. 3/ My debugging instinct (well, Claude's): add a /api/health endpoint that hits the DB and reports back. A tiny diagnostic probe so I could see what was actually happening vs. what I assumed was happening. 4/ That's it. One endpoint. Commit message: "debug: add /api/health endpoint to diagnose DB connection." 5/ The thing I keep learning: AI-generated code isn't a shortcut past production problems. It's a shortcut past *writing* the code to solve production problems. The problems themselves show up anyway. 6/ Honestly that feels right. The hard part of building was never the typing.
Hit Vercel's 60-second function limit mid-build and had to rearchitect GitHub sync across multiple commits — capping at 10s, then splitting to per-repo calls to stay under the ceiling.
Yesterday morning I got a silent failure I didn't understand. I clicked "Generate Drafts," watched the spinner, and nothing came out. No error message I could act on. Just... nothing happened. After some digging, I found it: Vercel Hobby has a 60-second hard limit on serverless function execution. My generate endpoint was doing everything in one shot — pull all repos from GitHub, hand the results to Claude, write the drafts to the database. On localhost that whole trip takes maybe 20 seconds. On Vercel it was dying somewhere past the ceiling and leaving no useful trace. Worth being honest: I didn't know this limit existed. The whole codebase is AI-generated — I'm not a developer, and I'm learning the infrastructure constraints the same way I'm learning everything else, which is by running into them at speed. My first instinct was a band-aid. Cap the GitHub sync at 10 seconds, commit it, see if that's enough. It wasn't. Syncing was only a fraction of the total time. Claude generation pushed the call well past 60s regardless. So I had to actually change the shape of the architecture. Instead of one function call that processes everything, the button now fires one call *per watched repo*, sequentially. Each call syncs that single repo from GitHub, then runs draft generation scoped to just that repo's activity. The whole thing finishes each chunk in around 50 seconds — comfortably under the ceiling — and shows per-repo progress in real time so you can see it working. The cron job that runs overnight mirrors the same loop, so the behavior is consistent whether a user clicks the button or the scheduler fires. Three commits, roughly two hours of work. The first moved Octokit out of claude.ts because bundling it there was causing a separate problem I'd been ignoring. The second was the 10-second cap. The third was the real fix. There was also a bug hiding inside the old structure that I only discovered when restructuring: notes with no linked repo — things jotted down outside of any specific project — were being silently skipped during generation. The per-repo loop now includes unlinked notes in every pass, so nothing gets dropped. I still don't have a complete mental model of how Vercel's execution environment works [VERIFY: whether Pro tier raises or removes the 60s ceiling]. What I do have is a working button and a better instinct: if something can be broken into smaller independent units, it probably should be, even before you hit a wall that forces you to.
Hit Vercel's 60-second function limit mid-build and had to rearchitect GitHub sync across multiple commits — capping at 10s, then splitting to per-repo calls to stay under the ceiling.
1/ Vercel killed my function mid-build last night. 60-second limit. I didn't know that was a thing. 2/ My "Generate Drafts" button was doing everything in one call: sync all repos from GitHub, then run Claude on the results. Worked fine locally. On Vercel Hobby, it just... died. 3/ First fix: cap the GitHub sync at 10 seconds. Committed it, tested it. Still exploding — because syncing was only part of the work. Claude generation pushed the total well past 60s. 4/ So I had to actually rethink the shape of the thing. Instead of one big call, fire one call *per repo*. Each call syncs that repo, then generates drafts scoped to it. Each one stays under ~50s. 5/ Three commits across two hours: - b06ca0a: move sync out of claude.ts so Octokit stops bundling wrong - 85a1981: cap sync at 10s as a band-aid - 614a59b: blow it up and do it right — per-repo loop, sequential, with real-time progress 6/ The per-repo approach also fixed a bug I didn't know existed: notes with no linked repo were being silently skipped. Now they're included in every per-repo generation pass. 7/ I don't fully understand Vercel's execution model yet [VERIFY: whether Pro tier raises this ceiling or removes it]. But I do understand that "works on my machine" is a sentence that will keep humbling me. 7/
Completed Phase 1 of Bloomr in a single day: deps, data layer (Neon + postgres.js + Resend), auth, holding page, and docs — all AI-generated, all shipped before writing a line of product logic.
Yesterday I completed Phase 1 of Bloomr in a single day. Auth, database layer, holding page with waitlist capture, and docs. Five commits. No product logic whatsoever — that's all Phase 2. I want to be upfront about something: I'm a beginner developer. I didn't write any of this code by hand. Every line came from Claude (Opus 4.8, 1M context). That's not a disclaimer — it's the whole point of what I'm building in public here. The commit order ended up being a clean story: deps first, then the data layer, then auth, then the holding page, then docs. Each piece was a foundation for the next, and it actually held together without me having to go back and patch things. I wasn't sure it would work that way. The data layer is probably the part that surprised me most. Neon for serverless Postgres, postgres.js as the query client (configured correctly for Neon's pooler — lazy connection, `prepare: false`), Better Auth handling email/password and GitHub OAuth, Resend sending verification and reset emails, and a `withSession()` tenancy helper that attaches the authenticated user ID to every app query. There was even a deliberate decision in there I had to understand: Better Auth needs its own Postgres adapter (`pg`), so the project ends up with two Postgres libraries — but all app queries still go through postgres.js. That's the kind of thing I would have gotten confused about and just added a second library carelessly. Claude flagged it in the commit message as a deliberate deviation. The holding page does more than I expected too. It's not just a "coming soon" screen — it captures UTM parameters from the URL (source, medium, campaign, content), upserts signups so there are no duplicates, and stores a normalized source field. There's already a reporting query written so I can actually measure which channels are driving signups. The docs even include pre-built UTM links for X, four subreddits, and Indie Hackers. That's the other thing worth saying about this workflow: Claude writes the documentation as it goes. PLAN.md, a step-by-step setup guide for the parts only I can do (connecting Neon, configuring Doppler, deploying to Vercel), a known issues file, a session log. When I start a new session tomorrow, there's real context to pick up from. I still had to review every decision. I had to understand enough to say "yes, that's right" or "no, that's wrong." I couldn't write this code from scratch — but understanding it felt like the right bar to hold myself to. Phase 1 was clearing the runway. The actual product starts now.
Completed Phase 1 of Bloomr in a single day: deps, data layer (Neon + postgres.js + Resend), auth, holding page, and docs — all AI-generated, all shipped before writing a line of product logic.
1/ I shipped an entire auth + data layer + holding page for my SaaS today. Zero product logic. Zero hand-written code. 5 commits. One day. Here's what "Phase 1 complete" actually looks like when Claude does the building: 2/ The commit order tells the story: deps → data layer → auth → holding page → docs Each step unlocked the next. The whole thing held together on the first pass. I didn't expect that. 3/ The data layer alone would have taken me a week solo: — Neon (serverless Postgres) + postgres.js client — Better Auth with email/password + GitHub OAuth — Resend-backed email verification + password reset — A tenancy helper so every query carries the userId 4/ The holding page isn't just a "coming soon" screen. It captures waitlist signups with UTM attribution — source, medium, campaign, content — and dedupes on email. There's already a SQL reporting query so I can see which channels actually convert. 5/ The thing I keep noticing: Claude doesn't just write code. It writes the docs too. PLAN.md, SETUP.md, KNOWN-ISSUES.md, a session log. When I hand off to the next session, there's actual context to hand off. 6/ I'm a beginner. I couldn't have written any of this from scratch. But I understood every decision well enough to review it — and that felt like the right bar. Phase 2 is the actual product. This was just clearing the runway. 7/
Locked in GBP as the pricing currency across every source of truth in the project — a small but telling decision about who Bloomr is actually for.
There's a type of assumption that doesn't feel like a decision. It just sits there, invisible, because you never consciously made it. For Bloomr, that assumption was USD. I wasn't building for a US audience specifically. I hadn't thought carefully about it at all. But when you start typing out pricing tiers and founding offers, your fingers just... go to dollars. It's the default. The water everyone swims in when they're building software products on the internet. This week I caught it. The commit is small — four files, one pass, swap the currency symbol — but what it actually represents is me answering a question I'd been avoiding: who is Bloomr for? The answer is UK-based Stripe users, at least to start. GBP is now the source of truth for Bloomr's own subscription pricing and founding tiers. That's locked into CLAUDE.md (the rules file that governs how Claude generates code and docs), the product spec, the build plan, and the distribution plan. There's one distinction worth spelling out, because I had to think it through myself: Bloomr's own prices are GBP, but the MRR figures Bloomr *tracks for its users* still display in whatever currency their connected Stripe account uses. Those are two different things — what you pay for Bloomr, versus what Bloomr shows you about your own business. I wanted to make sure the fix didn't collapse that distinction. The whole codebase is AI-generated. Claude Opus 4.8 wrote essentially all of it, based on specs and rules I put together. That setup works well for a lot of things. But Claude can't tell me who my customer is. It can't decide whether I'm building for a solo SaaS founder in Manchester or one in Austin. That decision had to come from me, and then I had to make sure it was actually written down somewhere Claude would see it — which is why the CLAUDE.md rule update mattered as much as the spec change. The thing I keep noticing: the earlier in a project you settle these questions, the cheaper they are. Swapping a currency assumption in four markdown files is a ten-minute job. Swapping it after you've built a checkout flow, written landing page copy, and onboarded a handful of early users on the wrong tier pricing is a much worse afternoon. Bloomr is for UK founders tracking MRR on Stripe. That's now in the docs. Costs nothing to write down, saves a lot later.
Locked in GBP as the pricing currency across every source of truth in the project — a small but telling decision about who Bloomr is actually for.
1/ At some point every product has to answer: who is this actually for? For Bloomr, that answer just got baked into the code. 2/ The docs had a USD assumption sitting quietly in the pricing sections. Nobody put it there deliberately — it's just what you default to when you're not thinking about it. This week I went through and swapped it all to GBP. 3/ Four files touched: the AI rules file (CLAUDE.md), the product spec, the build plan, the distribution plan. One currency. One pass. It sounds trivial. It's not. 4/ The subtle bit: Bloomr's OWN subscription prices are now GBP. But customer MRR figures — the data Bloomr tracks FOR its users — still display in whatever currency their Stripe account uses. Two different things. Easy to confuse. 5/ The USD default wasn't a decision. It was an assumption that would've quietly shaped everything downstream — pricing tiers, founding offers, the mental model of who the early customer is. Changing it now costs one commit. Changing it after launch costs a lot more. 6/ The whole codebase is AI-generated (Claude wrote it). But decisions like this — who is Bloomr for, where do they live, what currency do they think in — those still have to come from somewhere. That part isn't automated. 7/
Built and launched a holding page with waitlist capture and channel attribution tracking before the product itself exists — a deliberate distribution-first move documented in a 90-day plan.
The first real thing I shipped for Bloomr isn't the product. It's a holding page with a waitlist form and UTM tracking baked in — and I built it on purpose, before writing any product code. Here's the thinking. I've seen enough build-in-public postmortems to notice a pattern: someone spends 4 months building something genuinely useful, launches it, and then discovers they have no idea where to find the people who need it. Distribution becomes a scramble. It usually doesn't go well. So I wrote a 90-day distribution plan first. Specific targets, specific channels — X, four subreddits, Indie Hackers. Each channel gets its own UTM link. The holding page captures those UTM params from the URL and stores a normalized source field alongside the email when someone joins the waitlist. The signup action dedupes on email, so if the same person clicks from two different places I don't accidentally count them twice. I also added the reporting query directly into a docs/utm-links.md file, so when I want to know which channel is actually working I can just run it. The plan has targets. The data makes those targets falsifiable. That felt important — I didn't want a distribution plan that was just vibes. The whole thing was built by Claude (Opus 4.8 this time, the commit even has the co-author line). I'm not a developer. I couldn't have written the server action myself. But I understand what it does, I made the decisions about what to track and why, and I wrote the plan it's executing against. That division of labor is basically the whole thesis of this project. What I'm less sure about: whether "distribution-first" is actually the right call or whether I'm just procrastinating on the hard part by doing the part that feels strategic. Building the waitlist infrastructure is genuinely easier than building the thing people are waiting for. I'm aware of that. The honest answer is I don't know yet. The 90-day plan is real, the tracking is real, and the targets are falsifiable — so at least I'll have a clear answer in a few months rather than a feeling. If you've done a distribution-first launch before, I'd genuinely like to know what surprised you.
Built and launched a holding page with waitlist capture and channel attribution tracking before the product itself exists — a deliberate distribution-first move documented in a 90-day plan.
1/ I wrote a 90-day distribution plan before writing a single line of product code. Then I built the holding page to match it. Here's what that actually looks like in practice: 2/ The page has one job: capture waitlist emails and remember exactly where each signup came from. Every channel gets its own UTM link — X, 4 subreddits, Indie Hackers. The source gets stored on signup. 3/ That means when I post somewhere and get 12 signups vs 2, I'll know. Not a vibe. An actual number I can act on. The distribution plan has targets. The UTM data makes them falsifiable. 4/ The server action dedupes on email (so the same person clicking from two places doesn't inflate numbers) and normalizes the source field. I didn't know what "upsert" meant 6 weeks ago. Claude wrote this. I understand it now. 5/ The thing I keep coming back to: most solo builders ship the product, then panic about distribution. I'm trying to do it the other way. The holding page IS the first real deliverable. Whether that's smart or cope, I genuinely don't know yet. Ask me in 90 days. 6/
Shipped a full auth system (login, signup, password reset, GitHub OAuth) in one session with zero hand-written code — all Claude-generated, as a self-described beginner.
Last night I shipped a complete auth system for Bloomr. Login, signup, password reset, GitHub OAuth, email verification, protected routes with edge middleware — the works. I wrote zero lines of code. Claude Opus 4.8 wrote all of it. I want to be specific about what that actually means, because "AI wrote my code" sounds like a shortcut and I don't think that's the right frame. Here's what went into this session. There are three commits. The first installs the dependency stack and sets up shadcn/ui with Bloomr's green theme. The second wires up the data layer: a postgres.js client configured for Neon's connection pooler, a withSession() helper that threads the authenticated userId into every user-scoped query (app-layer tenancy instead of row-level security), and Better Auth handling email/password flows plus GitHub OAuth over its own separate pg connection. Resend backs the email verification and password reset templates. The third commit is the UI: /login and /signup with react-hook-form and zod validation, the forgot/reset password pages, a protected /dashboard placeholder that confirms sign-out works, and edge middleware that gates /dashboard, /goals, /settings, and /connect on the session cookie — with a second server-side session check in the layout as a backup. That's a lot. It shipped in one session. What I want to talk about is the part that didn't come from Claude: knowing what to ask for, in what order, and how to tell if the answer was right. I had to understand that Better Auth wants to own its own database tables and run its own migration CLI separately from my app migrations — before I could tell Claude to structure it that way. I had to know that Neon's connection pooler doesn't support prepared statements, so the postgres.js client needed prepare: false. I had to read the output, recognize what was load-bearing, and ask the right follow-up questions when something looked off. None of that is writing code. But it's not nothing either. I'm a genuine beginner. A month ago I was fuzzy on what a session cookie actually did. The "Co-Authored-By: Claude Opus 4.8" line in every commit isn't a disclaimer I'm hiding in the footer — it's the central fact of how this project works. The whole point is to find out how far a non-technical founder can get if they're honest about using AI as the actual builder, not just as autocomplete. So far: pretty far. Stripe Connect is flagged as Phase 2. There are 100 founding member slots seeded in the database. The waitlist table has UTM columns. The shape of the product is getting real. If you're building something similar — or if you're skeptical this approach produces anything worth trusting — I'm building in the open. The commits are there. Come poke at it.
Shipped a full auth system (login, signup, password reset, GitHub OAuth) in one session with zero hand-written code — all Claude-generated, as a self-described beginner.
1/ I just shipped a full auth system — login, signup, password reset, GitHub OAuth, email verification, protected routes — and I wrote zero lines of code. Claude wrote all of it. I'm a beginner. This is what that actually looks like. 2/ Here's what landed in one session: - Edge middleware gates /dashboard, /goals, /settings on a session cookie - /login + /signup with email/password and "Continue with GitHub" - /forgot-password + /reset-password backed by Resend - Signup triggers an email verification flow before you're in 3/ The data layer underneath it: - postgres.js for all app queries (lazy client, Neon pooler config) - Better Auth handling its own tables separately via its own migration CLI - withSession() helper that threads userId into every user-scoped query — app-layer tenancy instead of RLS 4/ Two separate migration runners, two separate Postgres clients, one auth library that does what it says on the tin. I don't fully understand every line. But it works, and the architecture decisions are documented in the commits so I can reason about them. 5/ A month ago I didn't know what a session cookie was. The "Co-Authored-By: Claude Opus 4.8" line in every commit isn't a disclaimer. It's the actual story of how this is getting built. 6/ If you're a non-technical founder watching this and thinking "that's cheating" — I'd push back. Understanding *what* to build, *why* it's structured that way, and *whether it's correct* is still real work. Claude writes the code. I have to know enough to direct it. 7/ Building bloomr in public. 100% AI-generated codebase. Beginner learning in the open. Follow along if that's interesting to you.
Session 11.5 required correcting both PDF content layout and cross-references in the plan generator — two classes of bug discovered in the same pass.
Session 11.5 of building PanelPro, and I found myself staring at a PDF where the label said one thing and the number next to it said the opposite. The background: PanelPro generates full construction plan PDFs for DIY garden rooms — dimensions, elevations, build stages, the lot. The whole codebase is written by Claude. I'm not a developer; I describe what I want, review the output, and flag what's wrong. That's the whole loop. This session I did a careful read-through of the generated plan and found two completely separate clusters of bugs hiding in the same document. **Cluster A** was a labeling problem. Back in UI Session 4, the project had settled on a convention: front eaves = HIGH, back eaves = LOW (water runs backward to the gutter). At some point I'd hand-authored some label strings — captions for elevation drawings, edge labels on the roof panel layout, a construction note about gutter slope. Those strings still said "front-low / back-high." Everything else — the geometry, the dimension callouts, the numeric heights — had been updated to match the real convention. Only my old hand-written labels hadn't caught up. So the PDF was showing a height value and a label that flatly contradicted each other. **Cluster B** was a different kind of stale. The document has cross-references: "for base options, see page 23," that kind of thing. Those page numbers were hard-coded literals, frozen from when the document was 24 pages long. It's now 31. The page footers were already dynamic — they derive from a running counter that tracks what's actually been drawn. The body cross-references just weren't. They were literals that had no idea the document had grown around them. The fix for Cluster B required a new function — `computePageRefs()` — that walks the same draw sequence the renderer uses and returns the resolved page number for each cross-referenced section. Now every "see page N" derives from that. It physically cannot re-freeze the next time a page gets added. Claude wrote both fixes and the two fail-first tests that proved the bugs existed before the fix and were gone after. I reviewed the output on seven specific pages in the rendered PDF. 315/315 tests green. The thing I keep noticing about the bugs that survive the longest: they're not logic errors. They're things that were true once and quietly stopped being true. The label was accurate when I wrote it. The page number was accurate when someone typed it. Neither of them had any mechanism to notice when the world changed around them. That's not a beginner mistake. That's just software. [VERIFY: whether dynamic derivation fully covers future page insertions mid-sequence — worth a check next time the doc structure changes.]
Session 11.5 required correcting both PDF content layout and cross-references in the plan generator — two classes of bug discovered in the same pass.
1/ One commit, two completely different classes of bug. Both hiding in plain sight inside my AI-generated PDF plan generator. 2/ Cluster A: label strings I'd hand-authored months ago still said "front-low / back-high." The geometry, the dimensions, the callouts — all of them had already been updated to front=HIGH / back=LOW. Only the labels hadn't caught up. 3/ So the PDF would show a number that contradicted the word sitting right next to it. The kind of thing that looks fine until you actually read it carefully. Which, apparently, I hadn't done since UI Session 4. 4/ Cluster B was nastier in a different way. "See page 23" — except the doc had grown from 24 pages to 31. Every hard-coded cross-reference was frozen in time, pointing at the wrong page. 5/ The page footers were already dynamic. They derive from a running counter. The "see page N" references in the body just... weren't. They were literals. Nobody told them the document had grown. 6/ The fix for Cluster B: a new computePageRefs() function that walks the same draw sequence the renderer uses and returns the real page number for each section. Now "see page N" derives. It can't re-freeze. 7/ Claude wrote all of this — the fix, the refactor, the two fail-first tests that went red before the fix and green after. I reviewed the output on 7 specific pages. 315/315 tests passing. What I keep noticing: the bugs that survive the longest aren't logic errors. They're things that were true once and quietly stopped being true. /
A door was rendering as a hole on the cover page because a drawSvgPath call was placed off-page — a subtle coordinate bug that only showed up in the final PDF output.
The door on my floor plan cover page was rendering as a hole. Not an incorrectly-sized door. Not a door in the wrong place. A hole. A door-shaped void in what should have been a solid wall. I stared at it for a while wondering if I'd misunderstood what doors were supposed to look like in architectural drawings. I hadn't. The bug was weirder than that. The whole codebase is AI-generated — Claude is the one writing the actual code, and I'm directing the sessions and reading the output. Session 11.1 had already fixed the door rendering for chamfer-corner pods. But the plain bar pod's flat front wall still had this ghost door problem. So in Session 11.2 I had Claude go deeper. Here's what was actually happening. pdf-lib, the library we use to generate PDFs, has a function called `drawSvgPath`. It wraps every path it draws in a `scale(1,-1)` transform — flipping the Y axis — but it does *not* apply the corresponding page-height translation to move the geometry back into view. So a path sitting at y~505 in normal coordinates ends up at device y~-505. Six hundred pixels below the bottom of the page. Not invisible because it's transparent. Invisible because it's literally off the page. The thing that made this so hard to spot visually: the recess reveals — thin lines drawn with `drawLine`, which bypasses that transform entirely — *were* rendering. Just foreshortened to almost nothing on the shallow front wall. So you got a faint suggestion of depth but no actual door shape. The brain fills in "hole." Claude's approach to finding this wasn't guesswork. The commit notes it explicitly: the root cause was found at "L4 — inspected the PDF content stream, not speculation." It walked the raw content stream, tracked the coordinate transform matrix step by step, and identified where the geometry was actually landing. Device y = -620. That's a concrete number, not a theory. The fix is two-pronged: draw outlines using `drawLine` (which stays on-page), and fill shapes using a Y-negated path that cancels pdf-lib's flip. The test — also AI-written — walks the content stream tracking the CTM and asserts that no vector geometry lands below device y = -10. It fails on the broken code at exactly -620 (the door's bottom corner), passes after. What I keep noticing about debugging sessions like this: I still don't understand pdf-lib's coordinate system deeply. I couldn't have found this myself. But Claude can read the raw PDF output, reconstruct what the renderer is doing, and explain it back to me in terms I can follow. The takeaway isn't that AI is magic — it made this exact mistake in the first place. The takeaway is that having a collaborator who can go from "something looks wrong" to "here is the exact device coordinate where your geometry is landing and here is why" is a genuinely different kind of debugging experience than I've had before. 310/310 tests green. The door looks like a door now.
A door was rendering as a hole on the cover page because a drawSvgPath call was placed off-page — a subtle coordinate bug that only showed up in the final PDF output.
1/ A door was rendering as a hole in my floor plan PDF cover. Not a wrong door. A literal hole. The kind of bug that makes you feel like the house is haunted. 2/ The door shape was there. The coordinates were right. The clipping was right. But in the final PDF it just... vanished, leaving a cutout where solid wall should be. 3/ Root cause: pdf-lib's drawSvgPath wraps every path in scale(1,-1) with NO page-height translate. So a path sitting at y~505 in normal coordinates renders at device y~-505. Off the bottom of the page. Invisible. Gone. 4/ The only thing that *did* show up were the recess reveals — drawn with drawLine, which doesn't go through that transform. But on a shallow front wall, those lines foreshorten to almost nothing. So: hole. 5/ I didn't guess this. Claude walked the PDF content stream, tracked the coordinate transform matrix step by step, and pinpointed exactly where the geometry was landing. Device y = -620. That's 620 points below the page. 6/ Fix: draw outlines with drawLine (stays on-page), and fill with a Y-negated path that cancels pdf-lib's flip. Two rendering paths, one for each job. 7/ The test for this: walk the cover content stream tracking the CTM and assert no geometry renders below device y = -10. Fails at -620 on the broken code. Passes after the fix. 8/ I still don't fully understand pdf-lib's coordinate system from first principles. But I know exactly what it did here, why, and how to route around it. That's enough to ship. 310/310 green. /
Shipped Reddit drafting (Phase 1.5c) with per-subreddit journey drafts — expanding the platform's content output to a third channel with subreddit-aware formatting.
Build-in-Public Studio drafts posts for X and Indie Hackers. As of this week, it drafts for Reddit too — and the way it handles Reddit is the part I want to write about, because it forced me to think carefully about something I'd been handwaving. The obvious approach would be: write one Reddit post per moment, let the user copy it wherever. That's what I almost built. But Reddit isn't one place. r/SaaS, r/indiehackers, r/SideProject, r/microsaas — they're technically all "Reddit" and they all nominally welcome indie builder content, but they have genuinely different cultures and different self-promo rules, and a post that lands in one can get removed or buried in another. So instead, you pick up to 3 subreddits, and Claude writes a separate draft for each one. Same underlying moment, different framing. r/SaaS wants real numbers and honest postmortems. r/SideProject cares more about the story than the metrics. r/indiehackers expects you to be further along and more specific about what you learned. Each draft gets a title written for that sub, and a checklist of that sub's self-promo rules shown before you post. I also had to decide what a Reddit draft should actually look like. The format I landed on: honest headline, real numbers if you have them, what went wrong, one specific insight, no CTA. Reddit communities are fast to punish anything that feels like a product announcement dressed up as a story. So the prompt explicitly tells Claude not to end with "check it out" or "would love feedback" unless that phrasing genuinely fits. The technical side of this was all Claude — Opus 4.8 with 1M context, co-authored on the commit. I'm not a developer. I described the behavior I wanted, asked questions when I didn't understand the schema changes, and pushed back when early drafts of the prompt produced posts that felt too promotional. The whole codebase is AI-generated. That's not a caveat — it's the actual point of the project. What I genuinely don't know yet: whether the per-sub tailoring is different enough to matter. The prompts are tuned differently, and the output reads differently to me, but I haven't posted enough Reddit content through this yet to know if it changes real-world reception. That's the next thing to find out. One thing I'm confident about: "one draft, paste it everywhere" was the wrong call for Reddit specifically. The subreddit rules alone — which vary a lot on whether you can mention your own product at all, and how — justify the per-sub approach even before you get to tone. Next up is Phase 2, which is about improving voice learning across all three platforms. But Reddit being in the pipeline at all felt worth documenting now, while the decisions were still fresh.
Shipped Reddit drafting (Phase 1.5c) with per-subreddit journey drafts — expanding the platform's content output to a third channel with subreddit-aware formatting.
1/ Build-in-Public Studio now drafts Reddit posts. Not just "post to Reddit" — it writes a separate draft for each subreddit you pick, tuned to that sub's culture and self-promo rules. 2/ The flow: pick up to 3 subs (r/SaaS, r/indiehackers, r/SideProject, r/microsaas). Claude writes one draft per sub. Each gets its own title, its own framing, and a checklist of that sub's self-promo rules before you post. 3/ Why per-sub drafts instead of one post you paste everywhere? r/SaaS wants real numbers and what broke. r/SideProject wants the story, lighter on metrics. r/indiehackers wants the honest journey beat. Same moment, different shape. One draft doesn't fit all three. 4/ The format I settled on for Reddit: honest headline, real numbers if you have them, what went wrong, one specific insight, no forced CTA. That's it. Reddit punishes the "check out my thing" post fast. 5/ Technically: new `drafts.variant` column gains a 'reddit' value, plus subreddit and title columns. A versioned SUBREDDIT_RULES config file — no scraping, just curated rules I can update. One Claude call per moment×sub, cached system prompt. 6/ Everything the X and Indie Hackers drafts already had — edit, regenerate, approve/reject, history, ratings, voice learning — works for Reddit too. Copy+Open button goes straight to r/<sub>/submit with the draft on your clipboard. 7/ 100% of this code was written by Claude (Opus 4.8, 1M context). I described what I wanted. Claude built it. I'm a non-technical founder learning what's actually possible when you just keep asking. What I still don't know: whether the per-sub tailoring is actually different enough to matter in practice. That's a real-world test I haven't run yet./
Added a /api/health endpoint mid-debug to diagnose a DB connection issue — a small but real example of building observability in when things break.
I was stuck on a DB connection issue with no real idea where to even start. The app was broken, the logs were vague, and I kept staring at config files hoping something would jump out. Eventually I stopped trying to read the tea leaves and just asked Claude to add a `/api/health` endpoint — a single route that hits the database and returns the connection status as JSON. Nothing fancy. Just: are you alive or not? I hit the endpoint. It came back telling me the connection was timing out, not failing outright. That one word — timeout vs. error — completely changed where I looked next. It wasn't a credentials problem or a misconfigured URL. It was a networking thing, a connection that was starting and then hanging. Different cause, different fix. The whole thing took about five minutes to add, and it probably saved me an hour of confused poking around. I've seen the word "observability" thrown around a lot and always found it a bit abstract. Monitoring dashboards, distributed tracing, structured logging — it can sound like infrastructure work that serious teams do, not something a solo beginner needs to think about. But this tiny endpoint is observability. It's just: give yourself a way to ask your system a direct question and get a straight answer, instead of inferring everything from broken behavior. The interesting thing about building with Claude doing all the actual coding is that it sharpens what my job actually is. I can't always follow every line it writes. What I *can* do is notice when I'm flying blind and ask for a way to see better. That instinct — "I need more information before I can solve this" — turned out to be the right move here. The endpoint is staying in the codebase. Next time something goes sideways, I'll have somewhere to look first. If you're early in building something and you hit a wall where nothing makes sense: before you dig deeper into the thing that's broken, it might be worth spending five minutes building yourself a window into it first.
Added a /api/health endpoint mid-debug to diagnose a DB connection issue — a small but real example of building observability in when things break.
1/ I had no idea if my app could even talk to the database. So I asked Claude to build me a way to ask it. 2/ The DB connection was failing somewhere. Logs weren't telling me much. I didn't know if the problem was my code, my config, or the connection itself. 3/ So I had Claude add a /api/health endpoint. Hit it, get back the status of the DB connection in plain JSON. That's it. 4/ Took maybe 5 minutes to add. And immediately I could see the connection was timing out — not erroring, timing out. That's a different problem with a different fix. 5/ I'd read about "observability" before and kind of glazed over. Now I get it. It's just: give yourself a way to ask the system what's happening, instead of guessing. 6/ 100% of this codebase is written by Claude. I'm the one who decided "I need to be able to see what's broken." That turned out to be the useful part. 7/
Octokit couldn't be bundled into claude.ts, forcing a refactor to move GitHub sync up to the caller layer (button + cron) — a concrete lesson in how serverless bundling constraints shape architecture.
This morning I ran into a constraint I didn't know existed, and it quietly taught me something about how serverless architecture actually works. The plan was simple: whenever Claude generates content, immediately sync the result to GitHub. One clean flow — generate, then sync, all inside the same file (claude.ts). I added Octokit, GitHub's API client, wired it up, and ran the build. The bundler rejected it. Octokit couldn't be included in that particular bundle. I don't fully understand the internals of why [VERIFY: exact bundling constraint], but the error wasn't subtle. claude.ts lives in a serverless context with restrictions on what can be packaged alongside it — and Octokit crossed some line. So I had to refactor. Instead of syncing inside claude.ts, I moved the GitHub sync up to the "caller layer" — meaning both the button that triggers generation and the cron job that runs it on a schedule now handle the sync themselves. They call claude.ts, get the result back, and then push to GitHub. claude.ts stays focused on talking to Claude. Sync happens where the environment actually permits it. The code itself wasn't wrong. The location was wrong. That's the part that stuck with me. I'm used to bugs where logic is broken — a value is missing, a condition is backwards, something doesn't run. This was different. The logic was fine. The architecture had to change because of where the code was allowed to live, not what it was doing. For someone learning by building (and learning by breaking things), this was a more interesting failure than a typo or a missing await. It's the kind of thing that's hard to find in a tutorial because it only shows up when you're building something real with actual deployment constraints. Worth noting: every line of this codebase is written by Claude. I'm not a developer — I'm someone who can read code, describe what I want, and learn from what goes wrong. Today what went wrong pointed me toward something I'll probably remember: serverless environments have opinions about bundling, and those opinions shape your architecture whether you planned for it or not.
Octokit couldn't be bundled into claude.ts, forcing a refactor to move GitHub sync up to the caller layer (button + cron) — a concrete lesson in how serverless bundling constraints shape architecture.
1/ Spent this morning learning that *where* you put code matters as much as *what* the code does. Tried to bundle Octokit (GitHub's API client) into my claude.ts file. The bundler said no. 2/ claude.ts is the file that calls Claude to generate content. I figured: generate content → immediately sync to GitHub. Logical flow, right? The serverless bundler didn't care about my logic. 3/ Octokit couldn't be included in that bundle. Something about how the serverless function gets packaged — I don't fully understand the internals yet [VERIFY: exact constraint], but the error was clear enough. 4/ The fix: move GitHub sync *up* to the caller layer. Now both the button and the cron job handle the sync themselves, *after* they get results back from claude.ts. claude.ts stays thin. GitHub sync lives where it's actually allowed to live. 5/ The architecture I wanted and the architecture I could build turned out to be different things — and the constraint came from the environment, not from bad design. That's a new kind of lesson for me. The code was fine. The location was wrong. 6/ 100% of this codebase is written by Claude. I'm learning how software works by watching what breaks and asking why. Today's answer: serverless bundlers have opinions. Respect them or refactor./
Hit Vercel Hobby's 60s function limit and had to redesign the GitHub sync architecture across 3 commits in one morning to work around it — capping sync at 10s, then splitting per-repo, all before the feature could ship.
This morning I couldn't ship a feature I thought was basically done. Not because of a bug in the code — the code worked fine locally — but because Vercel Hobby plans kill serverless functions at 60 seconds, and my GitHub sync was blowing past that. I'm not a developer. The entire codebase for Build-in-Public Studio is AI-generated by Claude. I'm learning what I'm learning as the constraints show up, not before. The first fix felt clever: cap the GitHub sync hard at 10 seconds. One commit, done. Except when I actually measured the full operation — sync commits from GitHub, then run the draft generation — it was running around 110 seconds across three repos. A 10s sync cap doesn't save you when the rest of the pipeline is still enormous. So the real fix was architectural. Instead of one function call that handles all repos, I split it into per-repo calls: syncOneRepo() followed by generate, fired sequentially, one repo at a time. Each call now caps out around 50 seconds. Comfortably inside the limit. That meant reworking two things at once. The "Generate Now" button in the UI needed to fire one call per watched repo instead of one call total, and show per-repo progress in real time so it doesn't just look frozen. The cron endpoint needed the same loop for consistency — same logic, two entry points. Three commits. One morning. All before the feature could ship at all. The honest thing about building this way — as a non-developer leaning on AI to write the code — is that Claude is genuinely good at implementing a design once you describe it clearly. What it can't do is warn you that your architecture is about to collide with a platform constraint you haven't encountered yet. That part still requires you to actually run the thing, hit the wall, and figure out what the wall is made of. The 60-second limit isn't obscure. It's in Vercel's documentation. I just hadn't hit it before because I hadn't built anything that pushed against it. The takeaway I'm sitting with: platform constraints don't care how clean your code is. They care how your code behaves at runtime. The only way to find them is to build something real enough that they find you.
Hit Vercel Hobby's 60s function limit and had to redesign the GitHub sync architecture across 3 commits in one morning to work around it — capping sync at 10s, then splitting per-repo, all before the feature could ship.
1/ I couldn't ship a single feature this morning until I redesigned the architecture around a time limit I didn't know existed. Vercel Hobby plans kill serverless functions at 60 seconds. My GitHub sync was running longer than that. 2/ First fix: cap the sync hard at 10s. Quick commit, felt clever. Then I actually measured what "sync + generate drafts" costs end-to-end. ~110s for 3 repos. Not even close. 3/ The real fix: stop syncing all repos in one function call. Split it into per-repo calls — syncOneRepo() + generate, one at a time, sequentially. Each call caps out around 50s. Comfortably under the limit. 4/ The button in the UI now fires one call per watched repo and shows per-repo progress in real time. The cron job does the same loop. Same logic, two entry points. 5/ None of this was planned. It's 3 commits across one morning before the feature could ship at all. The whole codebase is AI-generated — I'm not a developer. But "your architecture doesn't fit the platform" is a constraint Claude can't warn you about in advance. 6/ The 60s limit isn't hidden. It's in Vercel's docs. I just hadn't hit it before. Build something real and the platform constraints find you eventually. 7/
Went from zero to a documented Phase 1 — data layer, auth, holding page, setup guide, and session log all committed on the same day — a full founder sprint powered entirely by Claude.
I started yesterday with a blank Next.js repo and no idea what I was doing. By the end of the day, Bloomr had a working data layer, full auth flows, a holding page with UTM-attributed waitlist capture, a step-by-step setup guide, a known issues log, and a session handoff note — all committed, all documented, all written by Claude. I want to be clear about what I mean by "written by Claude": not assisted, not suggested, not autocompleted. Every line of production code in this project has a Co-Authored-By: Claude Opus 4.8 in the commit. I described what I needed. Claude built it. That's the whole setup. What surprised me about this sprint wasn't the speed — though one day from zero to Phase 1 complete is not something I could have done any other way. What surprised me was the *shape* of what got built. The auth layer has defense in depth: edge middleware for fast redirects, then a real server-side session check in the layout. The waitlist form deduplicates on email and normalizes the UTM source so my reporting query is actually useful from signup number one. The postgres.js client is configured with `prepare:false` because Neon's pooler requires it. I didn't know any of those things before this session. Claude knew them, applied them, and left comments explaining why. The docs might be the most underrated part. SETUP-Phase-1.md is a step-by-step guide to every external thing only I can do — spin up Neon, configure Doppler, wire GitHub OAuth, connect Resend, deploy to Vercel, point DNS, run a smoke test. It's written for me, a non-technical founder, not for a senior engineer who already knows the defaults. NOTES.md ends with a handoff: here's where we stopped, here's what comes next, here's what to watch for. I didn't ask for that handoff note. It just appeared in the commit. The honest caveat: I haven't deployed yet. Phase 1 is "code-complete, pre-deploy." That's the next session, and it's the part I'm least confident about — DNS and environment variables have a way of humbling people. I'll post how that goes. The thing I keep coming back to: I'm not a developer. I'm a founder who decided to build in public specifically to answer the question of how far a non-technical person can get with AI as the full engineering team. Day one answer is further than I expected. Ask me again after the deploy. If you're building something and you've been waiting until you "know enough to start" — this is the data point I wish I'd had six months ago.
Went from zero to a documented Phase 1 — data layer, auth, holding page, setup guide, and session log all committed on the same day — a full founder sprint powered entirely by Claude.
1/ Yesterday I had zero lines of production code for Bloomr. Today: data layer, auth, a holding page with waitlist capture, a setup guide, and a session log — all committed. One day. No prior coding experience. Every line was written by Claude. 2/ Here's what "Phase 1 complete" actually means in commits: - Neon + postgres.js data layer with a tenancy helper - Better Auth: email/password, GitHub OAuth, Resend-backed password reset - Edge middleware protecting /dashboard, /goals, /settings - 100 founding member slots seeded in the DB 3/ The holding page does something I care about: it captures UTM attribution from day one. Every waitlist signup stores utm_source/medium/campaign/content. I have a reporting query ready so I can see which channels (X, IH, 4 subreddits) are actually converting. 4/ The docs Claude wrote might be the most useful artifact of the day. SETUP-Phase-1.md walks me through every external step I have to do myself — Neon, Doppler, GitHub OAuth, Resend, Vercel, DNS. It even has a smoke test at the end. I didn't write it. I also didn't have to ask twice. 5/ The one thing I still have to do: deploy. The code is marked "code-complete, pre-deploy." The next session hands off exactly there. I know what I'm walking into. That handoff note was also Claude's idea. 6/ I'm not a developer. I'm building in public to find out how far a non-technical founder can get with AI as the entire engineering team. Phase 1 answer: further than I expected, faster than I expected, on day one. Follow along. I'll post the deploy attempt next — that one might hurt. 7/
Locked pricing currency to GBP across every source of truth as a deliberate decision — a small but concrete signal of how building in public forces you to make real calls early.
Yesterday I made a change that took about 20 minutes and touched four files. On the surface: find-and-replace USD with GBP across my docs. In practice: finally making a real decision I'd been drifting past. Bloomr is mine. I'm in the UK. So Bloomr's subscription prices should be in GBP. Obvious in retrospect, but I'd been writing pricing examples in USD for weeks without ever consciously choosing it. That's just what SaaS pricing looks like in my head — every product I've ever read about used dollars, so dollars kept appearing in my notes. The problem is that "out of habit" isn't a decision. It's a gap. And when I looked at my docs, USD and GBP were scattered across four different files — CLAUDE.md, the product spec, the build plan, the distribution plan — each quietly disagreeing with the others. Nobody would have caught this. The product isn't live. No customers exist yet. But that inconsistency was sitting there like a small splinter, and at some point it would have caused confusion, either for me or for Claude when it generated code against those docs. The fix was a single commit. Claude (Opus 4.8, which I'm using as the sole author of this codebase) reconciled it in one pass. What I found interesting was the thing that *didn't* change: customer MRR figures. Bloomr connects to Stripe accounts and displays their revenue data — that data stays in whatever currency each Stripe account uses. Changing that would be wrong. So the commit was precise: Bloomr's own subscription prices are GBP; everything a customer sees about their own Stripe data is untouched. I hadn't written that distinction down anywhere before this. It existed in my head as a vague "well obviously those are different things" — but vague and obvious isn't the same as documented. Now it's rule #12 in CLAUDE.md. This is the thing I keep finding with building in public, or even just building with a shared codebase that another system reads: you can't leave decisions implicit. The moment you have to write something down, you discover whether you've actually decided it or just assumed it. USD vs GBP is a tiny example. But the habit of catching it early, when it costs 20 minutes instead of a refactor, feels like the right one to build. The whole codebase is AI-generated — Claude writes every line of code, I make every decision. That split only works if the decisions are actually made. Yesterday's commit was a small proof of that.
Locked pricing currency to GBP across every source of truth as a deliberate decision — a small but concrete signal of how building in public forces you to make real calls early.
1/ I spent 20 minutes yesterday doing something that sounds boring: changing every "$" in my docs to "£". It forced a decision I'd been quietly avoiding. 2/ Bloomr is a UK-based product. I'd been writing pricing in USD out of habit — that's just what SaaS pricing looks like in my head, I guess. But "out of habit" isn't a decision. It's a drift. 3/ Building in public means your docs aren't private scratchpads. They're a record. And when I looked at them, USD and GBP were mixed across four different files, each quietly disagreeing with the others. 4/ The fix itself took one commit. Claude reconciled it in a single pass: - CLAUDE.md: GBP tiers + founding prices - spec §13: product/price list swapped - build-plan.md: subscription prices swapped - Distribution-Plan.md: was already GBP (lol) 5/ The subtle part: customer MRR figures stay in whatever currency their Stripe account uses. Only Bloomr's *own* subscription prices are GBP. That distinction matters and I'd never written it down anywhere until now. 6/ No one would have noticed if I'd left it inconsistent for another month. The product isn't live. But that's exactly when it's easiest to make the call — before anything is real. 7/ Small decisions made explicitly beat big decisions made by accident. That's the whole thing. 7/
Built and deployed a holding page with waitlist capture and channel attribution before the product exists — treating distribution as a day-one engineering task, not an afterthought.
The holding page for Bloomr went live before I'd written a single line of product code. That was deliberate, and it felt slightly unhinged to do, so I want to explain the reasoning. The usual pattern — at least the one I've fallen into before — is to build the thing, then figure out how to tell people about it. Distribution becomes this anxious scramble at the end, when you're tired and the product isn't quite done and you post in three places and get six signups and lose momentum fast. I wanted to break that pattern structurally, not just attitudinally. So the first real engineering task was: a holding page with a waitlist form that captures UTM parameters from the URL and stores them alongside each email. utm_source, utm_medium, utm_campaign, utm_content — all of it gets passed through a server action and upserted into the database (deduped on email, so someone clicking from two different channels only counts once). There's a normalized `source` field so the reporting stays clean. I also wrote out a docs file — utm-links.md — with ready-to-go links for every channel I'm planning to post on: X, four subreddits I've identified as relevant, Indie Hackers. Each link has the UTM params baked in. And there's a SQL query sitting there that I can run any time to see which channel is actually converting to waitlist signups. That last part is the bit I keep coming back to. The 90-day distribution plan I wrote has targets in it — specific numbers for specific channels. Those targets are meaningless if I can't measure against them. Now I can. The plan went from aspirational to falsifiable, which is a different thing entirely. None of this code is mine in the traditional sense. The entire codebase is generated by Claude — Opus 4.8, with a 1M context window. I'm not a developer. I'm someone learning what's actually possible when you treat an AI model as your whole engineering team and you're honest with yourself about what you don't know. What I'm finding is that the constraint of not being able to write code myself forces me to be very precise about *what* I want built and *why* — which turns out to be a reasonable substitute for engineering judgment, at least at this stage. To be clear: zero signups. The page isn't public yet. I have no idea if any of this will work. But the first time I post a link somewhere, I'll know immediately whether it sent anyone. That felt like the right thing to have in place before anything else. Distribution as a day-one engineering task, not an afterthought. I'll share the numbers as they come in.
Built and deployed a holding page with waitlist capture and channel attribution before the product exists — treating distribution as a day-one engineering task, not an afterthought.
1/ Before writing a single line of product code, I shipped a waitlist page that tracks exactly which channel each signup came from. UTM params → server action → database. Day one. Here's why I did it backwards on purpose: 2/ Most first-time builders (me, historically) do this: - Build for 3 months - "Now I need users" - Post once, get 4 signups, give up I wanted distribution to be an engineering task, not a prayer I say at the end. 3/ The holding page captures utm_source, utm_medium, utm_campaign, utm_content from the URL and upserts them into the DB alongside the email. Deduped on email. Normalized source field. Nothing fancy — but it's wired up before the product exists. 4/ I also wrote docs/utm-links.md with per-channel links ready to go: - X - 4 subreddits - Indie Hackers And a reporting query so I can see which channel is actually converting. The Distribution Plan targets are now falsifiable. That matters to me. 5/ The whole codebase is written by Claude (Opus 4.8). I'm not a developer — I'm learning what's possible when you treat AI as your entire engineering team. Turns out "distribution-first" is a lot easier to attempt when the infra cost is a good prompt. 6/ I don't know if this will work. Zero signups so far — the page isn't public yet. But when it is, I'll know immediately which channel sent who. That felt worth doing first. 7/
Shipped a full auth system (login, signup, password reset, GitHub OAuth) in one session — entirely AI-generated, as a self-described beginner who couldn't have built this from scratch.
I shipped auth today. The full thing — login, signup, password reset, GitHub OAuth, email verification, protected routes, a tenancy helper so every database query carries the authenticated user. All in one session. I want to be clear about something upfront: I did not write any of this code. Every line is AI-generated, co-authored by Claude Opus 4.8, which is literally noted in the git commits. I'm a self-described beginner. I'm not hiding that — it's the whole point. Here's what actually got built across three commits: The data layer first. A postgres.js client configured for Neon's pooler, a `withSession()` helper that acts as an app-layer tenancy guard (every user-scoped query carries the authenticated userId), and Better Auth wired up with its own Postgres connection for its core tables. Two separate migration paths, which felt fiddly until I understood why — Better Auth owns its schema, your app owns its own. Clean separation once you see it. Then the auth flows. Edge middleware that gates `/dashboard`, `/goals`, `/settings`, and `/connect` on the session cookie — with a real server-side session check in the layout behind it (defense in depth, the commit message calls it). Login and signup forms with react-hook-form and zod validation. Forgot password, reset password, the full Resend-backed email flow. A "check your inbox" state on signup so users know verification is required. And a protected dashboard placeholder with a working sign-out. The thing I keep noticing as I build this way: the bottleneck is never "can I write this code." It's "can I describe the problem clearly enough." Auth is a domain with a lot of sharp edges — session handling, token expiry, OAuth redirects, email deliverability. I don't fully understand all of it [VERIFY as I go deeper into production]. But I understood it well enough to describe what I needed, and Claude built something I'd have spent weeks struggling through on my own. That gap — between "I can describe this" and "I can implement this" — is what I'm learning to work inside. And it turns out describing things clearly is a real skill you can get better at, separate from knowing how to code. Stripe connect is flagged as Phase 2. One thing at a time.
Shipped a full auth system (login, signup, password reset, GitHub OAuth) in one session — entirely AI-generated, as a self-described beginner who couldn't have built this from scratch.
1/ I just shipped a full auth system — login, signup, password reset, GitHub OAuth, email verification, protected routes — in a single session. I could not have written a single line of it myself. 2/ Here's what actually got built: - Edge middleware gates /dashboard, /goals, /settings on a session cookie - Better Auth handles email/password + GitHub OAuth - Resend sends the verification and reset emails - react-hook-form + zod covers validation - Defense-in-depth: middleware check + real server-side session check 3/ The data layer is real too. postgres.js client for app queries, a withSession() tenancy helper so every user-scoped query carries the authenticated userId, and a migration runner. That last part would have taken me weeks to figure out from scratch. Possibly longer. 4/ The whole codebase is 100% AI-generated — Claude Opus 4.8, which co-author signs every commit. I'm building in public as an explicit beginner. I'm not pretending I understand every line. But I shipped a production-grade auth system today, and it works. 5/ The thing I keep coming back to: the bottleneck isn't "can I write this code." It's "do I understand the problem well enough to describe it clearly." That turns out to be a learnable skill, and it changes what's possible. 6/
Session 11.5 fixed PDF content errors and cross-reference mismatches in the plan generator — the kind of tedious correctness work that's invisible until it's wrong.
Session 11.5 of building PanelProBuild was unglamorous. No new features, no geometry changes, no new pages in the PDF. Just correctness work — the kind that's invisible until a builder on a job site follows the wrong label. Two separate clusters of bugs turned up in a content review. The first one was embarrassing in a specific way. Back in Session 4 I had a UI change that flipped the eaves convention: front wall = HIGH, back wall = LOW. The geometry respected that. The dimension callouts respected it. But scattered through the label strings — the ones that get printed on the elevation captions, the roof panel layout, the gutter slope arrows — were phrases I'd hand-authored before the flip. "Front-low / back-high." Still sitting there. Still compiling. Still rendering. Just wrong. For a general-purpose app this might be a mild UX annoyance. For a roof drainage plan, it's a real problem. "Water flows toward the low eaves" is a construction instruction. If the label and the callout disagree about which wall is low, someone picks one and ignores the other. The second cluster was stale page cross-references. In-document "see page N" strings, hard-coded as literals, frozen from when the document was 24 pages. The document is now 31. Nothing crashed. The PDF rendered fine. It just quietly directed you to the wrong page. Claude's fix for that one was to write a new file — page-refs.ts — that walks the same draw sequence the renderer uses and returns each section's actual resolved page number at runtime. No more typed literals. Now if a section moves, the cross-reference moves with it. Before touching either fix, Claude wrote fail-first tests. One test checked label-to-numeric-height consistency and asserted that every "low eaves" label pointed at the wall with the lower height value. One read each section's real footer page number and checked that every cross-reference matched it. Both went red against the pre-fix code. Then the fixes made them green. I keep asking for that pattern — red first, then green — because I'm a beginner and I genuinely can't always tell from reading code whether a fix is real or just looks plausible. A test that was provably broken before the change and passes after it gives me something to stand on. 315/315 green. KNOWN-ISSUES item 6 marked resolved. The thing I'm sitting with after this session: the bugs that scare me most aren't the ones that throw errors. They're the ones that render a perfectly formatted PDF that tells a builder to do the wrong thing. Correctness work is boring to write about. It's not boring when it's wrong. [VERIFY: page count of 31 is taken from the commit message; actual current page count should be confirmed against a fresh render.]
Session 11.5 fixed PDF content errors and cross-reference mismatches in the plan generator — the kind of tedious correctness work that's invisible until it's wrong.
1/ My PDF plan generator had page cross-references that said "see page 17" — except the doc had grown from 24 pages to 31 and nobody told the numbers. 2/ There were actually two separate clusters of wrongness hiding in there. Cluster A: label strings I'd hand-written back in Session 4 still said "front-low / back-high." The geometry had been corrected months ago. The words hadn't. 3/ That one matters more than it sounds. This is a roof drainage plan. "Water flows toward the low eaves" — but which wall IS the low eaves? If the label says front and the callout says back, a builder follows one of them and ignores the other. 4/ Cluster B was the page refs. Hard-coded literals. "See page 10." Frozen in time while the document grew. No one crashes, nothing throws — it just quietly sends you to the wrong page. 5/ The fix for Cluster B was a new file: page-refs.ts. It walks the same draw sequence the PDF renderer uses and returns each section's actual page number at runtime. Now "see page N" derives instead of being typed. 6/ Claude wrote two fail-first tests before touching the bug. One checked that every label pointing to the low eaves actually pointed at the wall with the lower height number. One read each section's real footer page and checked that every cross-ref matched it. Both went red. Then the fix made them green. That ordering matters to me — I'm a beginner and I can't always tell if a fix is real or just looks right. 7/ 315/315 tests passing. The thing I keep noticing: the bugs that are hardest to spot aren't the ones that crash. They're the ones that compile fine, render fine, and just… say the wrong thing. /
A door rendered as a hole on the cover page because a drawSvgPath call was placed off-page — a specific off-by-geometry bug that took a dedicated session to hunt down.
For two sessions I had a door on my architectural plan cover that rendered as a hole. Not a door shape, not a label — just the wall fill with nothing behind it, like someone had cut a rectangle out with scissors. Session 11.1 had already fixed the chamfer corner doors. The plain flat front-wall door was still broken. I assumed it was the same class of problem. It wasn't. The geometry was right. The quad projected correctly. The clipping was correct. But the door was invisible — or rather, it was visible only as an absence, because the wall fill behind it was being punched through. The only thing that *did* render were the recess reveal lines, drawn with `drawLine`. But on a shallow front wall that's nearly face-on to the camera, those lines foreshorten to almost nothing. So the opening read as a dark rectangle. A hole. I ended up inspecting the raw PDF content stream — the actual byte-level instructions the renderer is executing — to figure out what was happening. That's when I found it. pdf-lib's `drawSvgPath` wraps every path in `scale(1,-1)` with no corresponding page-height translation. It flips the Y axis to handle the difference between SVG coordinates (origin top-left) and PDF coordinates (origin bottom-left), but it doesn't shift the origin back up. So a door drawn at y≈505 in my coordinate system was landing at device y≈-505 — off the bottom of the page. Every fill, every back-rect, every outline: all of it, invisible, below the page. The fix was to stop using `drawSvgPath` for anything that needed to land correctly. Outlines go through `drawLine` (which is on-page). Fills go through a Y-negated path I called `svgFillPathYUp` — it inverts the Y coordinates before handing the path to pdf-lib, which cancels the flip that pdf-lib would apply. The name is deliberately explicit. When I come back to this in three months I don't want to wonder why the Y is negated. Then I wrote a test that walks the content stream, tracks the current transformation matrix through every `cm` operator, and asserts no vector geometry ends up below the page. On the unfixed code it fails at device y=-620 (the bottom corner of the door). After the fix, 310/310 green. The whole codebase is written by Claude — that's the premise of this project. But this particular session reminded me that "AI wrote it" doesn't mean "AI debugs it automatically." Coordinate system bugs in a PDF renderer require someone to go read the spec and stare at content streams. That someone was me, with Claude helping think through the CTM math. The door is now a door. A clear, correctly-shaped, recessed hole in the wall. Which is what a door is supposed to be.
A door rendered as a hole on the cover page because a drawSvgPath call was placed off-page — a specific off-by-geometry bug that took a dedicated session to hunt down.
1/ A door on my architectural plan cover was rendering as a blank hole. Not a door. Just... nothing. Took a full dedicated session to find out why. The answer was genuinely weird. 2/ The door geometry was correct. The clipping was correct. The projection math was correct. But the door was invisible — except as a hole punched through the wall fill. 3/ Root cause: pdf-lib's `drawSvgPath` silently wraps every path in `scale(1,-1)` with NO page-height translation. So a door drawn at y~505 (PDF coordinates) was actually rendering at device y~-505. Off the bottom of the page entirely. 4/ The only thing *not* using drawSvgPath were the recess reveal lines, drawn with drawLine. Those rendered fine — but on a shallow front wall they foreshorten to almost nothing. So the door read as a hole with a ghost of a line. Took inspecting the raw PDF content stream to see it. 5/ Fix: stroke outlines with drawLine, fill with a Y-negated SVG path that cancels pdf-lib's flip. Named it `svgFillPathYUp` so future-me knows exactly what it's doing and why. 6/ Then wrote a fail-first test that walks the cover's content stream, tracks the CTM, and asserts no vector geometry renders below the page. Fails at device y=-620 on the old code. Passes after. 310/310 green. 7/ The whole codebase is written by Claude. I'm the one who had to stare at a PDF content stream to find a coordinate flip with no translation. Debugging is still debugging, even when an AI wrote the code. 8/
Shipped Reddit drafting (Phase 1.5c): per-subreddit journey posts, meaning the app now writes platform-native content for a third channel
Reddit is the third platform Build-in-Public Studio can draft for, and it's the one that actually required the most thought about format. X threads and Indie Hackers posts are both pretty forgiving. You can be a little loose, a little casual, and it reads fine. Reddit is different. Every subreddit has its own personality and its own self-promotion rules, and if you get either of those wrong, your post just disappears — either removed by a mod or silently ignored by the community. So when I added Reddit drafting (this was commit d586b6b, which I'm calling Phase 1.5c in my internal versioning), I didn't want to just pipe the same content there. The whole point of the tool is platform-native drafts. For Reddit that means: you pick up to 3 subreddits — r/SaaS, r/indiehackers, r/SideProject, r/microsaas — and Claude writes a completely separate draft for each one. The format it writes to is what I'm calling a "journey post": honest headline, real numbers if you have them, what went wrong, one specific insight, no forced call-to-action. That's the pattern that actually gets traction on these subs rather than getting flagged as spam. [VERIFY: this is my read of what performs well — worth checking against your own experience] Each draft also surfaces that subreddit's self-promo rules as a checklist before you copy the post. I hardcoded these rather than scraping them live — it's a versioned config file, which means I can update the rules manually when something changes. Less fragile than scraping, and it means no new dependencies. One thing I was nervous about: the UI. I really didn't want to build a second editing interface just for Reddit. Turns out the existing edit/regenerate/approve/reject flow generalized cleanly. The only Reddit-specific addition is a title field, because Reddit post titles are a first-class thing in a way that X and IH don't need. History filtering and voice learning both got updated to understand the new `reddit` variant alongside `x_thread` and `ih_long`. Copy+Open sends you straight to r/[sub]/submit with the draft in your clipboard. The app never auto-posts. That's a deliberate line I don't want to cross. As always: every line of this code was written by Claude (Opus 4.8, 1M context). I'm not a developer. The co-author line in the git commit is literal. The thing that keeps surprising me is how well the abstractions hold when I ask Claude to extend them — I expected Reddit to require a bunch of special-casing, and mostly it didn't. One unglamorous thing also in this commit: a full ESLint migration. Next 16 removed `next lint`, and the old `.eslintrc.json` broke under ESLint 9. Claude rewrote it to flat config, fixed three pre-existing lint issues, and got `tsc` and `lint` both clean. Not the kind of work you'd write a post about on its own, but it was blocking the Reddit work and it's done now.
Shipped Reddit drafting (Phase 1.5c): per-subreddit journey posts, meaning the app now writes platform-native content for a third channel
1/ Build-in-Public Studio now drafts for Reddit. Not just "post this there too" — it writes a separate draft per subreddit, tuned to that sub's norms. r/SaaS gets a different post than r/SideProject. Same moment, different draft. 2/ The commit message calls it "Phase 1.5c." That naming is doing a lot of work. It's the third platform after X and Indie Hackers, and the one with the most rules about self-promotion. Each sub has its own checklist that shows before you post. No scraping — hardcoded and versioned. 3/ The format Claude writes to: honest headline, real numbers, what went wrong, one specific insight, no forced CTA. That's the "journey post" format. It's what actually gets upvoted on r/indiehackers vs. what gets removed. 4/ You pick up to 3 subreddits per moment. Claude makes one API call per moment×sub combo. Each draft gets its own title field because Reddit titles matter in a way X and IH don't require. 5/ The whole thing reuses the same edit/regenerate/approve/reject flow from the other platforms. Copy+Open goes straight to r/<sub>/submit. I didn't want to build a second UI. Turns out the abstraction held. 6/ 100% of this code was written by Claude (Opus 4.8, 1M context). I'm a non-technical founder learning in public. The co-author line in the git commit is literal, not a joke. 7/ Also buried in this commit: a full ESLint migration. Next 16 dropped `next lint`, the old config broke under ESLint 9. Claude rewrote it to flat config. tsc + lint both clean now. Not glamorous. Necessary. /
Added a /api/health endpoint just to figure out why the DB connection was failing — a classic debugging-in-production moment
The database connection was failing and I didn't know why. No useful error, no obvious log line, just a broken app sitting in production doing nothing helpful. So I did the only thing I could think of: I asked Claude to add a `/api/health` endpoint. Not because I had a plan. Because I needed a window into a system I couldn't see. The whole codebase is AI-generated — I'm a non-technical founder learning as I go — so "ask Claude to add a diagnostic endpoint" is genuinely what debugging looks like for me. I described what I was seeing, said I needed something to tell me the state of the DB connection at runtime, and it wrote the endpoint. Commit `a66a374`, message: "debug: add /api/health endpoint to diagnose DB connection." Possibly the most honest commit message in this repo. What the endpoint showed me: the connection was being established, then dropped almost immediately. The app was passing its own startup checks and then silently failing. Without something actively reporting state, I had no way to know that. I was reading logs and making guesses. [VERIFY] I still don't fully understand *why* the connection was dropping — that's the next thing to dig into — but at least I know what's happening now instead of what I assumed was happening. Here's the thing I keep noticing as a beginner building this way: a lot of "best practices" that I've heard about but never really understood turn out to be things developers built because they were stuck. Health endpoints exist because production systems are opaque and you need a way to ask them how they're doing. I didn't add one because I read that you should. I added one because I was desperate. It's staying in the codebase now. Desperation turns out to be a reasonable forcing function for building things that are actually useful.
Added a /api/health endpoint just to figure out why the DB connection was failing — a classic debugging-in-production moment
1/ My database connection was broken in production. I had no idea why. So I did what apparently every developer does: added a /api/health endpoint just to see what was actually happening. 2/ The endpoint didn't fix anything. That wasn't the point. The point was I needed a window into a running system I couldn't otherwise see. Claude wrote the whole thing — I just knew I needed *something* to return state. 3/ What came back told me the DB was connecting, then immediately dropping. The app was reporting healthy on startup, then silently dying. Without that endpoint I'd have been staring at logs guessing forever. 4/ The commit message is "debug: add /api/health endpoint to diagnose DB connection" which is maybe the most honest commit message I've ever written. I didn't add it because it's good practice. I added it because I was lost. 5/ It's now staying in the codebase. Turns out "I was desperate" is a fine reason to build something useful. 6/
Untangled a bundling conflict: Octokit couldn't live inside claude.ts, so GitHub sync had to move to the caller layer (button + cron)
I ran into something this week that felt like a wall until it suddenly made sense — and I want to write it down before I forget it, because I think it'll keep coming up. The short version: Octokit (GitHub's official JS client) can't live inside my claude.ts file. When I tried to put it there, the build broke. No dramatic error, just a bundling conflict that I didn't have the vocabulary to even search for at first. Some background on how this project is structured: I have a claude.ts file that handles all the AI interactions. It felt natural to also put the GitHub sync logic there — one file for the "smart" stuff. But the environment that claude.ts runs in has different bundling constraints than the rest of the app, and Octokit's dependencies don't play nicely with that environment. [VERIFY: the exact technical reason here — I'm still fuzzy on whether this is a Node vs. Edge runtime thing or something else entirely.] The fix, once Claude explained it, was straightforward: move the GitHub sync call up to what it described as the "caller layer." Instead of claude.ts doing the GitHub push, the button click handler does it, and the cron job does it. The logic is identical. The location is different. Build works. What I keep chewing on is how much of software is just... geography. Where something lives determines whether it works at all, independent of whether the logic is correct. I had correct logic in the wrong place and got a broken build. Moved it twenty feet (metaphorically), same logic, everything fine. I'm a non-technical founder. The entire codebase is AI-generated — I don't write the code, but I am trying to understand it. Moments like this one are where that understanding actually gets built. Not from reading docs, but from watching a specific thing fail, asking why, and getting an explanation I can almost follow. If you're building with AI and you're not technical, I'd say: don't skip past the confusing errors. That's the curriculum.
Untangled a bundling conflict: Octokit couldn't live inside claude.ts, so GitHub sync had to move to the caller layer (button + cron)
1/ I spent way too long wondering why my build kept breaking before I understood what was actually happening: you can't just put Octokit anywhere you want. 2/ The setup: I have a claude.ts file where all the AI stuff lives. I wanted GitHub sync to live there too. Felt tidy. Made sense to me. 3/ Octokit (GitHub's JS library) does not care about my feelings for tidiness. It has bundling requirements that clash with whatever environment claude.ts runs in. The build just... refused. 4/ Claude eventually walked me through what was wrong. The fix: move the GitHub sync call OUT of claude.ts and up to the "caller layer" — the button click and the cron job that trigger everything. 5/ That's it. Same logic, different file. Build works now. 6/ The lesson I'm slowly absorbing: where code LIVES matters as much as what it DOES. Bundlers have opinions. You either learn them or you fight them forever. 7/ All of this was written by Claude. I'm a non-technical founder learning what's actually happening by watching things break. Highly recommend breaking things.
Hit Vercel Hobby's 60-second function limit in production — had to re-architect GitHub sync across three commits in one morning to work around it
Saturday morning I opened my app and something felt off. The GitHub sync wasn't erroring — it was just stopping. No feedback, no failure message. Just... done, too soon. Eventually I found it: Vercel's Hobby plan has a 60-second hard limit on serverless function execution. My sync-and-generate pipeline was running longer than that. Vercel was killing it mid-run, quietly, and moving on. I didn't know this limit existed until I hit it in production. That's probably on me for not reading the pricing page more carefully, but also — it's the kind of thing that's easy to miss when you're heads-down building. My first instinct was the obvious band-aid: cap the GitHub sync itself at 10 seconds. That was commit 85a1981, around 7:35am. It helped a little. But if you're watching multiple repos, you're still stacking up work inside a single function call, and you'll blow past 60 seconds regardless of how fast any individual piece runs. So I had to actually think about the architecture. The question shifted from "how do I make this faster" to "what's the maximum useful chunk of work that fits inside one function call." The answer turned out to be: one repo. Sync it, generate drafts for it, done — all in roughly 50 seconds, safely inside the cap. I rewrote the whole pipeline around that idea. There's now a generateForRepoAction() function that takes a single repo, syncs it from GitHub, and generates drafts scoped to it. The UI fires one call per watched repo, sequentially, showing per-repo progress as it goes. The cron job does the same thing in a loop. Each invocation is independent and well within the limit. One edge case almost slipped through: notes that aren't linked to any specific repo (repo=null) still need to be included in draft generation. When everything ran as one big batch, this happened automatically. Once I sliced it into per-repo chunks, I had to explicitly include unlinked notes in every pass. Easy fix once you notice it — easy to miss until you do. Three commits, one morning. The codebase is 100% AI-generated — I described the constraint to Claude and it redesigned the approach. But I had to understand the problem well enough to describe it correctly. That part doesn't get outsourced. The takeaway, if there is one: infrastructure constraints aren't just annoyances. They force you to find the natural seams in your architecture — the places where your code *should* have been split up anyway. The 60-second limit made my sync pipeline more modular and more observable than it was before. I'm not sure I would have gotten there without the pressure.
Hit Vercel Hobby's 60-second function limit in production — had to re-architect GitHub sync across three commits in one morning to work around it
1/ My app silently broke in production because of a 60-second limit I didn't know existed. Here's how one Vercel constraint forced me to rethink my entire GitHub sync architecture — in a single morning. 2/ Build in Public Studio syncs your GitHub commits, then generates draft posts from them. That pipeline ran fine locally and in testing. In production on Vercel's Hobby plan? It was getting killed mid-run. The function timeout is 60 seconds. Hard stop. 3/ My first fix was a band-aid: cap the GitHub sync itself at 10 seconds. Commit 85a1981, 7:35am. Bought me some breathing room. Didn't actually solve it — if you're watching multiple repos, you still blow past 60s total. 4/ The real fix came from asking a different question. Instead of "how do I make the whole sync faster," it became: "what's the most work I can safely do in one function call?" Answer: one repo. ~50 seconds. Just under the cap. 5/ So I re-architected the whole thing. Each call to generateForRepoAction() now syncs a single repo from GitHub, then generates drafts scoped to it. The UI fires one call per repo, sequentially, with per-repo progress shown in real time. The cron job mirrors the same loop. 6/ There's a wrinkle I almost missed: notes that aren't linked to any repo (repo=null) need to be included in *every* per-repo generation pass. That's the kind of edge case you only find when you're forced to slice something up that used to run as one big chunk. 7/ The whole re-architecture — three commits, one morning, zero AI complaints. The codebase is 100% AI-generated (Claude). I described the constraint, it redesigned the approach. I mostly just had to understand the problem well enough to explain it. That part is still on me. /
Added a /api/health endpoint just to diagnose whether the DB was even connecting — a beginner's first taste of production debugging with no obvious error to go on
Something broke in production this week. I don't mean it threw an error — I mean it just silently didn't work, with nothing in the logs I knew how to read and no obvious place to start. I'm a beginner. My entire codebase is written by Claude. That means I can ship things, but when they break, I don't always have the instincts to know what to even ask. I can't just read through the code and spot the problem. I have to describe symptoms and hope I'm describing them well enough to get somewhere useful. What I landed on was asking Claude to add a `/api/health` endpoint. I'd seen the term somewhere but didn't really know what it meant. Turns out it's one of the simplest things in web development: a route that tries to touch the database and tells you whether the connection is alive or dead. That's it. But that small thing — hitting `/api/health` in my browser and seeing a response — was the first real signal I had. It didn't fix anything. It just told me whether the database was even in the picture. And it was. Which meant the problem was somewhere else. Which meant I had a direction. I think this is what experienced developers mean when they talk about "narrowing the problem space." I always assumed that was some sophisticated skill. Maybe sometimes it is. But sometimes it's just: build the smallest possible thing that gives you one bit of information. Alive or dead. Connected or not. Then go from there. The commit is one file, maybe 15 lines of AI-generated code [VERIFY]. I didn't write a single character of it. And yet figuring out that I needed it, and knowing what to ask for — that felt like a genuine debugging decision, maybe the first one I've made in production. If you're early and something is silently broken: ask for a health endpoint before you do anything else. You probably won't find the bug. But you'll find out what layer it's hiding in, and that's worth more than it sounds.
Added a /api/health endpoint just to diagnose whether the DB was even connecting — a beginner's first taste of production debugging with no obvious error to go on
1/ My app was broken in production. No error message. No stack trace. Just... nothing working. So I did the only thing I could think of: I asked Claude to add a `/api/health` endpoint so I could at least find out if the database was connecting at all. 2/ I didn't know what a health endpoint was a few weeks ago. Turns out it's just a route that pings your DB and reports back "alive" or "dead." Simple idea. But it told me everything. 3/ Hitting `/api/health` in the browser and seeing a response — any response — was the first real signal I'd had. Not a fix. Just a clue. That's apparently what production debugging looks like when there's no obvious error to go on. 4/ The whole codebase is AI-generated. I can't always read what Claude writes, but I'm learning to describe symptoms well enough that it can build me the right diagnostic tool. This time, that was enough to make progress. 5/ If you're a beginner and something's broken in prod with no error: ask for a health endpoint first. It won't fix anything. But it'll tell you where to look next. That alone is worth a lot.
Octokit couldn't be bundled inside claude.ts, forcing a refactor to move GitHub sync up to the caller layer (button + cron) — a concrete lesson in how bundling constraints shape architecture
I added auto GitHub sync this week — every time the app generates something, it pushes to GitHub automatically, both from the manual button and the background cron job. Took maybe an hour to build. Then it took another 30 minutes to figure out why it immediately broke. The plan was simple: put the GitHub sync logic inside `claude.ts`, the file that handles all AI generation. It felt tidy. One place for the "do the AI thing and save the results" flow. I asked Claude to write it, it wrote something reasonable, and then the build fell apart with bundling errors I didn't really understand. The culprit was Octokit — the official GitHub API client. Something about how it's packaged made it incompatible with being bundled inside that particular file in my Next.js setup. I'm honestly still a little fuzzy on the exact mechanism [VERIFY: whether this is a Next.js edge runtime constraint or a general webpack issue], but the error was real and consistent. The fix Claude landed on: move the GitHub sync call *up* to the caller layer. Instead of `claude.ts` handling sync, both callers — the button component and the cron job — handle their own sync after generation finishes. Two call sites instead of one centralized one. It works now. But what stuck with me is that I didn't choose this architecture. The bundler chose it for me. I was planning to keep things centralized, and the tooling said: no, this dependency belongs closer to the edge, closer to the user-facing trigger. This is the whole project in miniature, honestly. I'm a beginner building something real with 100% AI-generated code, which means I'm constantly in this position of learning the shape of a system by bumping into its constraints rather than designing it from first principles. I couldn't have told you before this week that "bundling constraints can dictate where a dependency lives in your call stack." Now I can. The takeaway I'd hand to another beginner: when a refactor gets forced on you by something as mundane as a build error, it's worth pausing to ask whether the error was actually right. Sometimes the tooling understands the architecture better than you do at that moment.
Octokit couldn't be bundled inside claude.ts, forcing a refactor to move GitHub sync up to the caller layer (button + cron) — a concrete lesson in how bundling constraints shape architecture
1/ Bundling constraints just forced me to rethink my whole architecture. Took about 30 minutes to understand why. Here's what happened. 2/ I wanted GitHub sync to live inside claude.ts — the file that handles all AI generation. Felt like the right place. One function, one responsibility. 3/ Except Octokit (the GitHub API client) refused to bundle cleanly inside that file. Errors I didn't fully understand, in a build process I didn't write. The code is 100% AI-generated, so I was reading someone else's reasoning in real time. 4/ The fix: move GitHub sync OUT of claude.ts and up to the "caller layer" — the button the user clicks, and the cron job that runs automatically. Both callers handle their own sync now. 5/ It works. But the interesting part is WHY it works: the bundler constraint wasn't a bug to fix, it was a signal about where the code actually belonged. 6/ I didn't plan this architecture. A build error taught it to me. That's been the pattern with this whole project — I'm not designing the system top-down, I'm learning its shape by running into its edges. 7/
Hit Vercel Hobby's 60-second function limit in production and had to redesign the sync+generate flow across three commits in one morning to work around it
On Saturday morning I opened my app and noticed the "Generate Now" button wasn't doing anything. No error. No toast. Just... silence. The drafts weren't generating. After some digging I found the culprit: Vercel Hobby caps serverless functions at 60 seconds. My sync-and-generate flow — which pulls recent commits from GitHub and then asks Claude to turn them into draft posts — was blowing past that limit in production. Locally it was fine, because local runs don't have the same cold start overhead and I was testing against small repos. My first instinct was a blunt instrument: cap the GitHub sync step at 10 seconds hard. That's commit `85a1981`. It helped, but the function was still one monolithic call doing too much, and I knew it was fragile. The real fix, which took two more commits across the same morning, was to rethink the unit of work. Instead of "sync all repos, then generate for everything," the new shape is one function call per repo: sync that repo, generate drafts scoped to it, move on to the next. Each call comes in around 50 seconds — well inside the cap. The cron job that runs this on a schedule mirrors the same loop, so the behavior is consistent whether you trigger it manually or it runs automatically overnight. All three commits were written by Claude. I described the constraint — "Vercel Hobby, 60 seconds, here's what's timing out" — and it restructured the flow. What I contributed was noticing the timeout was happening at all, which took me longer than I'd like to admit. There was an accidental upside. Because generation now runs repo-by-repo, I could show per-repo progress in the UI instead of a single spinner covering the whole batch. Forced constraints sometimes shake loose improvements you wouldn't have gone looking for. The takeaway for anyone else on Vercel Hobby: the 60-second limit is real and it will catch you in production when it doesn't catch you locally. The fix is almost always the same — make the unit of work smaller. Don't try to do everything in one call. Scope it down until each call has one clear job it can finish in time. I'm a non-technical founder building this entirely with AI-generated code [VERIFY: I should confirm this framing matches how I want to describe myself], so constraints like this are where I feel the gap most. I can't just read the stack trace and know what to do. But I'm getting better at describing the problem clearly enough that Claude can find the fix — and that's a skill I didn't expect to be learning when I started this.
Hit Vercel Hobby's 60-second function limit in production and had to redesign the sync+generate flow across three commits in one morning to work around it
1/ My app silently broke in production because of a 60-second limit I didn't know existed. Here's how I redesigned the whole sync+generate flow in one morning to fix it. 2/ Vercel Hobby plan caps serverless functions at 60 seconds. Locally, everything worked fine. In production, the function that synced GitHub commits AND generated draft posts was quietly timing out. No loud error. Just... nothing happening. 3/ First fix was blunt: cap the GitHub sync itself at 10 seconds. That bought some breathing room but didn't solve the real problem — the whole flow was still one big function call. 4/ The real fix: break it apart. Instead of "sync everything, then generate everything," the new shape is: - one function call per repo - sync that repo (~10s), then generate drafts scoped to it (~40s) - each call stays well under 60s - calls run sequentially, with per-repo progress shown in real time 5/ Claude wrote all three commits. I described the constraint, it figured out the restructure. What I actually did was notice the timeout was happening in the first place — which took embarrassingly long. 6/ The UI got a small win from this too. Because generation now happens repo-by-repo, I could show progress per repo instead of one spinner for the whole batch. Accidental improvement from a forced constraint. 7/ If you're on Vercel Hobby and doing anything that touches an external API in a serverless function: the 60s limit is real, it will bite you, and the fix is almost always "make the unit of work smaller." 7/
Wired up auto-sync from GitHub on every generate (both button and cron), then followed it up with UI polish on the Scheduled page — a full feature arc in one morning.
This past Saturday morning I shipped what felt like a complete little arc: a real feature fix, then a round of UI cleanup to match, all before lunch. The feature first. Every time you hit "Generate" in Build-in-Public Studio — whether that's you clicking the button or the cron job firing overnight — the app now automatically syncs your GitHub commits before doing anything else. No manual refresh step. No drafting posts about commits from three days ago because the local cache was stale. The problem sounds obvious when I write it out. Of course you want fresh data before generating. But the sync used to be a separate action, something you had to remember to do. Which means you'd forget, or not realize you'd forgotten, and the generate would run on whatever the app last knew about. The fix was to make sync the first thing that happens on every generate path, full stop. I described the problem to Claude roughly like that — "sync should always happen before generate, not as a separate manual step" — and it threaded the sync call into both the button handler and the cron job. I read through the diff, it looked right, and I shipped it. That's genuinely how this works. I'm not a developer. I can read code well enough to follow what changed, but Claude is writing all of it. That's not a caveat I'm hiding — it's the whole point of what I'm building in public. After that landed, I kept going. The dashboard had been quietly accumulating scheduled drafts, and it was starting to feel like a junk drawer. So I pulled them out onto their own /scheduled page — 60-day window — and added a Scheduled item to the sidebar between Dashboard and Batch. The dashboard is cleaner now. Feels more like a control panel and less like a pile. The last thing was small but bothered me more than it should have. Repos with no recent commits were showing a red error indicator. Red means something broke. But no commits isn't an error — it just means nothing happened yet. I changed it to muted grey text. Errors should be reserved for actual failures. I don't have a grand takeaway here. It was just a good morning: one real fix, two small improvements, a codebase that feels a little more intentional than it did yesterday. That's the thing about building with AI as a non-developer — the ceiling on what I can ship in a morning is way higher than I expected when I started. The floor is still me, figuring out what actually needs fixing.
Wired up auto-sync from GitHub on every generate (both button and cron), then followed it up with UI polish on the Scheduled page — a full feature arc in one morning.
1/ Every time you hit "Generate" in my app, it now silently syncs your GitHub commits first. No manual refresh. No stale data. Just: press button → get fresh commits → draft posts. Took me one morning to wire up. Here's what that actually looked like. 2/ The bug I kept hitting: you'd run a batch generate, and the app would draft posts about commits it already knew about — not the ones you pushed an hour ago. The fix was obvious in hindsight. Sync GitHub *before* generating, every single time, button or cron job. 3/ Claude wrote the whole thing. I just described the problem: "auto-sync should happen at the start of every generate path, not as a separate manual step." It threaded the sync call into both the button handler and the cron. I read the diff. It looked right. Shipped it. 4/ Then I kept going. The dashboard was getting cluttered with scheduled drafts, so I moved them to their own /scheduled page — 60-day window, clean sidebar nav item. Small thing, but the dashboard finally feels like a dashboard again. 5/ One detail I liked: repos with no commits used to show a red error indicator. That always felt wrong — no commits isn't an error, it's just... nothing happened yet. Changed it to muted grey text. Errors should mean something broke, not "it's quiet in here." 6/ Full feature arc in one morning: auto-sync → UI cleanup → better empty states. I'm a non-developer building this entirely with AI. Mornings like this still surprise me. 7/
Added a /api/health endpoint purely to diagnose a DB connection problem — a debugging step that reveals how much invisible infrastructure work goes into a side project.
This week I added an endpoint to my app that users will never see, never benefit from, and that I'll probably forget exists in two months. It's called /api/health, and all it does is check whether the server can reach the database. I built it because I was stuck. Data wasn't loading, and I had no idea why. The app wasn't throwing a clear error — it was just... not working. And when you're a beginner who doesn't write any code themselves (my whole codebase is generated by Claude), "not working" is a terrifying place to be. You can't read the stack and immediately know where to look. So I asked Claude to help me add a health check endpoint. The idea being: if I can hit /api/health and it tells me the DB connection is failing, then I know the problem is in the infrastructure layer, not in my query logic or my frontend code. I've isolated the variable. It worked. The endpoint came back with an error. DB connection was broken — turned out to be an environment variable that wasn't getting picked up correctly in the deployment environment [VERIFY: exact cause was the NODE_ENV-conditional config not running in prod context]. Fixing that one thing unblocked everything. But the bigger realization was this: I'd been thinking of my app as a single thing. You write the app, the app runs. What this debugging session made visible is that there are at least four or five distinct layers between "I clicked deploy" and "the user sees data" — the hosting environment, the environment variables, the DB connection pool, the ORM, the query itself. Any one of them can fail silently and make it look like any of the others are broken. Health check endpoints exist precisely to make those layers visible. I knew they existed in the abstract — I'd seen them in production systems — but I'd never understood *why* until I needed one badly enough to build one. The feature count didn't move this week. But I learned something concrete about how production software actually works, and my app is less fragile for it. For a project that's 100% AI-generated by a developer who's still figuring out what a connection pool is, that feels like real progress.
Added a /api/health endpoint purely to diagnose a DB connection problem — a debugging step that reveals how much invisible infrastructure work goes into a side project.
1/ I added a whole new API endpoint this week just to figure out why my database wouldn't connect. Not a feature. Pure desperation debugging. 2/ The endpoint is called /api/health. All it does is ping the DB and return "ok" or "error." 3 lines of logic. But it took me an hour to realize I even needed it. 3/ Here's the thing: when your app just silently fails to load data, you have no idea where the chain is broken. Is it the query? The ORM? The connection string? The environment variable that holds the connection string? 4/ /api/health let me isolate exactly one question: can the server reach the database at all? Answer: no. Which was actually useful! Now I had something to go fix instead of guessing. 5/ I write zero code myself — Claude generates everything. So this was me learning, via a debugging detour, that "the app" is actually 4 or 5 invisible layers that can each fail independently. 6/ Nobody talks about this part of side projects. The feature list is 0 items longer than yesterday. But the project is more stable than it was 24 hours ago. That counts. 7/
Discovered that importing Octokit inside claude.ts was silently breaking the bundle — had to move GitHub sync out to the caller layer (button + cron) to fix it.
This week I ran into one of those bugs that doesn't announce itself. No red error message, no stack trace pointing at the obvious culprit. The bundle just broke. Silently. Here's what happened: I'm building Build-in-Public Studio, and the entire codebase is AI-generated — Claude writes all of it. I ask for a feature, Claude produces the code, and I ship it. My job is to understand what I'm shipping well enough to catch when something goes wrong. I asked Claude to add GitHub sync. It did. It put the Octokit import inside claude.ts — the core file that handles all AI interactions. That seemed like a reasonable place for it at the time. GitHub sync is triggered by the AI workflow, so why not keep it together? Because bundlers don't care about your intuitions about cohesion, that's why. The import silently broke the bundle. I don't know exactly why Octokit specifically causes this — it might be Node-only APIs leaking into a context that expects browser-safe code, or an ESM/CJS mismatch, or something else entirely [VERIFY]. What I do know is that the fix required moving GitHub sync completely out of claude.ts and into what I'd call the "caller layer" — the actual button the user clicks, and the cron job that runs on a schedule. The core AI file got thinner. The edges got a little more responsibility. The bundle worked again. What sticks with me is how invisible this failure was. If you're an experienced developer, you probably have a mental model that tells you "Octokit shouldn't go there." I don't have that model yet. I'm building it one silent breakage at a time. The takeaway I'm carrying forward: in a bundled app, where you import something is a real architectural decision, not just an organizational preference. A library that works perfectly at the edge can corrupt things when it ends up deep in a shared core file. Keep the core thin. Push side-effects out. The fact that Claude generated this code and I had to debug it is kind of the whole point of what I'm building. I'm not a passive consumer of AI output — I'm the person who has to understand why it breaks. That gap between "Claude wrote it" and "I understand it" is where most of my learning is happening right now.
Discovered that importing Octokit inside claude.ts was silently breaking the bundle — had to move GitHub sync out to the caller layer (button + cron) to fix it.
1/ Importing one library in the wrong file silently broke my entire bundle. No error. No warning. Just... broken. Here's what I learned the hard way about where code lives. 2/ I'm building Build-in-Public Studio. The AI (Claude) generates all the code — I'm the one who has to understand what it actually does. This week: I asked Claude to add GitHub sync. It added the Octokit import inside claude.ts. Seemed fine. Was not fine. 3/ The bundle started breaking. Silently. Nothing in the logs told me "hey, Octokit doesn't belong here." I had to dig through the commit, read the diff, and slowly piece together that the import itself was the problem — not the logic around it. 4/ The fix was conceptually simple: move GitHub sync out of claude.ts and into the "caller layer" — the button that triggers it, and the cron job that schedules it. claude.ts should only do one thing. Pulling in a GitHub client isn't that thing. 5/ What I actually learned: in a bundled app, WHERE you put an import matters as much as WHAT you import. A dependency that's fine at the edge can corrupt the bundle when it ends up in a shared core file. 6/ I still don't fully understand why Octokit specifically causes this [VERIFY — could be Node-only APIs, ESM/CJS mismatch, or bundle size triggers]. But I understand the fix: keep your core AI layer thin. Push side-effects to the edges. 7/ The whole codebase is AI-generated. That means moments like this are part of the deal — Claude writes the code, I figure out why it breaks. Honestly? That's teaching me more than I expected. 7/
Hit Vercel Hobby's 60-second function limit in production and had to redesign the sync+generate flow across three separate commits to work around it — capping GitHub sync at 10s, then splitting into per-repo calls.
My app had been silently failing in production for who knows how long. The Vercel Hobby plan has a 60-second hard limit on serverless functions. My sync-and-generate flow — pull activity from GitHub, then use it to draft posts — was taking somewhere around 90 seconds. So every time it ran, whether triggered manually or by the cron job, it just died at the 60-second mark. No drafts generated. No obvious error surfaced to me. Nothing. I only caught it by going back to Vercel's docs after noticing the cron had never actually produced output. That's a weird feeling as a beginner: your app looks like it's running, the deploys are green, but the thing it's supposed to do has never worked. [VERIFY: whether Vercel shows timeout errors prominently in function logs — I may have missed them] My first instinct was to just cap the GitHub sync at 10 seconds. Don't let the data-fetch eat the whole budget. That helped, but it was a patch, not a fix. The underlying problem was the shape of the work: one massive function call trying to sync all repos, then generate all drafts, in a single execution. The real solution was to break it apart. Instead of "sync everything, then generate everything," the flow is now per-repo: sync one repo, generate drafts scoped to that repo, return, repeat. Each call tops out around 50 seconds. Comfortably under the limit. That redesign touched three commits across a couple of hours. And here's the part I want to be transparent about: I didn't write any of this code. The whole codebase is AI-generated — Claude wrote it, commit by commit, as I described what I needed. So "I refactored the sync flow" really means "I explained the problem clearly enough that Claude could redesign it." What I actually did was notice something was wrong, diagnose why, and translate that into a coherent prompt. That's not nothing — but it's also not the same as understanding every line of code that came out the other side. The takeaway I keep bumping into: free tier constraints aren't just about cost. They're architectural constraints. The 60-second function limit on Vercel Hobby isn't a footnote — it's a ceiling that determines how you have to structure your backend logic. If I'd known that at the start, I would have built per-repo calls from day one instead of discovering it the hard way in production. If you're building on Vercel Hobby: check your function durations early. The timeout won't announce itself loudly.
Hit Vercel Hobby's 60-second function limit in production and had to redesign the sync+generate flow across three separate commits to work around it — capping GitHub sync at 10s, then splitting into per-repo calls.
1/ My app silently broke in production yesterday. The Vercel Hobby plan kills any serverless function after 60 seconds. My sync+generate flow was taking ~90s. Every run: timeout, no drafts, no error message I could see. 2/ I didn't even know the limit existed until I went digging. The function just... stopped. No crash, no alert. I only figured it out by reading Vercel's docs after noticing the cron job had never successfully completed. [VERIFY: whether Vercel surfaces this as a visible error in logs] 3/ First fix: cap the GitHub sync at 10 seconds hard. Better to get partial data than blow the whole budget on fetch alone. That bought some room but didn't actually solve the core problem. 4/ The real fix was rethinking the shape of the work entirely. Instead of one big "sync everything, then generate everything" call, I split it into per-repo calls — sync one repo, generate drafts for it, done. Each call runs in ~50s. Well inside the limit. 5/ The codebase is 100% AI-generated (Claude wrote all of it). So this wasn't me refactoring — it was me describing the problem to Claude and having it redesign the flow across three commits. What I actually contributed: knowing something was wrong and understanding why. 6/ Beginner lesson I keep relearning: free tier limits aren't just billing footnotes. They shape your architecture. If you're on Vercel Hobby, your functions have a 60s ceiling. Design for it from the start — or you'll redesign for it later like I did. 7/
Added a hub command-loop simulator so the full actuation path (zone run/stop, schedules) can be tested in dev without any physical hardware.
I've been building a smart irrigation system — WyCo — and I hit the obvious problem with any hardware project: you can't run the full actuation path in development unless you have the hardware sitting on your desk. The system is designed so a Raspberry Pi on your local network is the thing that actually opens and closes valves. The cloud app coordinates, but it never directly actuates — it queues a command, the hub polls for it, acks it, and posts back an event. That separation is intentional; I didn't want to be able to accidentally water someone's lawn from a browser tab. But it also meant I had no way to test the complete loop without a Pi wired to a real valve. So for Phase 4, I asked Claude to build a simulator. It's a seed script that impersonates the hub. It polls /commands/pending, picks up whatever's queued (a "run zone 3 for 10 minutes" command, say), marks it sent, posts an ack, then fires a zone_run event back to the API. The API receives that event, converts the watering volume from liters/area into millimeters, and reduces the zone's water deficit accordingly. That last piece closes a loop I'd built two phases earlier for forecast-vs-actual tracking — I honestly forgot it was there until the event landed and the depletion number changed. The full round-trip, end to end, in a local dev environment with no hardware. There are a few guard rails baked in that I wanted to verify were actually working. Durations are range-checked — anything outside 1 to 7200 seconds gets rejected with a 400. The run/stop endpoints are hub-only; if you try to call them from the cloud-side app, you get a 403. Schedules are HMAC-signed when the hub fetches them, so the Pi can verify they haven't been tampered with in transit. All of that now has test coverage — 88 tests, all green. The thing I keep sitting with: every line of this was written by Claude (Opus 4.8, co-authored on every commit). I described the command loop I wanted, the event schema, the UI for the Schedules page with its day-of-week pills and smart-mode toggles — and it built the plumbing. I'm learning what the plumbing does by reading it after the fact, which is a strange way to build but it's working. What I don't know yet is whether the simulator's assumptions actually match what hubsync.py does on a real Pi. [VERIFY] The code was written to mirror the Pi client exactly, but "written to mirror" and "actually mirrors" are different claims. That's the next thing to find out — get this deployed to hardware and see where the sim diverges from reality. Phase 4 is done. The gap between "it works in the simulator" and "it works in the yard" is what Phase 5 is for.
Added a hub command-loop simulator so the full actuation path (zone run/stop, schedules) can be tested in dev without any physical hardware.
1/ I can't wire up a Raspberry Pi to real sprinkler valves in a dev environment. So Claude built a simulator that pretends to be the hub — polling commands, acking them, firing zone_run events — the full loop, no hardware required. 2/ Here's what the loop actually looks like: User taps "Run 10m" → API queues a command → Simulator polls /commands/pending → Marks it "sent" → Posts ack → Posts zone_run event → Depletion reduces by 2.4mm All of that, in a local seed script. 3/ The depletion part is the bit I didn't expect to care about. When the event comes in, it converts liters/area → mm and reduces the zone's water deficit. That closes a loop I set up two phases ago for forecast-vs-actual tracking. It just... worked. 4/ The cloud version of the app never sends actuation commands — it shows a "recommendation" badge instead. Only a hub site (a Pi on your local network) can actually fire a valve. The simulator lets me test that distinction without owning two deployments. 5/ All 88 tests pass. The HMAC-signed schedule payloads, the range-checks (1s..7200s, anything outside gets a 400), the 403 when you try to run a zone from the cloud side — all covered. 6/ Every line of this is AI-generated. Claude Opus 4.8 wrote the simulator, the API, the Schedules page, the command loop. I described what I needed; it built the plumbing. I'm still learning what the plumbing does by reading it. 7/ Phase 4 done. Next up: getting this onto an actual Pi and seeing if the simulator's assumptions hold when real valves are involved. [VERIFY: whether the hubsync.py on-device behavior matches what the sim models]
Designed an org-scoped multi-tenant query layer where tenancy enforcement lives in one central place rather than scattered per-route — an architectural decision worth unpacking for beginners.
A few days ago I merged two commits that I keep coming back to, not because they were hard to ship but because I finally understand *why* they're shaped the way they are. The project is WyCo, an irrigation management system I'm building entirely with Claude writing the code. I'm not a developer — I can read code slowly, I can reason about it, but I don't write it. That context matters here. The problem I needed to solve: the app has organizations. Each org owns sites. Each site owns hubs, nodes, zones, tanks, schedules, and a pile of telemetry. There are 17 tables in total, and every single row belongs to an org. The cardinal rule is that one org must never be able to read another org's data. The obvious approach — and the one I would have asked for if I didn't know better — is to just add a `WHERE org_id = ?` check everywhere you query. In every route. Every handler. Sprinkled throughout the codebase. Claude pushed back on that. It proposed a query layer instead: two functions, `requireSite` and `listSites`, that have the org predicate baked into them at the point of construction. A route handler doesn't receive a raw database handle and a reminder to filter correctly. It calls `requireSite(orgId, siteId)` and either gets back a site or gets a NotFound or Forbidden error with the right HTTP status code already attached. There is no version of that function that forgets the org check. The unsafe path doesn't exist at the layer the handler operates at. When I asked Claude to explain why this was better than the per-route approach, the answer was something I've been thinking about since: conventions fail under pressure. When a codebase is small and you wrote every line last week, you remember to add the WHERE clause. Six months later, three features deeper, you're tired and you're fixing something unrelated and you write a new query and you forget. You don't find out until a customer reports something strange. A structural boundary doesn't forget. That's the whole point. There's also a function called `resolveHubSite` that ties hub-token authentication back to a single site and org — so even the IoT device auth path goes through the same enforcement layer, not a separate one that could drift. And `requireEntitlement` is stubbed for now (metering is Phase 5), but the tables exist and the call sites are already in place. The schema itself is 17 tables, all org-scoped, built with Drizzle. One command — `pnpm migrate` — creates everything cleanly on a fresh store, whether that's PGlite embedded locally or Postgres on Neon in production. I didn't design this pattern. I described the problem, Claude proposed the shape, and I had to ask what "multi-tenant" meant. But I understand now why the boundary has to be structural rather than conventional. That understanding is mine, and it changes how I'll think about the next problem. If you're early in building something with multiple users or accounts, the question worth asking early is: where does the enforcement live, and can it be forgotten?
Designed an org-scoped multi-tenant query layer where tenancy enforcement lives in one central place rather than scattered per-route — an architectural decision worth unpacking for beginners.
1/ Every route in my irrigation app could have checked "does this user own this record?" separately. Instead, I built a query layer where that check is physically impossible to skip. Here's why that matters — even if you've never heard the word "tenancy" before. 2/ The app has orgs. An org owns sites. Sites own hubs, nodes, zones, tanks, schedules, telemetry — all 17 tables. The nightmare scenario: one org accidentally reads another org's data. Not malice. Just a missed WHERE clause. 3/ The naive fix: add `WHERE org_id = ?` to every query, in every route handler. The problem: you're one tired afternoon away from forgetting it somewhere. And you won't know until a customer sees data they shouldn't. 4/ What I built instead: `requireSite` and `listSites` bake the org predicate into the query itself. A route handler calls `requireSite(orgId, siteId)` — it either returns the site or throws NotFound/Forbidden with the right HTTP status. There's no version that skips the check. 5/ The handler literally cannot forget. The unsafe operation doesn't exist at the layer they're working at. This is what "enforcement in one central place" means. The security boundary is structural, not a convention people have to remember. 6/ I didn't come up with this pattern. I described the problem to Claude and it proposed it. I had to ask what "multi-tenant" meant. But now I understand *why* it's the right shape — and that's the part I actually own. 7/ The schema is 17 tables, every row org-scoped, prefixed text IDs, built with Drizzle + PGlite for local/test and Postgres (Neon) for prod. One command: `pnpm migrate`. Fresh store, all 17 tables. That part felt good. 7/
Wired a soil-water-balance math engine and nightly forecast-bias correction job — agronomic logic most developers would never write from scratch, produced entirely by AI.
I want to be honest about something: I do not know agronomy. I couldn't have written a soil-water-balance model before this week, and I probably still couldn't write one by hand. What I can do is describe a problem clearly to Claude and then read what comes back. This week that produced four commits that, together, form the agronomic brain of my irrigation system — WyCo Irrigation, a project for Wyoming ranch water management. Every line was written by Claude Opus 4.8. That's not a footnote. It's the premise. Here's what the code actually does, because I think the specifics matter more than the vibe. The foundation is a pure math module built around what the commit message calls a "single-Kc FAO-56-style bucket model." FAO-56 is a UN Food and Agriculture Organization standard for calculating crop water requirements. The formula: ETc = ET0 × Kc × bias. It takes evapotranspiration (how much water the plants and soil are losing to the air), adjusts for the crop coefficient and a learned bias, and tracks cumulative depletion against the total available water in the soil. When depletion crosses 50% of available water, the zone is flagged as needing irrigation. If a 48-hour rain forecast shows ≥5mm coming, or rain probability is ≥70%, the recommendation flips to "skip." The system looks ahead so it doesn't water before a storm. On top of that, there's a nightly job that compares yesterday's stored forecast snapshot against what Open-Meteo actually recorded for that day. If reality ran hotter and drier than predicted, the per-site ET0 bias nudges upward (learning rate 0.2, clamped between 0.5 and 1.5). The model is slowly calibrating itself to distrust its own forecasts in the right direction. The part I find most interesting is the manual calibration endpoint. A farmer can report soil moisture — a percentage, or just a 1-to-5 score for how the plants look — and the system treats that observation as ground truth. It resets the zone's depletion bucket and shifts the calibration bias to close the gap between what the model thought and what the farmer saw. The AI photo analysis method is deferred to a later phase. Right now it's just: human says the ground is dry, model adjusts. Four commits. 49, 53, 58, 61 tests. All green. The thing I keep running into with this project is that AI assistance isn't mostly useful for boilerplate or autocomplete. The more interesting value is domain access — the ability to reach into fields like irrigation science or bias correction statistics that I would never spend months learning from scratch, and get working, tested, reasoned code out the other side. [VERIFY: whether the FAO-56 implementation here is fully standards-compliant or a practical approximation — worth a domain expert review before production use.] I'm building this in public because I think the honest version of "AI-generated code" looks different from what people imagine. It's not magic and it's not sloppy. It's a collaboration where I'm the one who understands the problem domain at the business level, and Claude is the one who knows how to express solutions in code and math I'd otherwise never reach. That gap is where the whole project lives.
Wired a soil-water-balance math engine and nightly forecast-bias correction job — agronomic logic most developers would never write from scratch, produced entirely by AI.
1/ I don't know agronomy. I've never modeled soil moisture. I couldn't tell you what FAO-56 is off the top of my head. This week Claude wrote a full soil-water-balance engine for my irrigation system. Here's what that actually means: 2/ The core: a single-Kc FAO-56-style "bucket" model. ETc = ET0 × Kc × bias. It tracks depletion against total available water, hits a management-allowed-depletion threshold of 0.5, and spits out: fine / needs_water / skip. I did not write that math. Claude did. 3/ "Skip" fires when 48-hour rain forecast is ≥5mm OR rain probability is ≥70%. So the system looks ahead, sees rain coming, and tells the zone not to bother running. That's genuinely useful agronomic logic — and it came from a commit message, not my brain. 4/ Then there's the nightly forecast-bias correction job. Every night it diffs yesterday's stored forecast against what Open-Meteo actually recorded. If actual ET0 ran higher than forecast, it nudges the per-site bias up. Learning rate 0.2, clamped between 0.5 and 1.5. The model is slowly learning to distrust its own forecasts. 5/ And a manual calibration endpoint: a farmer reports soil moisture (a number, or just a 1–5 "plant looks sad" score) and the system treats it as ground truth — resets the zone bucket and shifts the calibration bias to reconcile model vs reality. Human-in-the-loop, written by AI. 6/ All four commits: 49 tests, 53 tests, 58 tests, 61 tests. All green. I'm a solo dev with no background in agriculture. Claude Opus 4.8 wrote every line of this. That's not a disclaimer — it's the whole point of the project. 7/ The thing I keep noticing: AI doesn't just help with boilerplate. It reaches into domains I'd never enter alone — FAO irrigation standards, bias correction algorithms, soil science — and produces working, tested code. I'm just the one asking the questions. /
Shipped a pure-JS EXIF strip and R2/in-memory storage interface — a concrete example of Claude solving a real infra constraint (no native sharp/exifr on edge) without being asked to explain the tradeoff.
I'm building an irrigation system management tool, and this week Claude shipped something that made me stop and actually think about what's happening here. The feature was simple on the surface: users upload photos of irrigation sites. But photos taken on a phone contain GPS coordinates baked into the EXIF metadata. That's PII. Those coordinates cannot reach storage — not in a buffer, not in a temp file, nowhere. Strip first, store second, no exceptions. I didn't spec that constraint explicitly. I described the feature. Claude figured out the privacy implication and handled it. Here's where it gets more interesting. Cloudflare Workers — where this runs — doesn't support native image processing libraries. No sharp. No exifr. If you try to pull those in, the edge runtime rejects them because they depend on native binaries that don't exist in that environment. [VERIFY: worth confirming this is still the case with current Workers runtime] I didn't know this was a constraint. I definitely didn't know to avoid it. What Claude built instead: a pure-JS EXIF stripper that works directly on the raw byte array. For JPEGs, it walks the segment markers and drops APPn, EXIF, and COM blocks, then hands back a clean file. For PNGs, it removes the metadata chunks. No dependencies. Runs on any JS runtime. The smoke tests it wrote check two things: that the EXIF marker is actually gone after stripping (not just that the function ran without crashing), and that the file is smaller afterward. Both pass. 93 tests, 93 passing. In the same commit, it also built the storage interface — Cloudflare R2 when the R2 environment variables are present, an in-memory store when they're not. It mirrors a pattern it already used for the database layer (PGlite as a local fallback for Postgres). I didn't ask it to be consistent with that pattern. It just was. The thing I keep returning to: I came in with a feature description. Claude came back with an architecture that accounted for a runtime constraint I didn't know existed, a privacy risk I hadn't fully articulated, and a testing strategy that actually proves the strip works rather than just proving the code runs. I'm a beginner. I'm not in a position to evaluate whether every decision here is optimal. But I can see that the decisions are reasoned — and that they're being made at a level I wouldn't have reached on my own. That's the whole experiment: what does it look like when someone who doesn't know what they're doing builds something real anyway, with Claude doing the actual engineering? This commit is a pretty clean answer to that question.
Shipped a pure-JS EXIF strip and R2/in-memory storage interface — a concrete example of Claude solving a real infra constraint (no native sharp/exifr on edge) without being asked to explain the tradeoff.
1/ My app handles irrigation site photos. Photos contain GPS coordinates in the EXIF data. GPS coordinates are PII. So the photos can never hit storage with metadata intact. I didn't know how to strip EXIF. Neither constraint nor solution came from me. 2/ The edge runtime (Cloudflare Workers) doesn't support native image libs like sharp or exifr. I didn't know that was even a constraint until Claude just... worked around it without being asked. 3/ What it built: a pure-JS EXIF stripper that walks raw JPEG bytes and removes APPn/EXIF/COM segments, then reconstructs the file. Same for PNG metadata chunks. No native deps. Runs anywhere. 4/ The output: SOI and EOI markers intact, file size smaller, GPS gone. It wrote smoke tests to verify the strip actually removes the marker and that the byte count drops. 93/93 passing. 5/ It also built the storage interface in the same commit — R2 when the env vars are set, in-memory fallback when they're not (mirrors the PGlite pattern it used for the DB). put/get/delete. Clean seam. 6/ The thing I keep noticing: I described what the feature needed to do. I didn't describe the infrastructure constraints. Claude found the edge runtime limitation, chose pure-JS as the solution, and handled the privacy angle without a prompt. I'm not sure I would have caught the GPS/PII issue on my own. [VERIFY before shipping to prod] 7/
Built a full multi-phase SaaS irrigation platform in roughly 2 days — from blank monorepo scaffold to AI vision pipeline, billing, and a V5 UI re-skin — entirely with AI-generated code.
Two days ago this repo didn't exist. Today it has an AI vision pipeline, entitlement metering with a 402 block, a hub command-loop simulator, weather jobs, water-balance calculations, zone scheduling, and a fully re-skinned UI across every page. 135 smoke tests, all green. I want to be precise about what "2 days" means here, because I think vague speed claims are mostly useless. Here's the actual commit sequence: Monday morning I scaffolded a pnpm monorepo — Express 5, React 19, Vite, Tailwind v4, one health endpoint, one blank React page. That's it. By end of day I had Phase 2 (weather/smart scheduling) and Phase 4 (hub command loop with HMAC-signed payloads) both shipped and tested. Tuesday I built the paid layer: an Anthropic vision adapter with forced tool-use structured output, a metering system that gates taster credits → site quota → org credits → hard cap, and the full analyze flow from photo upload through EXIF stripping through quality gate through calibration shift. Then a final design pass — signal-strength bars, battery/AC indicators, severity-coloured alert cards, grouped metric cards with trend arrows. Every commit has the same co-author line: `Claude Opus 4.8 (1M context)`. I don't have a CS background. I cannot write this code myself. The honest version of what I contribute is: I spec the behavior, I read the diffs carefully enough to catch things that don't make sense, I decide what not to ship, and I set rules like "if there's no real data source for a UI element, leave it out rather than fabricate it." That last one sounds obvious but it actually requires constant pushback — AI will fill in plausible-looking UI all day if you let it. The thing I keep noticing is that the constraint isn't speed anymore. Two days for a multi-phase SaaS with billing and an AI pipeline is fast by any measure. The constraint is judgment: knowing which features to build, which shortcuts create real debt versus acceptable debt, and when "all tests green" is meaningful versus cosmetic. Those are things I can develop without being able to write the underlying code, and that realization is genuinely changing how I think about what "learning to build software" means right now. [VERIFY: exact Claude model version listed in commits is "Opus 4.8" — double-check this is the correct model name before posting, it may be a placeholder or internal label] I'm not going to tell you this replaces senior engineering experience — it doesn't, and I'd be skeptical of anyone who said otherwise. What I will say is that the gap between "I have an idea and a clear spec" and "I have a working, tested, designed thing" is smaller than it's ever been. I'm watching that gap close commit by commit, and I'm not sure the industry has fully absorbed what that means yet.
Built a full multi-phase SaaS irrigation platform in roughly 2 days — from blank monorepo scaffold to AI vision pipeline, billing, and a V5 UI re-skin — entirely with AI-generated code.
1/ I went from a blank monorepo to a multi-phase SaaS irrigation platform in roughly 2 days. Billing, an AI vision pipeline, a full UI re-skin, hub command loops — all of it. Every line of code written by Claude. Here's what that actually looked like: 2/ Day 1, morning: scaffold a pnpm monorepo with Express 5, React 19, Vite, Tailwind v4. One health endpoint. One minimal React page. Build green. That's it. That's the whole thing. 3/ By end of Day 1: weather jobs, water-balance ticks, forecast-bias calculations, a hub command-loop simulator with HMAC-signed payloads, zone schedules, and a full local round-trip test. 88 passing tests. I still barely know what HMAC means. 4/ Day 2: an AI vision pipeline (photo → EXIF strip → Anthropic structured output → quality gate → calibration shift), entitlement metering with a 402 block, and Stripe billing groundwork. 119/119 tests green. The quality gate rejects junk images without billing you. That detail matters. 5/ Final stretch: a full V5 UI re-skin across every page. Signal-strength bars. Battery/AC indicators. Severity-coloured alert cards. Grouped metric cards with trend arrows. One rule I set: if there's no real data source for a UI element, leave it out. No fake tabs. 6/ The co-author on every single commit: Claude Opus 4.8 (1M context). I don't have a CS degree. I can't write this code myself. But I can spec it, review it, push back on it, and decide what not to ship. That turns out to be most of the job. 7/ I'm not claiming this is production-ready or that 2 days of AI commits equals 2 days of senior engineering. What I am saying: the gap between "idea" and "working, tested, designed thing" is smaller than it's ever been, and I'm watching it close in real time. 8/
A stuck 'quick read' flow needed three coordinated fixes: precomputing failure marking, a client-side recovery re-fire, and a card timeout — tracing one UX hang to its root cause across the stack.
Last week I had a bug that I genuinely couldn't explain by looking at any single piece of the code. The "quick read" flow in my property reconfiguration app would sometimes just hang. No error message. No loading state that resolved. The card would sit there, mid-process, and nothing would happen. If I refreshed the page, everything was fine — which told me the underlying data wasn't corrupted, just stuck. But that's all I knew. I described the symptom to Claude and started pulling on threads. It turned out to be three separate gaps that only produced this bug when they all lined up at once. The first was in how failures got recorded. When a read attempt failed, the failure wasn't being written back into the precomputed data the app relies on. So when anything downstream looked at that data to figure out what to do next, it just saw "pending" — not "failed." The system had no memory of anything going wrong. The second gap: even if the failure had been recorded, the client wasn't watching for it. There was no recovery logic — no "I see a failure here, let me try again." The app would check, see a bad state, and then do exactly nothing about it. The third gap was more of a future-proofing problem. What if the recovery attempt also hung? There was no timeout on the card. It could theoretically wait forever. So Claude added one — a hard deadline after which the card fails visibly rather than invisibly. Three fixes, shipped together in one commit: precompute the failure marking, add a client-side recovery re-fire, add a card timeout. What I keep thinking about is that I couldn't have found this by reading the code top-to-bottom. Each piece looked fine in isolation. The bug lived in the space between them — in what wasn't being passed along, what wasn't being checked, what wasn't being bounded. That's a harder thing to see. I still wrote none of this code myself. The entire codebase is Claude's work. My job was noticing that something felt off and being stubborn enough to keep asking why until the answer made sense. The takeaway, if there is one: when a bug needs multiple coordinated fixes, that's usually a sign the system was missing a feedback loop somewhere. One thing wasn't telling another thing what had happened. Find the gap in the communication, and the fixes follow pretty naturally from there. [VERIFY: whether the timeout value Claude chose is actually the right threshold for real-world usage — haven't tested under load yet.]
A stuck 'quick read' flow needed three coordinated fixes: precomputing failure marking, a client-side recovery re-fire, and a card timeout — tracing one UX hang to its root cause across the stack.
1/ The "quick read" flow in my app was silently hanging. No error. No spinner. Just... nothing. Tracking it down took three separate fixes across the stack. 2/ The symptom: a card would get stuck mid-read and never recover. Users (me, mostly) would just sit there waiting. Refreshing fixed it, which meant the state was salvageable — just nobody was doing the salvaging. 3/ Root cause #1: when a read attempt failed, the failure wasn't being marked on the precomputed data. So on the next pass, the app had no idea anything had gone wrong. It just saw the same "pending" state and shrugged. 4/ Fix #1: precompute the failure marking. Before the client even tries to recover, the failure gets written into the right place so the system actually knows what it's dealing with. 5/ Root cause #2: even with the failure marked, the client wasn't doing anything about it. There was no retry — no "hey, something looks wrong, let me re-fire this." It just accepted the broken state. 6/ Fix #2: a client-side recovery re-fire. If the flow detects a marked failure, it now kicks off another attempt automatically. The user never has to know it happened. 7/ Fix #3: a card timeout. Because "what if the re-fire also hangs?" is a real question. The card now has a hard deadline — if it hasn't resolved by then, it fails loudly instead of quietly. 8/ Three fixes, one UX hang. I wrote zero of this code myself — Claude traced the whole thing and built every layer of the fix. I just knew something felt broken and kept asking why. 9/ The part I keep thinking about: none of these were obvious in isolation. You had to see the precompute gap, the missing retry, AND the absent timeout as one connected problem. That's the diagnosis. The code was almost the easy part. /
New users silently had no profile row after sign-up — fixed by adding a DB trigger to auto-create the profiles row, a P1 auth bug introduced by the migration.
For some number of days — I don't know exactly how many — every new user who signed up for my app had a silently broken account. Not broken in an obvious way. The signup flow completed. No error screen. But underneath, a critical database row was missing, and any attempt to actually use the core features of the app would fail with a foreign key violation that never surfaced to the user. Here's what happened. My app uses a `profiles` table as the anchor for everything else. When a user signs up, a row in `user` gets created by the auth system, and a corresponding row in `profiles` needs to be created at the same time. Properties, imports, captures — they all hang off `profiles.id`. No profiles row means none of that works. When I was on Supabase, a database trigger handled this automatically. When I migrated to Better Auth, I replaced the trigger with an `afterCreate` hook — a function Better Auth calls after a user is created. Seemed like a clean swap. The problem is subtle and, honestly, the kind of thing I wouldn't have figured out without having Claude walk through the auth library's internals with me. Better Auth's `afterCreate` hook runs *inside* the same transaction that creates the `user` row. But my hook was using a separate database connection to insert into `profiles`. From that connection's perspective, the new `user` row didn't exist yet — transactions are invisible to other connections until they commit. So the foreign key check (`profiles.id` must reference a real `user.id`) failed every time. Better Auth caught the error and swallowed it. No exception propagated. The signup appeared to succeed. The fix was to replace the hook with a proper `AFTER INSERT` trigger on the `user` table, added in migration `0007`. A trigger runs in-transaction, so the `user` row is visible when the trigger fires. We also had to set `app.current_user_id` inside the trigger so the RLS policy (`profiles_insert_self`) would allow the insert — without that, the trigger would fire and immediately get blocked by row-level security. The migration also included a backfill for any users who already existed without a profiles row. Then I removed the `afterCreate` hook entirely since the trigger makes it redundant. Pushed to Neon prod on June 10th. Verified with a scripted round-trip: create a user, do an RLS-scoped insert, select it back, delete it. All green. The entire fix — migration, trigger, RLS handling, backfill — was written by Claude Opus 4.8. I'm a beginner. I understood maybe 60% of what was happening while we were working through it. Writing this post got me to closer to 90%. The takeaway I'm sitting with: when you migrate between auth systems, "functionally equivalent" replacements aren't always equivalent in ways that matter. Transaction visibility is real. And if the library you're using swallows errors quietly, you can ship a broken signup flow and never know.
New users silently had no profile row after sign-up — fixed by adding a DB trigger to auto-create the profiles row, a P1 auth bug introduced by the migration.
1/ Every new user who signed up for my app had a silently broken account. No error. No warning. Just... missing data, and a feature that didn't work. Here's the bug that took me (and Claude) a few hours to untangle. 2/ When a user signs up, my app needs to create two things: a row in `user`, and a matching row in `profiles`. The profiles row is what everything else — properties, imports, captures — hangs off of. 3/ The old setup used a Supabase trigger to create the profiles row automatically. When I migrated to Better Auth, I replaced it with an `afterCreate` hook. Seemed fine. It wasn't. 4/ The problem: Better Auth's `afterCreate` hook runs *inside* the create transaction. The new `user` row doesn't exist yet to other connections. My hook used a separate DB connection — so when it tried to insert into `profiles`, the FK check for `profiles.id -> user.id` failed. 5/ Better Auth swallowed the error silently. No exception surfaced. Every single new signup got a `user` row and nothing else. Then when they tried to use the app: `properties.user_id -> profiles(id)` FK fail. Capture broken. Import broken. Silent. 6/ The fix: a proper AFTER INSERT trigger on `user`, defined in a migration (0007). It runs in-transaction, so the user row IS visible. Also sets `app.current_user_id` so the RLS policy (`profiles_insert_self`) actually passes. Plus a backfill for any existing users who got caught by this. 7/ Removed the `afterCreate` hook entirely. Pushed to Neon prod. Verified with a full scripted round-trip: create user → RLS-scoped INSERT → SELECT → DELETE, all green. The whole fix was written by Claude Opus 4.8. I understood maybe 60% of it while it was happening. I understand closer to 90% now that I've written this out. 8/ Takeaway: when you migrate auth systems, the "equivalent" replacement for a trigger isn't always equivalent. Transaction visibility is a real thing, and ORMs/auth libraries that swallow errors will not warn you when it bites you./
JSONB double-encoding silently corrupted every analysis result after the Neon migration — the bug existed in two separate layers (postgres.js and a Supabase shim) and was only caught days later.
After migrating to Neon, my app spent days quietly storing corrupted data. Every analysis result. Every import. All wrong. The app didn't crash — it just lied, until it didn't. Here's the actual shape of what happened. The codebase is 100% written by Claude. I'm a beginner. I don't have a decade of PostgreSQL intuition telling me to watch out for double-encoding footguns. So when I moved to Neon and started using postgres.js properly, I hit one of those bugs that only bites you if you know the internals — and I didn't. The pattern I had everywhere was `${JSON.stringify(obj)}::jsonb`. Looks reasonable, right? You're being explicit about the type. But postgres.js, when it sees a `::jsonb` cast, runs its own JSON serializer on the value too. So the database receives a JSON-stringified string *of* a JSON string — a jsonb scalar string, not an object. `jsonb_typeof` returns `'string'` where it should return `'object'`. Every jsonb operator breaks. When the client tries to spread the value, it mangles into a character-indexed object. I caught this in commit 4b5096c. Claude helped me write a `jsonb()` typed-parameter helper that wraps `sql.json`, strips the manual stringify, drops the explicit cast, and documents the footgun right there in the code so future-me doesn't repeat it. We replaced all 8 call sites. I thought that was the end of it. It wasn't. Two days later, production went down. AppErrorBoundary took out every page when the client tried to read `structured.reconfiguration_ideas[0]` and hit a scalar string instead of an array. The same bug existed in a second place I hadn't looked: my Supabase compatibility shim. It was doing its own manual `JSON.stringify` on jsonb values, with its own `::jsonb` casts, completely independently of the sites I'd already fixed. With `prepare:false`, postgres.js describes the statement, resolves the parameter type to jsonb, and applies *its* serializer on top of the shim's. Same double-encoding. Different file. Different code path. Same corrupted rows in the database. The full fix had three parts. First, the shim now passes values through the same `jsonb()` helper — no manual stringify, no cast. Second, a migration (idempotent, with a verification query) to repair the rows already corrupted in production [VERIFY: confirm migration 0008 ran cleanly against all affected rows]. Third, client-side guards: a `parseIfString` coercion in the query layer, and an optional chain at the crash site so a malformed row degrades to a placeholder instead of taking down the page. The thing that sticks with me is how long this ran silently. Silent data corruption doesn't give you a stack trace. It doesn't page you. It just sits there being wrong until something downstream tries to use the data in a way that finally breaks visibly. I'm still learning what questions to ask when something "works" after a migration. Apparently "is the data actually structured correctly in the database" needs to be on that list now.
JSONB double-encoding silently corrupted every analysis result after the Neon migration — the bug existed in two separate layers (postgres.js and a Supabase shim) and was only caught days later.
1/ My app silently stored corrupted data for days after migrating to Neon. Every single analysis result. I had no idea until the whole app crashed. Here's the two-layer bug that caused it. 2/ When you write `${JSON.stringify(obj)}::jsonb` in postgres.js, you're already giving it a JSON string. But postgres.js sees the `::jsonb` cast, runs its OWN JSON.stringify on top, and stores a jsonb scalar string instead of an object. Double-encoded. Silent. Wrong. 3/ I caught that (commit 4b5096c), wrote a `jsonb()` helper, replaced all 8 call sites. Fixed, right? Not quite. 4/ There was a second layer. My Supabase-compatibility shim was doing the same thing independently — manually stringifying values AND adding `::jsonb` casts. Same footgun, different file. 5/ With `prepare:false`, postgres.js describes the statement, resolves the param type to jsonb, and applies its serializer again. The value going into the database: a jsonb scalar STRING. Not an object. A string. 6/ Days passed. Then: production outage. The client tried to read `structured.reconfiguration_ideas[0]`, hit the mangled scalar, and AppErrorBoundary took down every page. 7/ The fix required three things: - the `jsonb()` typed-parameter helper (no manual stringify, no cast) - a migration to repair corrupted rows already in the DB (idempotent, with a verification query) - client-side guards so one bad row degrades gracefully instead of killing the app 8/ The whole codebase is written by Claude. I'm not a professional developer. I would not have found "postgres.js describes the statement and resolves param types" on my own. What I did learn: silent data corruption is the scariest kind of bug. It doesn't crash. It just... lies. 9/
ESM-only better-auth caused a cascade of prod 500s on Vercel — fixed by pinning Node 22.x, then switching to a native dynamic import, then using a literal dynamic import to satisfy Vercel's bundler (three separate commits to crack one boot error).
Yesterday every single API route in my app returned a 500 in production. Not some routes — all of them. The app was dead on Vercel while it worked fine on my machine. I'm a beginner. The whole codebase is written by Claude (Opus 4.8). So when something breaks in prod that doesn't break locally, I'm pretty dependent on Claude to help me understand what's actually happening. What followed was three separate commits across about 20 minutes, each one fixing a real problem and uncovering the next one hiding behind it. The root issue: better-auth is an ESM-only package. My build pipeline compiles everything to CommonJS. Node 20 — Vercel's default runtime — will not `require()` an ESM module. It throws ERR_REQUIRE_ESM and stops. Since every API route resolves a session through a shared `with-session.ts` file that imports auth, every route died at boot. **Commit one** was the obvious fix: pin `engines.node` to `22.x` in package.json. Node 22.12+ added the ability to `require()` ESM modules that don't use top-level await. Shipped. Still 500s. What I didn't know — and now do — is that `engines.node` doesn't actually change what runtime Vercel deploys your functions on. It's more of a signal than a command. [VERIFY — this is my read of what happened, not confirmed Vercel docs] **Commit two** went deeper. Claude suggested wrapping the import in `new Function('return import(s)')`. The idea: esbuild parses your source and rewrites `import()` to `require()` — but it can't see inside a dynamically constructed string at parse time, so this import escapes unmodified. Node runs the real native dynamic import at runtime, ESM loads fine. It worked locally. In prod: ERR_MODULE_NOT_FOUND. The same trick that hid the import from esbuild also hid it from Vercel's file tracer (`@vercel/nft`), which is what decides what actually goes into your function bundle. better-auth was never deployed. The module didn't exist at runtime. **Commit three** was almost embarrassing. A literal `await import('better-auth')`. That's it. esbuild recognizes a dynamic import with a static string specifier and — unlike a regular `import` statement — leaves it as a native dynamic import rather than rewriting it to `require()`. And `@vercel/nft` can read the specifier just fine, so the package gets bundled. Both failure modes solved by the syntax I'd been trying to avoid. The takeaway I'm sitting with: when you're fighting a bundler, obscuring things from it tends to obscure them from everything else too. The "clever" workaround in commit two was clever in exactly the wrong direction. I didn't know any of this about CJS/ESM interop before this week. I still couldn't write a webpack config from scratch. But I now have a pretty concrete mental model of what `@vercel/nft` does and why a dynamic import isn't the same as a static one to a bundler. That came entirely from debugging three commits with Claude, reading the error messages carefully, and asking "okay but why did that not work?" Building in public as a beginner means a lot of moments like this — where the fix is three lines and the understanding costs three hours. Worth it.
ESM-only better-auth caused a cascade of prod 500s on Vercel — fixed by pinning Node 22.x, then switching to a native dynamic import, then using a literal dynamic import to satisfy Vercel's bundler (three separate commits to crack one boot error).
1/ Three commits to fix one boot error. Every API route in my app was returning 500s in prod. The culprit: a single `require('better-auth')` that Node refused to run. 2/ better-auth is ESM-only. My Next.js build compiles to CJS. Node 20 (Vercel's default) will not `require()` an ESM module. So every route that touched auth — which was all of them — died at boot with ERR_REQUIRE_ESM. 3/ Fix #1: pin Node 22.x in package.json engines. Node 22.12+ can require() ESM modules without top-level await. Shipped it. Still 500s. Turns out `engines.node` tells Vercel what runtime to *target*, not what the deployed functions actually run on. [VERIFY exact Vercel behavior] 4/ Fix #2: hide the import from the transpiler using `new Function('return import(s)')`. esbuild can't see it, so it can't downgrade it to require(). It works! ...locally. In prod: ERR_MODULE_NOT_FOUND. Vercel's file tracer (@vercel/nft) also couldn't see it, so better-auth never made it into the function bundle. 5/ Fix #3: a literal `await import('better-auth')`. esbuild sees a dynamic import and leaves it alone — no require() rewrite. @vercel/nft sees the specifier and bundles the package. Both problems solved by the most obvious syntax I avoided for two commits. 6/ The whole codebase is written by Claude. I don't have a background in bundlers or module systems. But Claude walked me through each failure mode, and I now understand CJS/ESM interop more than I ever expected to. Three commits, one lesson. 7/
Migrated the entire backend from Supabase to a new stack (Neon + Better Auth + Cloudflare R2 + Vercel) in a single week — a full infrastructure swap on a live app, with zero prior experience doing it.
A week ago my app ran entirely on Supabase — database, auth, file storage, and 8 Edge Functions. Today it runs on Neon Postgres, Better Auth, Cloudflare R2, and Vercel. I did the whole migration in a single week, on a live app, having never done anything like this before. I should say upfront: I'm a beginner. I don't write the code myself — Claude does. My job is to make architectural decisions, read every diff, and understand enough to catch when something looks wrong. That context matters for what follows. The migration had a clear shape from the start. Consolidate 31 historical SQL migrations down to 4. Swap Supabase's `auth.uid()` row-level security policies for a session-variable approach — Neon doesn't have a native auth concept, so instead every database call goes through a `withSession()` wrapper that sets `app.current_user_id` as a Postgres session variable, and the RLS policies read that. Add a non-owner role with FORCE RLS so even a badly-written query can't accidentally skip tenant isolation. Then replace the auth layer, move file storage, and port the Edge Functions one by one. The storage migration to R2 was surprisingly clean. Four Supabase buckets collapsed into one R2 bucket with path-based namespacing (`source/userId/resourceId/file`). The nastier part was the Deno-to-Node translation inside the Edge Functions — `imagescript` became `sharp`, `EdgeRuntime.waitUntil` became `@vercel/functions waitUntil`, and every Supabase storage call became an AWS SDK call against R2. Each swap is small. Across 8 functions they accumulate. The part I was most nervous about was the main AI analysis route — a 2,200-line SSE streaming pipeline that orchestrates calls to Anthropic, writes to the database at multiple points, and handles file uploads mid-stream. Rewriting it was not realistic. Instead, Claude built a shim: a thin layer that presents the exact same supabase-js method-chain API the route already calls, but executes against Neon and R2 underneath. The pipeline code is byte-identical. Only the data layer changed. I don't fully know yet whether there are edge-case regressions in that — that verification is still ahead of me [VERIFY]. By the final commit, the count was: 14 tables, FORCE RLS on 10 of them, zero Supabase imports left anywhere in the app code, 2 TypeScript errors (both pre-existing, neither introduced by the migration). What I keep coming back to is that the migration was possible at all because of how Claude handles large, structured refactoring tasks. I'd give it a step, it would produce a diff, I'd read it, ask questions about anything I didn't understand, and we'd move on. The architectural choices — which role model to use for RLS, whether to write a shim or a rewrite, how to handle the orchestration chain between functions — those were mine to make. The implementation was Claude's. That split is the thing I'm still figuring out how to describe. It's not "I built this" in the traditional sense. It's also not "AI built this and I watched." It's something in between that I don't have a clean word for yet. The takeaway, for now: a full infrastructure swap on a live app is within reach even if you've never done one — if you're willing to read deeply, make real decisions, and not pretend you understand things you don't.
Migrated the entire backend from Supabase to a new stack (Neon + Better Auth + Cloudflare R2 + Vercel) in a single week — a full infrastructure swap on a live app, with zero prior experience doing it.
1/ I swapped out my entire backend infrastructure in a single week — Supabase → Neon + Better Auth + Cloudflare R2 + Vercel. Live app. No prior experience doing a migration like this. 100% of the code written by Claude. Here's how it actually went. 2/ The old stack was Supabase for everything: auth, database, file storage, and 8 Edge Functions running in Deno. One provider doing all the jobs sounds clean until you want to change something. Then it's not. 3/ The plan was 8 steps across 9 commits. In rough order: - Consolidate 31 SQL migrations into 4 - Swap auth.uid() RLS policies for a session-variable approach (FORCE RLS) - Replace supabase-js with postgres.js - Wire in Better Auth (passwordless OTP) - Move file storage to R2 - Port all 8 Deno Edge Functions to Vercel API routes - Rewire every client query - Delete the Supabase folder 4/ The hardest single piece: the 2,200-line SSE streaming pipeline for AI property analysis. It was too tangled to rewrite. So Claude built a narrow shim that mimics the exact supabase-js API surface — same method chains, same return shapes — while the actual data layer talks to Neon + R2 underneath. Byte-identical pipeline. Different plumbing. [VERIFY: no regressions in prod yet] 5/ One thing I didn't expect: Deno → Node isn't just a runtime swap. `imagescript` → `sharp`. `EdgeRuntime.waitUntil` → `@vercel/functions waitUntil`. Signed URL fetches → R2 `getObjectBytes`. Each one small, but they add up across 8 functions. 6/ The RLS migration was the part I understood least going in. Supabase uses `auth.uid()` in every policy. Neon has no concept of that. The replacement: set a session variable (`app.current_user_id`) at the start of every DB call via `withSession()`, then read it in the policies. A non-owner role with FORCE RLS means even if I write a bad query, it can't skip the tenant boundary. 7/ At the end of the week: 14 tables, FORCE RLS on 10 of them, 0 Supabase imports left in the app code, 2 pre-existing TypeScript errors (not migration-introduced). I didn't fully understand half of what I was doing. Claude did the implementation. I made the decisions and read every diff. That's the whole model. It's working so far./
Condensed the order form across multiple sessions: stocked thicknesses only, live validation that warns without blocking, and a full-width single-panel layout
There was a bug in my order form that I'd been half-ignoring because it felt minor: if you submitted with a validation error, fixed the offending field, and then tried to generate again — the button was still disabled. Nothing had re-run validation. The form had no idea you'd fixed anything. You had to click Submit a second time just to prove you'd corrected yourself. It's the kind of thing that's easy to ship past because it only shows up after an error. But it's also exactly the kind of friction that makes a tool feel unpolished. I'm building PanelPro, a structural insulated panel configurator for self-builders. The entire codebase is written by Claude — I'm not a developer, I describe what I want and read what comes back. This week was a run of UI sessions focused on making the order form less annoying. The stuck-button fix turned out to be an architectural decision, not a patch. Claude built a new /api/internal/validate-order endpoint that runs the exact same Zod schema checks and cross-validations as the actual plan generator — but stops before rendering anything. That makes it cheap to call continuously. The form now POSTs the current order to it on every field change, debounced at 400ms, and gates the Generate button on the live error count rather than the result of the last submit. Fix an error → button re-enables immediately. No manual reset required. The other piece was separating warnings from errors properly. Warnings — things like "your footprint is over 15m²" — show up as amber cards and never block generation. They're worth surfacing. They shouldn't be a wall. Errors show red and disable the buttons until resolved. It sounds obvious when you write it out, but getting there required Claude to restructure how the issues panel worked and retire a whole validation_error state kind that was doing too much. Two other changes landed in the same cluster of commits. First: the roof and wall thickness dropdowns now only offer stocked sizes — [50, 75, 100, 120mm], mirroring the actual materials inventory. Previously you could configure an order with an 80mm panel, which doesn't exist in stock. The form can no longer produce that. The validator still warns on unstocked sizes for CLI/API paths, but the UI just doesn't offer them. Second: I removed the raw JSON/YAML preview panel entirely. The form was previously split into a two-column layout with a live data preview on the right. Deleted it. Single full-width column now. Every form section gets the whole row. The thing I keep noticing is how much the endpoint doing double-duty matters. The same validation logic serves both the live form check and the actual generation run. There's no way for the form to warn you about something that generation silently accepts, or to fail to warn you about something generation will reject. That kind of consistency is easy to lose when you're patching incrementally. Claude designed it as a shared path from the start. I couldn't have reasoned my way to that architecture. I described the stuck-button symptom and Claude came back with the structure. That's the build-in-public angle I keep coming back to: I'm learning what good software feels like by reading what an AI builds when you describe a problem clearly.
Condensed the order form across multiple sessions: stocked thicknesses only, live validation that warns without blocking, and a full-width single-panel layout
1/ I had a bug where fixing a validation error kept the Submit button disabled anyway. The form didn't know you'd fixed it. You had to submit again just to unblock yourself. Here's how Claude helped me rethink the whole thing. 2/ The old flow: submit → validation runs → error found → button disabled. Fix the error → button STILL disabled. Because nothing re-ran validation. You had to click Submit again to prove you'd fixed it. Classic stuck-UI problem. 3/ The fix: a new /api/internal/validate-order endpoint that runs the exact same Zod checks as generation — but stops before rendering anything. Cheap to call. So the form now POSTs to it on every field change (debounced 400ms). Button state tracks the live error count, not the last submit attempt. 4/ The warnings-vs-errors distinction matters too. Errors = red, buttons disabled. Fix the field → buttons re-enable immediately. Warnings = amber cards at the top, buttons stay enabled. "Your footprint is >15m²" is worth showing. It shouldn't stop you generating. 5/ Two other things that went in the same session cluster: - Thickness dropdowns now only show stocked sizes [50, 75, 100, 120mm]. The form literally cannot produce an out-of-stock order anymore. - Removed the raw JSON/YAML preview panel entirely. Single full-width column. The form leads with the build, not the data. 6/ All of this is 100% AI-generated code — Claude Opus wrote every line. I described the stuck-button bug, Claude designed the debounced live-validation architecture. I'm learning what good UX feels like by reading what it builds. 7/ The thing that stuck with me: the endpoint doing double-duty. Same validation logic, two callers — the live form check and the actual generator. No drift between "what the form warns about" and "what generation actually rejects." Small architectural decision. Big UX payoff. /
Elevations were anchored to the roof instead of the floor — a subtle geometry bug that made every front/back elevation look correct until you checked the numbers
The front and back elevation drawings looked correct. Same wall shapes, same dimension labels, nothing obviously off. I'd been staring at them across multiple sessions and they seemed fine. Then I checked whether the floor lines were actually level with each other. They weren't. PanelProBuild generates construction drawings for modular panel buildings. A pent roof means the front and back walls are different heights — say 2500mm front, 1900mm back. When you draw both elevations on the same page, the floors should sit on one shared line and the height difference should appear at the top. That's the geometrically correct thing. It's also the thing a contractor reads when they're figuring out where a wall lands. What was actually happening: both elevations were being top-aligned. Anchored to the roof. So the floor on the shorter wall was floating 600mm above the floor on the taller one. Completely wrong, but nothing about the individual shapes gave it away. Each elevation, looked at alone, was fine. The fix sounds simple once you see it: compute a shared floor line from the maximum of the two wall heights, then pass that shared value to both drawings when you render them. Floors lock. Height difference floats to the top where it belongs. For left/right elevations this was already fine — they're equal-height trapezoids, so they were always aligned. Claude (Opus 4.8, 1M context — the whole codebase is AI-generated) added a `floor_y_pt` field to the `ElevationLayout` type and two helper functions to derive frame positions from it. My job was to write the fail-first test before touching anything: assert that both floors land on the same y-coordinate with a shared floor line, and assert that the old top-aligned approach produces floors that differ by exactly `(backH - frontH) * scale`. Red, then green. 311 tests passing. 0 lint errors. What I keep noticing about geometry bugs like this one: they hide behind correctness. The individual pieces are right. The overall thing is wrong. The only way to catch them is to ask a specific, measurable question — are these two floors actually at the same height? — rather than trusting that it looks okay. I'm a beginner building real software by pairing with AI and learning from what it produces. That combination keeps surfacing bugs I wouldn't have known to look for on my own.
Elevations were anchored to the roof instead of the floor — a subtle geometry bug that made every front/back elevation look correct until you checked the numbers
1/ The front and back elevations looked perfect. Same walls, same dimensions, clean layout. Then I noticed the floors weren't on the same line. 2/ The bug: both elevations were being top-aligned — anchored to the roof, not the floor. On a pent roof, front and back walls are different heights. Top-align them and the floors drift apart by exactly that difference. 3/ The fix was to compute a shared floor line from the taller of the two walls, then pass it to both elevations. Now the floors lock to one line and the height difference shows at the top — which is correct. That's where a pent roof difference *should* appear. 4/ What made this hard to spot: the drawings looked right. The shapes were correct. The dimensions read correctly. Nothing was visually broken until you put a ruler on the floor lines and noticed they didn't match. 5/ Claude wrote the fix — a new `floor_y_pt` field on `ElevationLayout` plus two helper functions, `elevationFrameTopY` and `elevationFloorY`. I wrote the fail-first test that proved the old layout was wrong before touching the implementation. 6/ 311/311 tests green. The fix took one session. The subtle wrongness had probably been there since elevations were first drawn. The whole codebase is AI-generated. I'm learning by reading what it builds and asking why. /
Shipped a shared detail-page layout system so labels always sit outside the drawing and leaders never cross each other — took three consecutive fix passes to get right
Three commits in one afternoon. Same feature. I'm going to walk through what actually happened because it's a good illustration of how this kind of work goes when you're building with AI and you don't really know what you're doing. The problem was label overlap on technical detail pages. PanelPro generates construction drawings — wall sections, eave details, that kind of thing — and each page has callout labels pointing at specific parts of the diagram. For months the approach was: place labels, check for overlaps, nudge them apart. It worked until the drawing changed, then the overlaps came back. I'd been patching the same symptom in circles. So this session I asked Claude to fix it structurally. **Pass one** was the architectural change. New module, `detail-layout.ts`, with a pure function that places every callout in a reserved margin column *outside* the diagram box. Left column or right column, never inside. The logic: if labels live in a separate zone from the drawing, they physically cannot hit a dim line or overlap the geometry. By construction, not by nudging. Wrote a fail-first test proving the old inline placement violated the invariant, got it green on the new code. 304/304 green. Felt like a real solution. Felt that way for about an hour. **Pass two**: the labels were outside the box, but they were stacking from the top of the column regardless of where their targets were. A label pointing at something near the bottom of the drawing had a leader that ran all the way down across a label pointing at something near the top. They crossed. The fix was to sort callouts by their target's Y position and centre each label on its target row — push it down only when it would overlap the one above. Leaders end up short and parallel. Never cross. 307/307 green. I thought that was it. **Pass three**, same evening: one specific leader on the external corner detail was running dead-horizontal along the rivet row it was meant to point *at*, threading through every other rivet on that row to reach the target one. Visually it looked like it was pointing at the whole row. The fix was an optional `approach: "above" | "below"` on the leader — the label holds off the row, then the leader makes an L-shaped dog-leg: a run clear of the row, then a short perpendicular drop onto the single target. 309/309 green. The pattern I noticed: each fix made the next problem visible. The structural fix exposed the crossing problem. Fixing crossings exposed the dog-leg problem. None of these were regressions — they were things that had always been wrong but were hidden behind the worse problem in front of them. I wrote zero geometry code across all three sessions. Claude Opus 4.8 wrote everything in `detail-layout.ts` and the leader logic. What I contributed was: deciding the fix needed to be structural rather than another nudge, writing the failing tests before asking for the fix, and catching each remaining issue in the render output. The takeaway, if there is one: "nudge until it fits" is not a testable invariant. "Labels are outside the drawing box" is. If you're generating any kind of diagram or document layout programmatically, find the invariants you can express as tests and build toward those. The nudging will always come back.
Shipped a shared detail-page layout system so labels always sit outside the drawing and leaders never cross each other — took three consecutive fix passes to get right
1/ I shipped a layout system to stop detail labels overlapping the drawing. Then immediately had to fix it twice more. Here's what three consecutive passes in one afternoon actually looks like. 2/ The problem: on technical detail pages, callout labels were being nudged around *inside* the diagram. Every time the drawing changed, overlaps came back. The fix had to be structural, not cosmetic. 3/ Pass 1 (7d04bff): moved labels entirely outside the drawing box — into reserved margin columns, left and right. By construction, a label can never hit the drawing, a dim line, or another label. 304 tests green. 4/ Felt good for about an hour. 5/ Pass 2 (5252a66): labels were outside the box but still messy. They were stacking from the top, so leaders crossed each other — long diagonals out to the page margin instead of short hops. Fixed: sort by target Y, centre each label on its target row. Leaders now run parallel. Never cross. 307 green. 6/ Pass 3 (3036831): one specific leader — "Rivets every 300–400mm" on an external corner — ran dead-horizontal *through* the rivet row it was trying to point at. Needed a dog-leg: run the leader clear of the row, then a short perpendicular hop onto the single target rivet. 309 green. 7/ The thing I keep noticing: each fix revealed the next problem. Not because the previous fix was wrong — because it was right enough to make the next issue visible. All three sessions co-authored with Claude Opus 4.8. I wrote zero of the geometry code. I did write every failing test first. 8/ If you're building anything with generated drawings or diagrams: "nudge things until they fit" will always regress. You need invariants you can test. Outside-the-box is an invariant. "Leaders never cross" is an invariant. "Label centred on its target row" is an invariant. Nudging is not. /
A front-wall door was rendering as a hole that escaped off the page entirely — tracked down to a stray drawSvgPath call placed outside the drawing boundary
Three sessions to find a door that was rendering 500 pixels below the bottom of the page. That's the whole story, really, but let me back up. I'm building PanelProBuild — a tool that generates construction documents for modular pod structures. One of the outputs is a 3D cover sheet: a perspective-ish view of the pod with doors and windows shown as recessed cut-outs in the walls, so you can actually read the openings as holes rather than just labels floating on a flat surface. The front-wall door on a plain bar pod wasn't showing up. At all. No outline, no fill, no recess depth. Just... the wall, uninterrupted. Session 11 (commit 9a9d3e2) built the whole recess-drawing system. Session 11.1 (a7e71bf) fixed a separate bug where corner doors on chamfer faces were being skipped entirely — the clip of the door quad against a near-edge-on face was returning zero points, which hit an early return and bailed before drawing anything. That was a real bug and worth fixing. But after both of those: still no front-wall door. Session 11.2, I stopped guessing and read the PDF content stream directly. Not the TypeScript, not the abstraction — the raw vector operators that pdf-lib was actually writing to disk, tracking the coordinate transform matrix op by op. Here's what I found: pdf-lib's `page.drawSvgPath` wraps every path in a `scale(1, -1)` transform. That's how it converts from y-up SVG coordinates to y-down PDF device space. Except it applies the scale with no page-height translation. So a path at y ≈ 505 (roughly mid-page in my coordinate system) ends up at device y ≈ -505. Off the bottom of the page by about 500 pixels. Every single drawing call for that door — the fill, the outline, the recess back rectangle — was going through `drawSvgPath`. All of them were invisible for the same reason. The reveal lines happened to use `drawLine`, which bypasses that transform entirely, and those *did* render. Which is why the door read as a very subtle "hole" in some cases: not because the geometry was there, but because four lines happened to land on the page. The fix was to route outlines through `drawLine` (which stays on-page correctly) and fills through a Y-negated path that cancels out pdf-lib's flip. I also added a test that walks the content stream, reconstructs the CTM at each draw call, and asserts no vector geometry lands below device y = -10. On unfixed code it fails at -620 — the bottom corner of the door. After the fix, 310/310 green. The entire codebase is written by Claude. My job in Session 11.2 was to describe what I found in the content stream — "here's the scale op, here's where the y-coordinate ends up, here's why I think the translate is missing" — and Claude worked out the negation trick and wrote the corrected helpers. I couldn't have written that code myself. But I could read a PDF spec and follow a coordinate transform, and that turned out to be enough. Two separate bugs, three sessions, one door. The render looks right now.
A front-wall door was rendering as a hole that escaped off the page entirely — tracked down to a stray drawSvgPath call placed outside the drawing boundary
1/ A door on my architectural PDF renderer was showing as a blank hole in the wall. Except it wasn't a hole. It was rendering at device y = -505. Off the bottom of the page entirely. Three sessions to track that down. 2/ The symptom: the plain bar pod's front-wall door just... wasn't there. No shape, no outline. But the *label* floated fine. So the geometry existed. Something downstream was broken. 3/ First I thought it was a clipping problem — the door quad wasn't surviving the projection onto the wall face. That was a real bug too, so Session 11 fixed it. Still no door. 4/ Session 11.1: chamfer corner doors were also disappearing. Different cause — the clip against a near-edge-on face was returning zero points, hitting an early return, skipping the draw entirely. Fixed that too. Still no front-wall door on the plain pod. 5/ Session 11.2: inspected the actual PDF content stream. Not guessing — reading the raw vector ops and tracking the coordinate transform matrix by hand. Found it: pdf-lib's page.drawSvgPath wraps every path in scale(1,-1) with NO page-height translate. 6/ A y-up path at y ≈ 505 comes out at device y ≈ -505. Every fill, every outline, every recess rectangle — all of them were drawing 500px below the bottom of the page. The door "hole" was only visible at all because the reveal lines used drawLine, which doesn't go through that transform. 7/ Fix: stroke outlines with drawLine (stays on-page), fill with a Y-negated path that cancels pdf-lib's flip. Added a fail-first test that walks the content stream, tracks the CTM, and asserts no geometry renders below device y = -10. Fails at -620 on unfixed code. Passes after. 8/ Three sessions. Two separate bugs. One was logic, one was a coordinate system assumption baked into a library I didn't write. The whole fix was written by Claude. I read the PDF spec, described what I found in the content stream, and Claude figured out the negation. That's the actual workflow now. /
Built a cutting-stock solver to minimise panel waste — moved from a naive 2500mm cap to real stock lengths with an actual optimisation algorithm
My quoting tool was telling customers they needed 14 panels for a garden bar pod. The real order — an actual invoice, order #8496 — was 10. I knew there was a pricing bug somewhere. I didn't realise it was a 40% overcount until I sat down and traced the logic properly. The root cause was a single number: 2500mm. That's the maximum structural height for the panel product I'm building around (a 40mm PIR insulated roof/wall panel, SKU PIR40ANPIRP31). It's a real spec limit — it's in the datasheet. But somewhere early in development, that cap got used as the assumed stock length for every panel. So every required piece, no matter how short, got billed as its own 2500mm unit. No offcut reuse. No packing. Just "you need N pieces, so you're buying N full lengths of 2500mm" — except 2500mm isn't even a purchasable length. The real stock sizes start at 3100mm. The fix took three commits across one session. First, replace the fake 2500mm cap with the real list of purchasable lengths: 3100, 3600, 4100, all the way up to 8100mm. Second, build an actual cutting-stock solver — First-Fit-Decreasing, which means you sort all the pieces you need from largest to smallest, then pack them into stock lengths one by one, reusing offcuts from previous cuts when they fit. Third, carry that lineage through to the PDF layout pages, so each panel shows where it came from: "roof-2 ← offcut of back-0". The solver packed the pod's 14 required pieces — six at 2400mm, four at 2000mm, four at 1077mm — into exactly 10 purchased stock lengths. Matching the real invoice exactly, first try. I want to be honest about what I did and didn't do here. I didn't write this code. Claude Opus 4.8 wrote it — every line of the solver, the schema changes, the 8 new tests. What I contributed was understanding the bug clearly enough to describe it, and knowing that "FFD bin packing" was the name of the right tool for the job (I looked that up). I reviewed the output, ran the tests — 285/285 green — and checked that the pod count matched the real order. That's the whole build-in-public angle for me. I'm not a developer. I can't write a bin-packing algorithm. But I could look at an invoice and a quote and ask "why are these different?" That question, asked precisely enough, turned into three commits and a tool that now quotes correctly. The thing I keep coming back to: the bug was there from the start because I didn't know enough to catch it. The fix was possible because eventually I knew enough to ask the right question. That gap — between not knowing and knowing what to ask — is the actual learning curve here, and it's a lot shorter than I expected.
Built a cutting-stock solver to minimise panel waste — moved from a naive 2500mm cap to real stock lengths with an actual optimisation algorithm
1/ My app was quoting 14 panels for a garden bar pod. The real order needed 10. That 40% overcount came from one bad assumption baked into the code from day one. 2/ The original logic billed every panel at 2500mm — a structural cap from the product datasheet, not an actual stock length. Real PIR panels come in lengths like 3100, 3600, 4100... up to 8100mm. Nobody sells a 2500mm panel. 3/ So every piece got charged as its own full stock unit. A 1077mm offcut? Billed as a whole new panel. No reuse. No packing. Just multiply and overcount. For the pod: 14 pieces × 2500mm = a quote that bore no resemblance to what you'd actually order. 4/ The fix was a First-Fit-Decreasing cutting-stock solver. Sort all required pieces largest-first. Pack them into real stock lengths. When a piece fits in an existing offcut from another wall face, use that instead of opening a new length. 5/ Result: the 14 required pieces (6×2400, 4×2000, 4×1077) pack into exactly 10 purchased stock lengths. 10 — the same count as Ben's actual order #8496. The solver matched a real invoice on the first run. 6/ The PDF layout now shows lineage too. A panel page might read "roof-2 ← offcut of back-0". You can trace exactly which physical stock length each cut came from. 7/ All 285 tests green. Claude Opus 4.8 wrote every line of this — I described the problem, reviewed the output, and ran the tests. I still don't fully understand FFD packing. But I understood the bug, and that turned out to be enough. 8/
Shipped the scheduled posts page and polished the generate UI — the product is now visually closer to something real people could use
The dashboard was getting cluttered. Scheduled drafts, recent repo activity, navigation — all living in the same place, competing for attention. It wasn't broken, but it felt cramped in a way that was starting to bother me every time I opened the app. So this week I asked Claude to split things up. Scheduled posts now live on their own /scheduled page with a 60-day window, there's a new nav item in the sidebar to get there, and the dashboard is a bit more focused. One commit, maybe 20 minutes of back-and-forth with Claude to get the layout right. The change that actually stuck with me, though, was smaller. Repos with no recent commits were showing a red indicator next to their name — the kind of thing that reads immediately as "something is wrong." But nothing was wrong. The repo just hadn't had any commits in a while. That's a totally normal state. Red means error. It's one of those UI conventions so ingrained that you don't have to think about it — which is exactly why misusing it is a problem. If users see red and nothing is actually broken, they learn to ignore it. Then when something *is* broken, they ignore that too. Changing it to muted gray text was a one-line fix, but it's the kind of thing I think matters. I want to be clear that I didn't write any of this code. The entire codebase is Claude's. What I'm doing is more like product editing — I open the app, something feels off, I try to articulate *why* it feels off, and then I describe that to Claude specifically enough that the fix comes out right. The red indicator thing is a good example: I didn't say "fix the repos with no commits," I said "this reads as an error state but it isn't one, it should feel neutral." That framing seemed to matter. I don't know yet if "noticing things and describing them well" is a sufficient skill to build something people will actually pay for [VERIFY]. But the app is starting to look like something a real person could use without me explaining what they're looking at. That feels like progress worth marking.
Shipped the scheduled posts page and polished the generate UI — the product is now visually closer to something real people could use
1/ The dashboard was getting crowded. Scheduled posts, recent commits, nav items — all piled together. So I asked Claude to split it up. One commit later, scheduled drafts have their own page. 2/ The change sounds small: move scheduled drafts from the dashboard to /scheduled, add a nav item, done. But it's the kind of thing that makes a product feel like a product instead of a prototype. 3/ There was also a small UX fix that bothered me more than it should have. Repos with no recent commits were showing a red error indicator — like something was broken. Nothing was broken. There just weren't any commits. 4/ Red = error is a pretty universal signal. Showing it when everything is fine trains users to ignore your actual errors. Changed it to muted gray text instead. Small thing. Obvious in hindsight. 5/ I still can't write the code myself. Every line in this repo came from Claude. But I'm getting better at noticing when something *feels* wrong in the UI — and being specific enough about it that the fix comes out right the first time. 6/ That might be the actual skill here: not coding, but having a clear enough opinion about the product that you can describe the problem precisely. Still figuring out if that's enough to build something people will pay for. [VERIFY] 7/
Added a /api/health endpoint just to diagnose whether the DB was actually connecting in prod — a debugging step that reveals how opaque production failures can be
This week I spent an embarrassing amount of time debugging a production failure before I realized I didn't actually know whether my database was connecting at all. Not "connecting slowly." Not "returning bad data." I mean: was it even there? I had no idea. The fix sounds almost too simple to post about. I asked Claude to add a `/api/health` endpoint — one route whose entire purpose is to poke the database and report back. Green means it connected. Red means it didn't. That's the whole thing. Two minutes of AI-generated code later, I curled it in production and got my answer. The connection was failing. Everything else I'd been looking at — the query logic, the response formatting, the frontend handling — was irrelevant. I'd been debugging the wrong layer for hours because I had no way to ask the most basic question. This is one of those things that experienced developers probably have as muscle memory. You instrument first, then you debug. But when you're new to this, you tend to jump straight at the symptom and start poking around. Production failures feel urgent, and "add a diagnostic endpoint" feels like a detour. It isn't. It's the first step. What I keep running into as someone building with AI-generated code is that Claude can give me working, reasonable solutions very quickly — but I still have to know what questions to ask. The health endpoint isn't impressive code. It's maybe 20 lines. The value wasn't in the implementation, it was in knowing that "I need a way to verify this specific thing is working" was even the right frame. Production is just quieter than local dev. Locally, failures are loud and fast — everything's on the same machine, errors surface immediately. In prod, things fail silently and you're left reading logs trying to infer what happened. Without any instrumentation, you're basically guessing. If you're early-stage and your production environment still feels like a black box: add the dumbest diagnostic you can think of. Not because it'll fix the problem. Because you can't fix what you can't see. [VERIFY: confirm /api/health is not publicly exposed without auth or is at minimum rate-limited — depending on what it returns about your stack it could leak info you don't want visible.]
Added a /api/health endpoint just to diagnose whether the DB was actually connecting in prod — a debugging step that reveals how opaque production failures can be
1/ My app was failing in production and I had no idea if the database was even connecting. So I did the dumbest-sounding smart thing: I added an endpoint whose only job is to say "yes I'm alive." 2/ The endpoint is called /api/health. It does one thing — tries to reach the DB and returns either a 200 or a 500. That's it. No logic. No auth. Just a pulse check. 3/ What surprised me: I'd been assuming the DB connection was fine and debugging everything else around it. Adding this revealed I was debugging the wrong layer entirely. The connection was the problem. 4/ 100% of this code was written by Claude. I just described what I wanted: "an endpoint that checks the DB connection and returns its status." Two minutes later I had something I could actually curl in prod and get a real answer from. 5/ The lesson isn't really about health endpoints. It's about how opaque production failures are when you have no instrumentation. Local dev hides this — everything's on the same machine, connections are instant, failures are loud. Prod is quieter and much harder to read. 6/ If you're early and your prod environment is a black box: add the dumbest diagnostic you can think of first. You'll be shocked how often the answer is already there, you just had no way to ask the question. /
Octokit couldn't be bundled into claude.ts — had to move GitHub sync up to the caller layer (button + cron) to fix a real deployment breakage
I shipped GitHub auto-sync this week — every time you hit the generate button, the app automatically pulls your latest commits. It took about 20 minutes to build. Then it took another hour and a half to un-break everything I'd just broken. Here's what happened. I wrote the sync logic using Octokit (GitHub's official JS library) and dropped it inside claude.ts, which is the file that handles all the AI generation calls. That felt like the right place — the sync happens during generation, so why not keep it together? The bundler disagreed. When I deployed, the build failed. Octokit, apparently, does not play nicely when it's bundled into certain server-side files in this setup. I don't fully understand the mechanics of why [VERIFY — something about how Octokit uses Node APIs that conflict with edge runtime bundling], but the error was real and the deployment was dead. I described the problem to Claude, and it came back with a fix I wouldn't have thought of on my own: move the GitHub sync call *up* to the caller layer. Instead of claude.ts reaching out to GitHub internally, the two things that trigger generation — the button handler and the cron job — each call the sync function themselves. claude.ts goes back to doing exactly one thing. The fix was maybe 15 lines of code moved around. But the concept was new to me. "Caller layer" isn't something I'd ever thought about consciously. The idea is that a core utility (claude.ts) shouldn't have side effects baked in — anything with external dependencies should live at the edges, where the actual triggering happens. I'm not sure I could have articulated that principle before this week. Now I can, because I broke something and had to understand the fix well enough to implement it, even if Claude wrote the actual code. That's the thing about building this way. The codebase is 100% AI-generated — I'm not writing the syntax. But the debugging loop still forces comprehension. You can't just paste an error and accept a fix blindly if you want the next version to work. At some point you have to understand what layer things belong on. The takeaway, if there is one: when a dependency breaks your bundle, the answer might not be a config fix. It might be a question of *where* that dependency lives in your architecture.
Octokit couldn't be bundled into claude.ts — had to move GitHub sync up to the caller layer (button + cron) to fix a real deployment breakage
1/ I added GitHub auto-sync to my app. It immediately broke deployment. Here's the dumb thing I didn't know about bundlers. 2/ The feature itself was simple: every time you hit "generate," sync your commits to GitHub automatically. Wrote it, shipped it, done. Or so I thought. 3/ The problem: I put the GitHub sync code (using a library called Octokit) inside claude.ts — the file that handles all the AI calls. Seemed logical. It all runs together, right? 4/ Wrong. The bundler (the tool that packages your code for deployment) couldn't handle Octokit sitting inside that file. Deployment failed. The whole thing broke. 5/ The fix was an architectural concept I didn't have a name for: move the sync logic "up to the caller layer." Instead of claude.ts doing the GitHub sync, the button and the cron job each call it directly. 6/ claude.ts goes back to only doing one thing. Octokit never touches the bundle. Deployment works. 7/ I didn't design this fix — I described the error to Claude and it explained the pattern. But now I actually understand *why* the code is structured this way, which feels like the real win. 7/
Hit Vercel Hobby's 60-second function limit in production — had to cap GitHub sync at 10s and then rearchitect to per-repo sync+generate to stay under the limit
Earlier this week my app stopped working in production and I couldn't figure out why for an embarrassingly long time. The symptom was nothing. I'd hit the "Generate Now" button and... nothing would happen. No error, no spinner that stopped weirdly, just silence. The kind of failure that makes you question whether you even deployed the right code. Eventually I found it: Vercel's Hobby plan caps serverless function execution at 60 seconds [VERIFY: confirm this is still accurate for current Hobby tier]. My GitHub sync was running longer than that. When a function exceeds the limit, it doesn't throw a visible error — it just gets killed. Which explains the silence. My first patch was embarrassingly blunt: cap the sync at 10 seconds of GitHub API calls and bail out. That got the function under the limit, but created a new problem. Ten seconds isn't enough to sync more than a repo or two, so "it works" was doing a lot of heavy lifting. It was completing successfully while barely doing anything. The real fix took a bit more thought. The issue was that I was trying to do everything in a single function call — sync all repos, then generate drafts for all of them. That's the kind of design that feels fine when you're building locally and there's no clock running. The new shape: one function call per repo. Each call syncs a single repo from GitHub, then generates drafts scoped to just that repo. The whole thing runs in under 50 seconds, with room to spare. The GenerateNow button fires these calls sequentially and shows per-repo progress as each one finishes. The cron job does the same thing. While I was in there I also found a bug I'd introduced earlier without noticing: notes that aren't linked to any repo (repo=null) were getting silently dropped from generation. They're included in every per-repo pass now. The whole codebase is 100% AI-generated — I described this constraint to Claude and it sketched out the per-repo architecture. I'm a beginner, and what I'm learning is that I'm often not good at anticipating infrastructure limits until I hit them in production. Claude is good at reasoning about the shape of a solution once I can describe the actual constraint clearly. Getting to that clear description is usually the hard part. The thing I keep taking from moments like this: free tier limits aren't just annoyances. They push you toward designs that are more granular, more observable, and more resumable. I wouldn't have built per-repo progress reporting if I hadn't been forced to break the work into per-repo chunks. The constraint made the product slightly better.
Hit Vercel Hobby's 60-second function limit in production — had to cap GitHub sync at 10s and then rearchitect to per-repo sync+generate to stay under the limit
1/ My app silently broke in production and I had no idea why. Vercel Hobby plan has a 60-second limit on serverless functions. My GitHub sync was blowing past it. No error message. Just... nothing happening. 2/ First fix: cap the sync at 10 seconds. A blunt instrument, but it got things limping along. The problem: 10s isn't enough time to sync more than a couple repos properly. I'd just traded "function times out" for "function succeeds but does almost nothing." 3/ The real fix was rearchitecting how the sync works. Instead of one function call that tries to sync everything, I split it into one call per repo: sync that repo, generate drafts for it, done. Each call stays under 50s. Well inside the 60s cap. 4/ The GenerateNow button now fires these calls sequentially and shows per-repo progress in real time. The cron job does the same thing. One repo at a time, all the way down. 5/ I also caught a bug while doing this: notes not linked to any repo (repo=null) were getting dropped from generation entirely. They're now included in every per-repo pass. The whole codebase is 100% AI-generated — Claude wrote this architecture too. I just described the constraint and it worked out the shape of the solution. 6/ The lesson I keep relearning: free tier limits aren't obstacles to route around. They're forcing functions. The per-repo design is actually cleaner than what I had before. If you're on Vercel Hobby: your functions have a 60s ceiling [VERIFY: confirm this is still current for Hobby tier]. Design for it from the start, not after you hit it./
Implemented a soil-water-balance engine with weather/ET0 data from Open-Meteo, a nightly forecast-bias correction job, and manual human-in-the-loop calibration — all from scratch, all AI-written.
Six commits landed yesterday that I've been circling for weeks, and I'm still a little surprised they work. The project is WyCo Irrigation System — a smart watering controller I'm building entirely with AI-generated code (Claude Opus 4.8 writes everything; I direct, review, and occasionally argue with it). Yesterday I shipped the core brain of the whole thing: a soil-water-balance engine. Here's what that actually means, because I had to learn it myself. FAO-56 is an agronomic standard for modeling how much water is in soil on any given day. You start with a "bucket" — the total available water for a zone — and each day you subtract evapotranspiration (ET0, how much water the plants and sun pull out) and add rainfall. When the bucket drops below a threshold, you water. When rain is coming, you skip. Simple in concept, fiddly in practice. The engine pulls real ET0 data from Open-Meteo's free API. A nightly job stores a forecast snapshot — one row per site per day — and the daily balance tick advances each zone's bucket using today's ET0/rain plus the 48-hour outlook. The dashboard now surfaces which zones need water, average soil moisture, and whether a rain-skip is coming. That much I could have described before building it. The part I didn't fully anticipate was how wrong the forecasts would be, and how you have to build for that from the start. So there are two correction layers. The first is automated: every night a bias-correction job diffs the stored forecast from the day before against Open-Meteo's measured actuals (they publish these as "past days"). It nudges a per-site ET0 bias and rain bias toward reality using a learning rate of 0.2, clamped so it can never go more than 50% in either direction. Over time, the model should stop making the same mistakes for a given location. The second is manual: I walk outside, look at the soil or the plants, enter a moisture reading or a simple 1-5 score. That single input resets the zone's depletion bucket to match what I actually observed, and shifts a per-zone calibration bias to reconcile the model with ground truth. The commit message calls it "human-in-the-loop" — which sounds fancy, but really it's just: sometimes you have to go touch the dirt. Sixty-one tests across the whole thing, all fixture-based, no live network calls. Claude wrote every line. I described the problem, reviewed the output, and pushed back when something felt wrong — which happened more than once on the bias math. The takeaway I keep coming back to: I don't fully understand FAO-56 hydrology. I'm not an agronomist. But I understood the *shape* of the problem — what inputs matter, what outputs I needed, where the failure modes were — and that was enough to direct the AI toward something real. Being a beginner doesn't mean you can't build something technically serious. It means you have to be honest about what you know and what you're relying on. The AI-photo plant-health method is deferred to Phase 5, by the way. One thing at a time.
Implemented a soil-water-balance engine with weather/ET0 data from Open-Meteo, a nightly forecast-bias correction job, and manual human-in-the-loop calibration — all from scratch, all AI-written.
1/ I just shipped a soil-water-balance engine for my irrigation system. It pulls real ET0 weather data, runs FAO-56 bucket math, self-corrects its forecasts nightly, and lets me manually nudge it when the model is wrong. I wrote zero of this code. 2/ The engine starts with Open-Meteo: a nightly fetch grabs ET0 (evapotranspiration) + rain forecast per site. That feeds a pure "balance tick" — FAO-56-style single-Kc bucket math that outputs soil moisture % and a fine/needs_water/skip recommendation. 3/ "Skip" triggers when 48h rain forecast hits ≥5mm or ≥70% probability. The system already knows not to water before rain. That logic lives in ~50 lines of pure TypeScript I can actually read and reason about. 4/ But forecasts lie. So there's a nightly bias-correction job: it diffs yesterday's stored forecast snapshot against Open-Meteo's measured actuals, then nudges a per-site ET0 bias and rain bias toward reality. Learning rate 0.2, clamped between 0.5 and 1.5. 5/ And forecasts *really* lie. So there's also manual calibration: I walk outside, look at the soil, enter a moisture reading or a 1-5 plant-look score. That resets the zone's depletion bucket and shifts a per-zone calibration bias (k=0.1) to reconcile model vs reality. 6/ Total test count went from 0 to 61 across these 6 commits. Every piece is fixture-tested with no network calls. Claude Opus 4.8 wrote all of it. I directed, reviewed, and occasionally said "that's wrong, try again." 7/ I'm a beginner. I don't fully understand FAO-56 hydrology. But I understood the *shape* of the problem well enough to describe it — and that turned out to be enough. The AI-generated codebase angle isn't a gimmick. It's the whole point. /
Completed a full V5 re-skin sweep across 8 distinct pages (Dashboard, Weather, Tanks, Zones, Calibration, Schedules, Monitor/Fleet/Alerts/NodeSetup) in under 3 hours of wall-clock time.
Eight pages. Seven commits. Somewhere under three hours of wall-clock time. I'm not a developer. The entire WyCo Irrigation System codebase is written by Claude — I write specs, review diffs, and push back when something looks wrong. This week Claude completed a full V5 re-skin sweep across the whole frontend: Dashboard, Weather, Tanks, Zones, Calibration, Schedules, and the Monitor/Fleet/Alerts/NodeSetup cluster. The thing I want to talk about isn't the speed. It's the constraint that made the whole thing actually worth doing. Going in, we agreed on one rule: if V5's design called for a UI element that had no real data source, drop it — don't fabricate it. That sounds obvious, but it's surprisingly easy to violate when you're doing a pure visual pass. The temptation is to stub something in, mark it as a placeholder, and ship it anyway. We didn't. The Zones page in the V5 mockups had a live "Running" badge and a water-bucket used/total display. No real-time event stream exists in the current architecture. Both got cut. Monitor had a hub-infrastructure tab. No real data source for it. Gone. Alerts had a fake read/unread state layered on top of the real open/acknowledged model. Stripped back to the real model. What's left is leaner than the designs, but it's honest. Where the designs called for something that *could* be derived from data already in the system, Claude built it properly. The Tanks page got a composed bar chart showing daily drawdown vs refill — computed client-side from the existing tankHistory endpoint points, no backend changes. Calibration got an ET Trend panel with a stability rating (stable/drifting/high-variance) pulled entirely from calibrationHistory that was already exposed. Schedules got real rain-skip counts and litres-saved attributed from actual zone skip events. Two pages needed new read endpoints: Dashboard got a water-balance series (7 past days + today + 6 forecast days projected from the latest snapshot), and Weather got a rain-skip events history derived from real zone_water_balance rows. Both additive, both read-only, both passed 135/135 smoke tests on the first build. That last part still catches me off guard every time. I'll be honest — I expected at least one test to break somewhere in a sweep this wide. It didn't. What I'm still sitting with is that the "no fabrication" constraint turned out to be a better quality signal than I expected. Every place we were tempted to invent a UI element was a place where the underlying data model had a gap. Naming those gaps explicitly — dropping the element rather than faking it — made the gaps visible in a way that vague "TODO" comments never would. The re-skin is complete. The app looks like V5. All the real data wiring is intact. The fabrication debt is zero. Now I have to figure out what "good enough to put in front of real irrigation users" actually means when you're a solo non-dev shipping on AI-generated code. That's the question I don't have a clean answer to yet.
Completed a full V5 re-skin sweep across 8 distinct pages (Dashboard, Weather, Tanks, Zones, Calibration, Schedules, Monitor/Fleet/Alerts/NodeSetup) in under 3 hours of wall-clock time.
1/ 7 commits. 8 pages. Under 3 hours of wall-clock time. That's how long it took Claude to re-skin an entire irrigation app to a new design system — while keeping every real data wire intact. Here's what that actually looked like. 2/ The rule we set going in: never fabricate UI elements that have no real data behind them. V5 designs had a live "Running" badge for zones. No real-time event source exists. So it got dropped. Same for a hub-infrastructure tab in Monitor. Same for a fake read/unread alerts state. 3/ Where V5 had something real to work with, Claude built it properly. Tanks page: SVG gauge with semantic fill colours, a composed bar chart showing daily drawdown vs refill — derived client-side from the existing tankHistory endpoint. No new backend required. 4/ Calibration page got an ET Trend panel — per-zone tabs, amber area chart, calibration reference lines, a stability banner (stable/drifting/high-variance). All of it fed from calibrationHistory that was already exposed. Zero new endpoints. 5/ Two pages *did* need new endpoints: Dashboard (water-balance series, 7 past + today + 6 forecast days) and Weather (rain-skip events from real zone balance rows). Both passed 135/135 smoke tests on the first build. That part still surprises me every time. 6/ The whole codebase is 100% AI-generated — by Claude. I'm not a developer. I write the specs, review the commits, push back when something gets fabricated instead of built honestly. That constraint — no fake data — turns out to be a pretty good forcing function for software quality. 7/ V5 re-skin: complete. Next up: figure out what "done enough to ship to real users" actually means for a solo non-dev building irrigation software with an AI. That's the harder problem.
Added an AI vision pipeline with entitlement metering and a full billing/credits/cap UI — going from zero to paywalled AI features inside a single day.
Yesterday morning I had no AI features in my irrigation app. By evening I had a working vision pipeline, a metering engine, Stripe-ready billing, a credit wallet, per-account spend caps, idempotent webhooks, and a billing UI — all tested, all green. I wrote none of the code. Claude Opus wrote all of it. I'm a beginner and that's the whole point of this project. Here's what the feature actually does: you photograph a plant, the AI assesses water stress from the image, and the result nudges the watering schedule automatically. Simple enough idea. The implementation is not simple. Before any AI call can happen, the metering layer runs a chain: does the user have a free taster allocation left this week? If not, do they have site quota from a paid tier? If not, do they have org credits in their wallet? If none of that, block with a 402. That ordering matters — you want to exhaust the cheapest bucket first. I wouldn't have designed it that way instinctively. Claude did. The quality gate was another detail I hadn't thought through. If the model looks at an image and either can't identify a plant or isn't confident enough in the result, it rejects the analysis — but it doesn't bill the user for it. That's the right behaviour. It's also the kind of thing that's easy to get wrong silently. The billing side is built local-first, which I asked for specifically because I didn't want to set up a real Stripe account just to test a paywall. The mock provider generates HMAC-signed webhooks that the app verifies the same way it would verify real Stripe webhooks. So the entire checkout → webhook → entitlement → gate flow runs in tests with no network calls. [VERIFY: I haven't tested against a real Stripe account yet, so there may be gaps when I do.] By the end of the day: 135 tests passing. The suite covers bad webhook signatures returning 400, replayed webhooks being no-ops that preserve quota counts, a paid tier unblocking a second cloud site, and a full end-to-end cap block returning 402. The thing I keep coming back to is what my job actually was here. I didn't write code. I described what I wanted — the metering order, the local-first constraint, the fact that rejected analyses shouldn't cost credits — and I reviewed what came back carefully enough to notice when something felt off. That was enough to produce something I'd be embarrassed to have shipped without. I don't know if that's a skill yet or just luck. But it's working.
Added an AI vision pipeline with entitlement metering and a full billing/credits/cap UI — going from zero to paywalled AI features inside a single day.
1/ I went from "no AI features" to a fully paywalled vision pipeline with billing, credits, and quota metering — in a single day. I wrote zero of the code myself. Claude did all of it. 2/ The project is an irrigation controller. The new feature: take a photo of a plant, get an AI assessment of its water stress, and have that automatically nudge the watering schedule. 3/ But AI calls cost money, so I needed a real paywall — not a fake one. That meant building: tiers (Starter / Grow / Pro), a per-account spend cap, a credit wallet, idempotent Stripe webhooks, and a billing UI. All in one day. 4/ The part I find genuinely impressive: it's all local-first. No Stripe account needed to run or test it. The mock provider signs its own webhooks with HMAC so the whole billing flow — including replay deduplication — is testable without touching the internet. 5/ The metering order is: taster allowance → site quota → org credits → account AI cap (402 if blocked). The quality gate rejects non-plant or low-confidence results *without* billing the user. That detail matters and I wouldn't have thought of it. 6/ 135 tests, all green. Covers: bad webhook signatures (400), idempotent replays preserving quota, a paid tier unblocking a second cloud site, and an end-to-end cap block returning 402. 7/ I'm a beginner. I couldn't have designed or written any of this. What I did was describe what I wanted clearly, review what came back, and push on the details that felt wrong. That turned out to be enough. 8/
Shipped a pure-JS EXIF stripper and R2/in-memory storage interface so image uploads work on Cloudflare Workers without any native binary dependencies.
Cloudflare Workers has a hard constraint that doesn't get mentioned enough: no native binaries. No compiled C extensions, no ImageMagick, no sharp. If you're building on Workers and you need to process images, you're working in pure JavaScript or you're not working at all. This week that constraint became real. The irrigation system app lets users upload photos — equipment, field conditions, that kind of thing. And phones embed GPS coordinates into every JPEG they produce. Latitude, longitude, sometimes altitude. That's not metadata I can quietly strip "eventually." GPS data is PII. It can't reach storage in the first place. So Claude needed to write a pure-JS EXIF stripper. I'm a beginner. I know what EXIF is in the abstract, but I couldn't have told you what the byte structure of a JPEG looks like, or what APPn segments are, or how PNG metadata chunks differ from JPEG ones. I described the problem — "phones put GPS in photos, Workers can't run native code, the file needs to be clean before storage" — and Claude Opus 4.8 built it. What came out: a parser that walks the raw JPEG bytes, finds APPn, EXIF, and COM segments, removes them, and leaves the SOI and EOI markers intact so the image still actually renders. PNG gets similar treatment with its metadata chunks. The whole thing is ArrayBuffers and byte offsets. No dependencies at all. The storage layer got the same philosophy. If the R2 environment variables are present, use Cloudflare R2 via the S3 SDK. If they're not — local dev, CI, wherever — fall back to an in-memory store with an identical put/get/delete interface. It's the same pattern I've been using for the database (PGlite as a local fallback for Postgres). One codebase that runs in production and on a laptop without any extra setup. Smoke tests cover both: the EXIF strip verifiably removes the GPS marker while keeping a valid image and actually shrinking the file size. The storage layer round-trips correctly on the in-memory store. 93 tests, 93 passing. The thing I keep noticing: the platform constraints are doing design work. I didn't sit down and think "I should write a dependency-free image processor" — the Workers environment made that the only option, and that forced a cleaner result than I would have reached by choosing freely. A native-dep solution would have worked, but it would have been harder to test locally, harder to reason about in CI, and it would have hidden complexity behind a library. The constraint surfaced the simpler path. That's what I'm finding with this whole project — describing *why* something needs to exist, including the real constraints, produces better code than describing *what* to build. The GPS-is-PII requirement isn't a footnote. It's load-bearing. Claude treats it that way when I surface it clearly.
Shipped a pure-JS EXIF stripper and R2/in-memory storage interface so image uploads work on Cloudflare Workers without any native binary dependencies.
1/ Cloudflare Workers won't run native binaries. So when I needed to strip GPS data from uploaded photos, I couldn't just npm install sharp and call it a day. Claude had to write a pure-JS EXIF stripper from scratch. 2/ Why strip EXIF? Because phones embed GPS coordinates into every photo. Latitude, longitude, sometimes altitude. That's PII sitting in a file, and it must never reach storage. Not "we'll clean it up later" — never. 3/ The solution: parse the raw JPEG byte structure and surgically remove APPn, EXIF, and COM segments before the file touches anything. For PNGs, strip the metadata chunks. Keep the SOI and EOI markers intact so the image still renders. No native deps. No ImageMagick. Just ArrayBuffers and offsets. 4/ The storage layer got the same "works everywhere" treatment. If R2 credentials are set, use Cloudflare R2 via the S3 SDK. If not, fall back to an in-memory store — same interface, same put/get/delete API. It's the same pattern as the PGlite fallback for the database. One codebase, runs in prod and locally. 5/ Smoke tests: EXIF strip removes the GPS marker while shrinking the file. Storage round-trips cleanly on the in-memory store. 93/93 tests passing. The whole thing was written by Claude Opus 4.8. I don't write the code — I describe what needs to exist and why the constraints matter. 6/ The constraint (no native deps on Workers) forced a cleaner design than I would have reached otherwise. That's been a recurring pattern with this project. Constraints from the platform → Claude finds the path I wouldn't have thought to look for.
Built an entire multi-tenant irrigation SaaS from schema to deployed app in roughly 24 hours across 5 phases — all AI-generated code, zero prior experience shipping cloud software.
I shipped a multi-tenant irrigation SaaS in roughly 24 hours across 5 phases. I have no prior experience shipping cloud software. Every line of code was written by Claude. I want to be precise about what that means, because "AI-generated code" gets used to describe a pretty wide spectrum of things. This isn't Copilot autocomplete. I wrote BUILD-PLAN.md — a detailed spec covering the domain model, the phase structure, the security constraints, the hardware protocol. Then I handed it to Claude Opus and asked it to build one phase at a time, reviewing the output, flagging things that seemed wrong, and occasionally pushing back when something didn't match what I knew about how irrigation hardware actually behaves. The thing I didn't expect was how much the *order* of decisions mattered. Phase 0 was just scaffolding — pnpm workspaces, Express 5, React 19, a health endpoint. Nothing impressive. But Phase 1 was the database schema: 17 tables, every row org-scoped, with a query layer that bakes the org predicate into `requireSite` and `listSites` rather than checking it per-route. I asked for that because I'd read that multi-tenancy bugs are catastrophic, not because I fully understood what I was asking for. Claude built it that way, and by Phase 3 when I was adding hubless cloud-only sites, I kept noticing how naturally everything composed. The foundation held. By Phase 4 we had the full hardware command loop: a user queues a run in the web UI, the hub polls a `/commands/pending` endpoint, acks the command, posts a `zone_run` event, and the UI closes the loop. HMAC-signed payloads, range-checked, 88 tests passing with a live round-trip verified in the seed simulator. That's not a toy. That's the actual protocol a physical device in a field would use. I keep trying to figure out the honest way to describe my role in this. I didn't write the code. I didn't architect the schema in the traditional sense. What I did was hold the domain knowledge — the irrigation side, the hardware constraints, the product decisions — and translate that into instructions precise enough for Claude to execute correctly. When the output was wrong, I caught it because I knew what "wrong" looked like for this domain, not because I could read the TypeScript and spot the bug. Is that engineering? I genuinely don't know. It's something. It produced a working, testable, deployable multi-tenant SaaS in a day, built by someone who couldn't have built it the traditional way on any reasonable timeline. The thing I'd tell another beginner: your leverage isn't the AI. It's the spec. The more precisely you can describe what you want — including the constraints, the failure modes, the things that must be true at every layer — the better the output. BUILD-PLAN.md was probably 4 hours of work before a single commit landed. That's where the real decisions got made. Phase 5 is metering and billing. The entitlement tables are already there, stubbed out, waiting. That was another thing Claude did on its own: it built the scaffolding for a feature I hadn't asked for yet, because the plan implied it was coming. I'm still not sure how I feel about that.
Built an entire multi-tenant irrigation SaaS from schema to deployed app in roughly 24 hours across 5 phases — all AI-generated code, zero prior experience shipping cloud software.
1/ 8 commits. 5 hours. A multi-tenant irrigation SaaS — schema, auth, cloud dashboard, hardware command loop, deployed. I've never shipped cloud software before. Every line of code was written by Claude. Here's what that actually looked like: 2/ Commit 1: greenfield monorepo scaffold. pnpm workspaces, Express 5, React 19, Vite, Tailwind. Health endpoint responds. That's it. That's the whole app. 5:23pm. Clock starts. 3/ Commit 3: 17-table multi-tenant Drizzle schema. Orgs, users, sites, hubs, zones, tanks, telemetry, events, commands, schedules, credit ledger. Every row org-scoped from the start. The query layer physically can't return cross-org data — the predicate is baked in, not bolted on per route. 4/ That last part matters more than I understood when I asked for it. Multi-tenancy isn't a feature you add. It's a constraint you either build into the foundation or spend months patching in later. Claude put it in layer 2 of 5. 5/ By commit 5 (Phase 1): session auth. Signup creates an Org + owner User + Site + entitlement in one transaction. scrypt hashing, no native deps. Verified live: 201, session cookie, logout, 401 on bad password, 409 on duplicate email. I did not write a single line of that. 6/ Phase 3: cloud-only "hubless" sites. A customer can onboard, add a site, enter tank readings manually, and see a dashboard — no hardware required. Phase 4: the hardware command loop. User queues a run in the UI → hub polls → hub acks → zone fires → event closes the loop. HMAC-signed payloads. 88/88 tests passing. 7/ Total time across all 5 phases: roughly 24 hours [VERIFY — spanning multiple sessions]. What I brought: a BUILD-PLAN.md, an understanding of the irrigation domain, and the judgment to know when something Claude generated was wrong. What I didn't bring: any prior cloud shipping experience. 8/ I don't know yet if this is the right way to build software. I know it's the only way I could have built *this* software, at *this* speed, at *this* level of quality. That's the experiment. Still running.
New user sign-ups silently had no profile row — fixed with a DB trigger to auto-create profiles, a classic auth gotcha that only surfaces after a real migration.
Every new user who signed up for my app had a broken account. No error message. No failed request they could see. Just silently, invisibly broken — because there was no profile row attached to their user record. I found this out after deploying a real migration to production. Not ideal. Here's the chain of events, because I think this is a classic gotcha that's worth spelling out clearly. When I was using Supabase Auth, there's a default trigger that fires after a new user is created and auto-inserts a row into a profiles table. It's easy to take for granted because it just works and you never have to think about it. I switched to Better Auth. Better Auth has an `afterCreate` hook, which looked like the right place to handle profile creation. I wired it up and moved on. The problem is that the hook runs on a separate database connection — and inside Better Auth's own create transaction, the new `user` row hasn't been committed yet. So when the hook tried to insert a profiles row with a foreign key pointing at that user, the FK check failed. Better Auth caught the error and swallowed it. No log. No crash. Just silence. So every new signup got a user record and nothing else. And because my properties table has a FK pointing to profiles(id), every property capture and import attempt also FK-failed. New accounts were completely broken in a way that would have been invisible until someone tried to actually use the app. The fix was to replace the hook with a proper AFTER INSERT trigger on the `user` table — one that runs in-transaction, so the user row is already visible when the profile insert happens. I also needed SECURITY DEFINER on the trigger function so it could pass my RLS policy checks. Claude wrote the trigger and the migration; I wrote the question, which took most of the debugging time. Migration 0007 also includes a backfill for any existing users who were caught in the gap. Applied to Neon prod, verified with a scripted round-trip: RLS-scoped INSERT, SELECT, and DELETE all green. The takeaway I keep thinking about: when you migrate away from a managed auth system, you're not just replacing the auth logic — you're also taking on responsibility for whatever background work that system was doing quietly on your behalf. Supabase's default triggers are well-documented, but they're easy to forget when you're focused on getting the new integration working. Worth doing an explicit audit of what you're dropping before you drop it.
New user sign-ups silently had no profile row — fixed with a DB trigger to auto-create profiles, a classic auth gotcha that only surfaces after a real migration.
1/ Every new user in my app had a silently broken account. No error. No warning. Just... no profile row in the DB. I only found out after running a real migration to prod. 2/ Here's what was happening: Supabase has a built-in trigger that auto-creates a profile row when a user signs up. I dropped it when I switched to Better Auth. I didn't realize that trigger was doing something important. 3/ Better Auth has an `afterCreate` hook, so I wired up profile creation there. Seemed fine. Wasn't. The hook ran on a separate DB connection. Inside Better Auth's own create transaction, the new `user` row isn't visible yet — so the FK insert failed. 4/ Better Auth swallowed the error silently. No log. No crash. Just a missing profiles row for every single new signup. And because properties.user_id points to profiles(id), every capture and import FK-failed too. New accounts were completely broken. 5/ The fix: a proper AFTER INSERT trigger on the `user` table, running in-transaction, with SECURITY DEFINER so it can pass RLS checks. Migration 0007. Backfill for existing users. Removed the redundant hook. 6/ Claude wrote the trigger. I wrote the question: "why does my afterCreate hook keep failing silently?" That conversation — not the code — was the actual debugging work. 7/ Verified with a scripted create/read/delete round-trip on Neon prod. RLS-scoped INSERT, SELECT, DELETE all green. If you're migrating away from Supabase Auth: check what work their default triggers were doing. They're easy to miss and expensive to rediscover. /
JSONB double-encoding bug silently corrupted every property analysis since the Neon migration — the same root cause hit in two different layers (postgres.js direct and a supabase-shim) and required two separate fixes days apart.
Two days apart, the same bug hit me twice. Same root cause, different layer, and the second hit took down every page in production. Here's what happened. I migrated from Supabase to Neon a few weeks ago. To avoid rewriting every database call at once, I kept a compatibility shim — a thin file that translates Supabase-style calls into postgres.js queries. It seemed like the boring, sensible thing to do. Migrate the infrastructure, keep the interface stable, swap out the internals gradually. The shim was writing JSONB values like this: JSON.stringify(value) bound to a $N::jsonb parameter. That looks reasonable if you're not deep in the postgres.js docs. The problem is that postgres.js, when running with prepare:false, describes the statement, resolves the parameter type to jsonb, and then applies its own JSON serializer on top of what you already serialized. The value gets stringified twice. What ends up in the database isn't a JSON object — it's a jsonb scalar string. A blob of text that looks, from the outside, like it stored fine. Nothing crashed on write. The rows committed cleanly. The corruption was completely silent. I found the bug pattern on June 10th — commit 4b5096c — and fixed it across 8 call sites in the direct query files. Added a jsonb() helper to lib/db.ts that wraps the sql.json typed-parameter correctly, so postgres.js knows not to serialize again. Felt like a solid fix. Two days later, June 12th, the app started crashing. AppErrorBoundary was taking down every page. The culprit: buildDashboardRow trying to access structured.reconfiguration_ideas[0] on what it expected to be an object — but the stored value was a char-indexed string, because the supabase-shim was still doing the old double-encode pattern. I'd fixed 8 sites and completely missed the shim. The repair took three pieces. First, update the shim to use the jsonb() helper, no manual stringify, no ::jsonb cast. Second, write a migration (0008) to repair the already-corrupted rows sitting in production — idempotent, with a verification query, run manually in the Neon SQL editor. Third, add client-side guards so that if a malformed row somehow makes it through, it degrades to a placeholder instead of crashing the whole dashboard. Claude wrote all of this. I'm a beginner, and I understood maybe 60% of what was happening in real time. I understood more by the time we were done. The thing I keep thinking about: when you find a bug pattern, you have to search every abstraction layer, not just the obvious call sites. I found the footgun in the direct queries and assumed I'd found it everywhere. The compatibility shim — the thing I built specifically to avoid touching things — was hiding the same pattern one layer down. A "safe" shim isn't safe if it's carrying the same bad assumptions you just fixed everywhere else. That's the thing I'll remember from this one.
JSONB double-encoding bug silently corrupted every property analysis since the Neon migration — the same root cause hit in two different layers (postgres.js direct and a supabase-shim) and required two separate fixes days apart.
1/ My app silently corrupted every property analysis for days after a database migration. No crash, no error in the logs. Just wrong data, quietly stored, waiting to blow up later. Here's the bug that hit me twice — same root cause, two different layers. 2/ When I moved to Neon, I kept a supabase-shim so I didn't have to rewrite every DB call. Seemed safe. It wasn't. The shim was doing: JSON.stringify(value) bound to $N::jsonb That looks fine. It is not fine. 3/ postgres.js, when you use prepare:false, describes the statement, figures out the param type is jsonb, then applies its OWN jsonb serializer on top. So the value gets JSON.stringify'd twice. What lands in the database is a jsonb scalar STRING, not an object. 4/ I didn't notice for days because nothing crashed on write. The corruption was invisible until something tried to READ the data — specifically, buildDashboardRow trying to access structured.reconfiguration_ideas[0]. Then AppErrorBoundary took down every page at once. 5/ The fix sounds simple in retrospect: stop doing manual stringify, use the jsonb() typed-parameter helper so postgres.js knows not to serialize again. But here's the part that stings: I'd already fixed this exact bug two days earlier (commit 4b5096c) across 8 other call sites. 6/ I missed the supabase-shim entirely. Same footgun, different file, fired again on June 12th. Fix 1 (June 10): replaced all the ${JSON.stringify(x)}::jsonb patterns in direct queries. Fix 2 (June 12): found the same pattern hiding inside the shim layer. 7/ The repair also needed a migration (0008) to fix already-corrupted rows in production, plus client-side guards so a malformed row degrades to a placeholder instead of killing the whole app. Claude wrote all of it. I understood maybe 60% of it in the moment. I understand more now. 8/ The takeaway I'm sitting with: a "safe" compatibility shim can paper over the exact footgun you thought you already fixed. When you find a bug pattern, search every abstraction layer — not just the obvious call sites. 8/
ESM-only better-auth broke prod with 500 errors on Vercel — fixed through three successive commits trying dynamic imports, then a literal dynamic import, then pinning Node 22.x runtime.
My entire app went down in prod last Tuesday. Every API route returning 500s, no partial failures, just total darkness. I'm a non-technical founder building with Claude as my sole developer, and staring at a wall of ERR_REQUIRE_ESM errors is its own special kind of helpless. Here's what happened, and more importantly, the thing I didn't understand until the third fix attempt. The auth library I'm using — better-auth — ships ESM-only. No CommonJS build. Vercel compiles my TypeScript to CJS at deploy time. So when my server booted, Node tried to `require('better-auth')`, better-auth said no, and every single route that touched auth (which was all of them, because they all resolve a session) died immediately. **Fix attempt one: pin the Node runtime.** Node 22.12+ has a feature where it can require() ESM modules, as long as those modules don't use top-level await. So Claude added `"engines": { "node": "22.x" }` to package.json, the idea being Vercel would run functions on a newer Node that handles this gracefully. Deployed. Still 500s. Turns out `engines.node` is more of a suggestion than an instruction [VERIFY — this may depend on your Vercel plan or framework config]. The function runtime didn't change. Back to the same error. **Fix attempt two: outsmart the transpiler.** The real problem is esbuild rewriting `import('better-auth')` to `require('better-auth')` during compilation. So Claude wrapped the import in `new Function('return import(s)')` — a string evaluated at runtime that the bundler literally cannot see or rewrite. Node loads it as a proper dynamic import. ESM works fine. Deployed. Routes came back. I thought we were done. Then I noticed the error had changed. Instead of ERR_REQUIRE_ESM, I was now getting ERR_MODULE_NOT_FOUND. **The thing I didn't know:** Vercel uses a tool called @vercel/nft to trace which files each serverless function actually needs, so it can bundle only those files. It does this by statically analyzing your imports. A dynamic import hidden inside `new Function(...)` is completely invisible to that analysis. So better-auth was never included in the deployment bundle at all. I had solved the require() problem and created a missing-file problem. Two different errors, identical symptoms. **Fix attempt three — the one that actually worked:** A plain, literal `await import('better-auth')`. That's it. @vercel/nft can see a literal dynamic import, so it bundles the package. esbuild preserves native dynamic imports as dynamic imports — it only rewrites static `import` declarations to `require()`. So the specifier never gets mangled. Node loads ESM. Everything works. Three commits across one afternoon. The fix is a single line. The understanding of *why* that line works took destroying prod twice to acquire. The broader thing this taught me: when you're getting 500s from a bundled serverless function, there are two completely separate failure modes that look the same from the outside. "The runtime can't load this module format" and "the bundler didn't include this module at all." You have to think about both layers simultaneously, with the same import statement. I couldn't have reasoned through any of this without Claude writing and re-writing the fix and explaining what each piece was doing. The whole codebase is AI-generated — that's not a caveat, it's the point of what I'm building. Days like this are part of what I'm trying to document honestly.
ESM-only better-auth broke prod with 500 errors on Vercel — fixed through three successive commits trying dynamic imports, then a literal dynamic import, then pinning Node 22.x runtime.
1/ My entire app went down in prod. Every single API route returning 500s. The culprit: one npm package that ships ESM-only, and a bundler that quietly rewrote my import to require(). 2/ The package is better-auth. It doesn't ship a CommonJS build. Vercel compiles my TypeScript to CJS. So at boot, Node hit `require('better-auth')` on an ESM-only module and just... refused. ERR_REQUIRE_ESM. Every route dead. 3/ Fix attempt #1: pin Node 22.x in engines. Node 22.12+ can require() ESM modules that don't use top-level await. Seemed logical. Deployed. Still 500s. Turns out `engines.node` in package.json doesn't actually change the Vercel function runtime. It's essentially a hint. [VERIFY for your own Vercel setup] 4/ Fix attempt #2: hide the import from the transpiler using `new Function('return import(s)')`. The bundler can't see it, can't rewrite it to require(). Node loads ESM fine at runtime. Deployed. Routes came back up. Celebrated briefly. 5/ Then I noticed: the package was missing from the function bundle entirely. Vercel uses @vercel/nft to trace which files each function needs. A dynamic import hidden inside `new Function(...)` is invisible to the tracer. So better-auth wasn't bundled. ERR_MODULE_NOT_FOUND. I had traded one error for a different error. 6/ Fix attempt #3 — the one that actually worked: a plain literal `await import('better-auth')`. @vercel/nft can see it → package gets bundled. esbuild preserves native dynamic imports as-is → no require() rewrite. Node loads ESM. Done. Three commits. One afternoon. All AI-generated code, all my confusion. 7/ The thing I didn't understand going in: there are two separate problems that look identical from the outside. "Can the runtime load this module?" and "Did the bundler include this module?" both produce 500s. You have to solve both at once, with the same import statement. 8/ If you're hitting ERR_REQUIRE_ESM on Vercel with an ESM-only package: skip the Node version pin, skip the new Function trick. Just use a literal `await import('your-package')` and make sure your bundler treats dynamic imports as dynamic. Learned this the hard way so you don't have to. 100% of this code was written by Claude.
Migrated the entire backend from Supabase to Neon + Better Auth + Cloudflare R2 + Vercel in a single week — a full infrastructure swap on a live app, 100% AI-written code.
Last week I migrated my entire backend. Not a feature, not a refactor — the whole foundation. Supabase out, Neon Postgres + Better Auth + Cloudflare R2 + Vercel in. On a live app. In about five days. And I didn't write a single line of the code myself — it was all Claude. I want to be honest about what that experience was actually like, because "AI wrote my whole migration" sounds either miraculous or reckless depending on where you're standing. It was nine commits. The first one consolidated 31 database migrations into 4 and rewired every row-level security policy from Supabase's `auth.uid()` to `current_setting('app.current_user_id', true)::uuid` — because we were leaving Supabase Auth behind and needed a different way to pass the user identity into Postgres. I understood maybe 60% of that commit when I read it. I understood more by commit nine. The storage migration was cleaner conceptually: one Cloudflare R2 bucket with a path scheme that mimics the four separate Supabase Storage buckets I had before. `<source>/<userId>/<resourceId>/<filename>`. The tenancy guard — making sure users can only access their own files — lives in a `verifyUserPath()` function on every read and delete. The part that genuinely surprised me was how Claude handled the 2,200-line SSE streaming pipeline. That's the route that runs property analysis — it calls Anthropic's API, streams results back to the client, writes to the database as it goes. Rewriting it from scratch would have been high-risk. Instead, Claude built a narrow shim that mimicked the exact supabase-js interface the route was already calling, so the streaming logic could stay completely unchanged while the data layer switched underneath it. I read that commit three times. It's a real software engineering decision, not just a translation. There was also a moment where Claude did the right thing by *not* shipping something. The PDF report generator had drifted — it was feeding the wrong value into a stress-test calculation, using current value where it should have used post-refurb value. Rather than silently produce wrong numbers, the route got a 501 stub and a note in the commit message explaining exactly what needed reconciling first. That's the kind of judgment call I would have missed entirely if I'd been moving fast. By the end, `lib/supabase.ts` was deleted with zero importers remaining. The TypeScript error count was two — both pre-existing, none introduced by the migration. The CLAUDE.md architecture rule covering the old hybrid runtime approach was marked RETIRED. The takeaway for me isn't "AI is magic." It's that this kind of migration — the kind that used to mean either months of careful work or a significant contractor bill — is now something a non-developer can actually navigate, as long as you're willing to read slowly, ask questions when something doesn't make sense, and trust the process enough to let a shim be a shim.
Migrated the entire backend from Supabase to Neon + Better Auth + Cloudflare R2 + Vercel in a single week — a full infrastructure swap on a live app, 100% AI-written code.
1/ I swapped out my entire backend in a single week. Supabase → Neon + Better Auth + Cloudflare R2 + Vercel. On a live app. Every line of code written by Claude. Here's what that actually looked like. 2/ It started with 31 database migrations consolidated into 4. The big rewrite: every `auth.uid()` call had to become `current_setting('app.current_user_id', true)::uuid` — because we're off Supabase Auth entirely. 3/ Then file storage. Supabase Storage → a single Cloudflare R2 bucket with path-based "virtual buckets": `<source>/<userId>/<resourceId>/<uuid>-<file>`. One of the trickier design calls — AWS SDK is Node-only, so it can't run at the edge. 4/ The hardest part: 8 Supabase Edge Functions (Deno) → 8 Vercel API routes (Node). One of them was a 2,200-line SSE streaming pipeline hooked into Anthropic's API. Claude wrote a narrow "shim" that mimicked the exact supabase-js surface so the streaming logic could stay byte-identical. 5/ The shim approach is the thing I keep thinking about. Instead of rewriting the complex part, Claude wrapped it. I wouldn't have thought of that. I'm not sure I even fully understood it until I read the commit message. 6/ One function got a 501 stub and a deferred notice. The PDF report builder had semantically diverged from the main codebase — wrong input values feeding a stress-test calc. Claude flagged it rather than silently shipping broken math. That's the right call. 7/ Final state: `lib/supabase.ts` deleted. Zero importers. Two pre-existing TypeScript errors, zero migration-introduced ones. The CLAUDE.md rule for "hybrid runtime" marked RETIRED. I'm a non-developer building this entirely with AI. A full infra swap in a week used to mean hiring someone. Now it means a lot of careful prompting and reading commit messages very slowly. 8/
Redesigned the order form UX in a single evening session: stocked thicknesses only, live validation that warns but never blocks, and collapsing opening types down to just door/window.
Last Thursday evening I sat down with the order form for PanelProBuild — a configurator for SIP panel buildings — and made a list of things that were quietly broken. Not crashed-broken. Subtly wrong in ways that would erode trust if a real user hit them. Three things stood out. First: the thickness dropdowns offered seven options. Our actual stocked sizes are four. So you could configure an order, hit Generate, and get an error about a thickness we don't carry — after the fact, with no warning. That's a bad place to put the friction. The fix was just mirroring the dropdowns to `available_thicknesses_mm` from materials.yaml. Four options. You can't select something we don't stock. Second: opening types. The form had a five-way enum — single-hinged, double, sliding, picture, casement. Looked thorough. But in a previous fidelity pass, door swing arcs had been removed from the renderer, which meant all the sub-types rendered identically. It was fake specificity. Collapsed it to door | window. Sixteen fixture files needed migrating; Claude wrote a Node script to do it. 279/279 tests green. Third, the one that annoyed me most: the stuck-disabled bug. Enter bad dimensions, get a validation error, fix the dimensions — and the Generate button stays greyed out. You have to hit submit again just to clear the error state. Nobody would figure that out intuitively. The root problem was that validation only ran on submit. The fix was a new `/api/internal/validate-order` endpoint that runs the exact same Zod schema checks and cross-checks as generation, but bails before any rendering happens. Cheap to call. The form now posts to it on every change, debounced 400ms, and gates the buttons on the live error count. Fix the last error, buttons re-enable immediately. There's also a warnings-vs-errors split that I think is genuinely the right model for this kind of tool. Some things are worth flagging — a building footprint just over the permitted development threshold, a roof pitch shallower than ideal — but they shouldn't stop you generating a plan. Those render as amber cards. Actual errors (door wider than wall, sill height impossible) render red and do block. The distinction is: "you should know this" vs "this cannot work." All of this code was written by Claude Opus 4.8. Every commit has the co-author tag. I'm a non-developer building a real construction tool by learning to describe problems precisely enough that the AI gets them right. The evening's work was mostly the list — identifying what was wrong and why — not the implementation. The takeaway I keep coming back to: a lot of bad UX isn't missing features, it's fake choices and friction in the wrong place. Removing options (thickness sizes, opening sub-types) made the form more useful, not less.
Redesigned the order form UX in a single evening session: stocked thicknesses only, live validation that warns but never blocks, and collapsing opening types down to just door/window.
1/ Rebuilt the order form UX in one evening session. Three things that were quietly making it worse — and how Claude fixed all three in ~90 minutes of commits. 2/ Problem 1: the form let you pick panel thicknesses that don't exist in stock. 80mm, 140mm, 150mm — all selectable, all guaranteed to fail downstream. The fix was just: only show what's actually available. Four options instead of seven. 3/ Problem 2: a five-way opening type dropdown (single-hinged / double / sliding / picture / casement). Sounds thorough. But the sub-types rendered identically — door swing arcs were already removed. It was fake choice. Collapsed it to door | window. 4/ Problem 3: the stuck-disabled bug. Fill in bad values, get an error, fix the values — Generate button stays greyed out until you manually submit again. Infuriating. 5/ The fix: a new /api/internal/validate-order endpoint that runs the exact same Zod checks as generation, but stops before rendering. Form posts to it on every change (debounced 400ms). Buttons gate on the LIVE error count, not submit state. 6/ The warnings-vs-errors distinction matters here. Warnings (amber) never block — a small footprint, a shallow pitch. Errors (red) disable the buttons. Fixing an error re-enables immediately. No manual reset. 7/ I wrote zero of this code. Every commit is Claude Opus 4.8. My job was noticing the three problems and describing them clearly enough that the fixes came out right. That's the whole skill I'm building. 8/
Across two full days, closed out roughly 20 numbered fix items (Sessions 8–11) on the plan generator — eaves details, DPM asymmetry, label collisions, float rounding on elevation pages — the unglamorous grind of getting technical drawings right.
Two days ago I sat down to clear a backlog of ~20 numbered fix items on the plan generator. No new features. Just a list of things that were wrong with the technical drawings it produces — label collisions, bad DPM geometry, float dimensions printing as 1727.2077mm on a document someone might hand to a builder. This is what most of the work actually looks like. The fix I found most instructive was the floor-alignment one. Front and back elevations were top-aligned, which sounds reasonable until you remember this is a pent-roof building — the front and back walls are different heights. Top-aligning them meant the floors sat at visually different levels on the page. On a technical drawing, that reads as a mistake even if every individual measurement is correct. The fix was to compute a shared floor line from the taller of the two walls and pass it to both elevations, so the height difference shows at the top rather than the bottom. Simple in retrospect. Claude (Opus 4.8, writing 100% of the code) also wrote a fail-first test before touching the layout logic — a test that proved the old alignment was wrong, watched it fail, then made it pass. I've started to appreciate that discipline more than I expected to. A test written after the fix is just a sanity check. A test written before is proof you fixed the right thing. The label collision work was messier. Four wall names and four overhang callouts all competing for space on the top-down plan page, overlapping dimension lines, each other, the title. The approach was to extract a pure computePlanLabelLayout function that became the single source of truth for both the renderer and a new collision test. The test asserts no label rectangle intersects another label or a dimension line. Again, written to fail first against the old placement. [VERIFY: worth checking this holds at edge-case scales I haven't tested yet.] I'm a beginner at this. I don't have a background in CAD tooling or PDF generation or any of the geometry involved. What I've learned over these sessions is that the hardest part isn't the code — Claude handles the code — it's knowing what "correct" looks like well enough to describe it. Is the DPM visible under the hatch on the concrete variant? Should the label extend inward or outward? Where exactly does the floor line sit? That judgment is the thing I'm actually building, session by session. 298 tests at the start of the two days. 309 at the end, all green. Every line of source code written by Claude. The unglamorous part is that most of those 11 new tests are checking whether a rectangle overlaps another rectangle, or whether a number has been rounded, or whether a membrane drawn after a hatch is actually visible. Not impressive individually. Collectively, it's the difference between a drawing that looks right and one a builder could actually use.
Across two full days, closed out roughly 20 numbered fix items (Sessions 8–11) on the plan generator — eaves details, DPM asymmetry, label collisions, float rounding on elevation pages — the unglamorous grind of getting technical drawings right.
1/ Two days. ~20 numbered fix items. The plan generator now has floor-aligned elevations, collision-free labels, readable float dims, and correct DPM geometry. None of it is glamorous. All of it matters. 2/ The one that broke my brain first: front and back elevations were top-aligned instead of floor-aligned. A pent roof has different front/back heights, so top-aligning them made the floors sit at different levels. On a technical drawing, that's just wrong. 3/ Fix: compute a shared floor line from the taller of the two walls, pass it to both elevations, and let the height difference show at the TOP. Claude wrote a fail-first test that proved the old layout misaligned floors before writing a single line of the fix. That ordering matters. 4/ Then: elevation pages were printing raw interpolated floats. "Front wall length: 1727.2077mm" on a printed drawing. Looks like a bug even if the geometry is correct. One Math.round() call in elevation-helpers.ts. 309/309 tests still green. 5/ Label collisions were the messiest. Four wall names + four overhang callouts all competing for space on the top-down plan page. The fix extracted a pure computePlanLabelLayout function — now the single source of truth for both the renderer AND a new collision test. 6/ The collision test asserts no label rect intersects another label or a dimension line. It was written to FAIL against the old placement first, then the fix made it pass. That's the only way to trust a layout test. [VERIFY: worth checking this holds at edge-case scales] 7/ DPM geometry, eaves details, base-channel visibility on the concrete variant, a pitch advisory that was more noise than signal — all landed in the same two-day stretch. 298 → 309 tests. All green. 0 lint errors. Claude Opus 4.8 wrote every line. 8/ The honest summary: most of "building" a technical drawing tool is this. Not architecture. Not clever algorithms. Just: is that label covering that dimension line? Is that membrane visible under that hatch? Unglamorous. Necessary. Shippable. /
Shipped a shared detail-page layout system so all dimension labels land outside the drawing area — then immediately had to fix leader lines that were collapsing into collinear runs.
Three sessions ago I was patching label overlaps by nudging things a few pixels. They'd come back the next time I changed a drawing. Classic whack-a-mole. So in Session 10 I asked Claude to do it properly: build a layout engine that reserves margin columns on each side of the drawing before placing anything. Labels go outside the box. Not "pushed outside" — outside by construction, because the system never allows them inside. The invariant is structural, not a constraint you enforce after the fact. That's a distinction I've been learning to care about. Nudging says "this happened to not overlap." A structural fix says "this cannot overlap." The test for the new system uses an adversarial callout set — inputs specifically designed to trigger the old bug — and a fail-first control that proves the old placement violated the invariant. Green on the new code. Red on the old. 304 tests passing. I felt pretty good about this. Eight detail pages, all routing through the same shared helper. Two building configurations (office, bar pod), both verified. One afternoon's work. Then I looked at the external corner detail. The "Rivets every 300-400mm" label had a leader pointing at one rivet in a horizontal row. The leader ran dead-horizontal through the entire row — through every other rivet — to reach its target. Technically correct. Visually it looked like the line was labelling the whole row, or maybe just making a mess. I didn't know this had a name. Turns out it's called a collinear run, and technical drafters avoid it by using a dog-leg: you hold the leader clear of the row, then make a short perpendicular hop onto the single target. An L-shape instead of a straight line through everything. Session 11 added an approach: "above" | "below" option to the detail leader. The label sits off the rivet row, leader runs horizontally clear of all rivets, then drops down onto the one it means. The test checks that the leader clears every non-target rivet by a margin — proven red without the flag, green with it. The thing I keep noticing: I can't read coordinate output and intuit whether a drawing is correct. The numbers are meaningless to me until I render the PDF. So I've gotten very dependent on fail-first tests that prove the old code was wrong before proving the new code is right. Without that red-first step, I'd have no way to know the test is actually testing anything. All of this — every line of layout engine, every leader calculation, every test — was written by Claude Opus 4.8. I'm the one who looked at the rendered PDF and said "that leader is going through the rivets." Claude figured out what to do about it. 309 tests green. On to D2 through D7.
Shipped a shared detail-page layout system so all dimension labels land outside the drawing area — then immediately had to fix leader lines that were collapsing into collinear runs.
1/ I "fixed" a label-overlap bug by building a proper layout system. Labels now live in reserved margin columns — outside the drawing, by construction, not by nudging. Then immediately hit a new problem I didn't know had a name. 2/ The old approach: shove labels wherever they fit, patch overlaps when they appear. They kept coming back. The new approach (Session 10): a shared layout engine that reserves left/right margin columns for all callouts before drawing anything. Labels can't hit the drawing because they're structurally outside it. 3/ 8 detail pages. All 8 now route through the same layoutDetailLabels() helper. One fix, everywhere. Render-verified on two building types (office + bar pod). 304 tests green. Felt good for about an hour. 4/ Then the leader lines broke. A "Rivets every 300-400mm" callout needed to point at one specific rivet in a horizontal row. The leader ran dead-horizontal through every other rivet in the row to reach it. Collinear. Visually useless. 5/ The fix: an approach: "above" | "below" option on any detail leader. The label holds off the row, then the leader makes an L-shaped dog-leg — a horizontal run clear of the rivets, then a short perpendicular hop down onto the target. I did not know this was a standard drafting move. Apparently it is. 6/ The test I'm most pleased with: proven RED without the approach flag (leader runs straight through the row), proven GREEN with it. The test literally checks that the leader clears every non-target rivet by a margin. Fail-first tests are the only reason I trust any of this. I can't read the output coordinates — I need the test to tell me. 7/ Three sessions, one afternoon. Shared layout system → crossing leaders → collinear leaders. Each fix exposed the next problem. That's either how software works or how my software works. Not sure yet. 309/309 green. Claude Opus 4.8 wrote every line of this.
A door on the front wall was rendering as a hole punched off the edge of the page — a subtle SVG path coordinate bug that took a dedicated follow-up session to chase down after the original 3D cut-out fix.
Three sessions to make a door look like a door. That's the summary of this week's rabbit hole, and I want to write it down while the pain is fresh. PanelProBuild generates PDF cover sheets for modular building configurations — 3D perspective views with walls, openings, the works. For a while, doors and windows were rendering as flat dashed rectangles with labels. Fine as a placeholder, but it read like a tag, not a hole. I wanted them to look recessed — like you're actually looking into the wall. Session 11 got the basic 3D cut-out effect working. Session 11.1 fixed a separate issue where chamfer-corner doors were being silently dropped (clipped to zero points, hit an early return, vanished entirely). Both of those felt like real progress. I checked the output, it looked right, I moved on. Then I looked at the plain flat front wall on the bar pod. The door was gone. Not misaligned, not wrong color — just a floating label where a hole should be. The root cause took a dedicated follow-up session to find. pdf-lib (the library I'm using to write PDFs) wraps every SVG path drawn with `drawSvgPath` in a `scale(1, -1)` transform — a Y-axis flip — with no compensating translation. So a door sitting at y ≈ 505 in my coordinate space was actually hitting the page at device y ≈ -505. More than 500 pixels below the bottom of the page. Completely invisible. Perfectly, mathematically invisible. I found this by inspecting the raw PDF content stream directly. Not guessing, not running experiments — reading the actual bytes the library was writing. That felt like a small milestone for me as someone who has never written a PDF parser or really thought hard about coordinate systems before this project. The fix was to stop using `drawSvgPath` for fills altogether. Outlines now use `drawLine` calls (which don't go through the flip), and fills use a Y-negated path I called `svgFillPathYUp` — a name chosen specifically so future-me doesn't have to re-derive why the negation is there. I also wrote a test that walks the PDF content stream, tracks the coordinate transform matrix at each operation, and asserts that no vector geometry renders below the page boundary. On the unfixed code, it fails at device y = -620 (the door's bottom corner). After the fix, it passes. Seeing that -620 disappear felt better than I expected. The whole codebase is written by Claude Opus 4.8 — I describe what I want, review the output, test it, and redirect when something's wrong. What I'm learning through that process isn't how to write TypeScript. It's how to read a system: how to look at wrong output and ask the right question about what could cause it. How to tell the difference between "the logic is wrong" and "the coordinate space is lying to you." That distinction cost me three sessions. I'll probably remember it for a while.
A door on the front wall was rendering as a hole punched off the edge of the page — a subtle SVG path coordinate bug that took a dedicated follow-up session to chase down after the original 3D cut-out fix.
1/ I spent a dedicated session chasing a door that was rendering as a hole punched off the edge of the page. Not off by a few pixels. Off by ~1000 pixels. Into negative space that doesn't exist. 2/ The first fix (Session 11.1) got the 3D cut-out look working for chamfer-corner doors. Looked good. Shipped it. Then I checked the plain flat front wall. The door was just... gone. A floating label where a hole should be. 3/ Turned out pdf-lib's drawSvgPath wraps every path in scale(1,-1) — a Y-flip — with NO matching page-height translation to compensate. So a door sitting at y≈505 in PDF coordinates was actually drawing at device y≈-505. Below the page. Invisible. 4/ The door *was* there. The clipping was correct. The projection math was correct. It was just rendering in a coordinate space that doesn't exist on paper. This is the kind of bug that makes you feel like you're losing your mind. 5/ The fix: stop using drawSvgPath for fills. Instead — stroke outlines with drawLine (which stays on-page), and fill with a Y-negated path that manually cancels pdf-lib's flip. Named it svgFillPathYUp so future-me knows exactly what it's doing and why. 6/ Wrote a fail-first test: walk the PDF content stream, track the coordinate transform matrix, and assert no vector geometry renders below the page. On unfixed code it fails at device y = -620. After the fix, passes. That number — -620 — is weirdly satisfying to see go away. 7/ Three sessions (11, 11.1, 11.2) to get doors that look like actual holes with depth. Every commit co-authored by Claude Opus 4.8. I don't write the code. But I'm learning what "the coordinate transform matrix" means the hard way.
Built a cutting-stock solver from scratch (via Claude) to minimise panel waste — one of the meatiest algorithmic features of the week, going from a naive 2500mm cap to real stock lengths and a proper optimisation pass.
My panel cost estimates were off by roughly 3x. Not 3%. Three times. I knew the numbers felt wrong — my app was quoting a garden bar pod at somewhere between £1,200 and £1,900 for panels, and the builder's actual invoice (order #8496) came back at £622. That's not a rounding error. That's a broken model. This week I tracked it down, and fixing it turned into the meatiest single feature I've built so far — entirely via Claude, because I can't write TypeScript beyond the basics. The root cause was embarrassingly simple in hindsight. The app was billing every panel against a hardcoded 2500mm cap, as if each piece of insulated panel you needed meant buying a 2500mm length. But the real product — a 40mm PIR roof/wall panel, SKU PIR40ANPIRP31 — doesn't come in 2500mm at all. The available stock lengths are 3100, 3600, 4100mm, up to 8100mm. The 2500 cap was a structural design constraint that had leaked into the cost model and been treated as a purchase length ever since. The actual way it works on a real build: you order stock lengths, cut your required pieces from them, and reuse the offcuts on other faces of the building. The pod has 14 required pieces (six at 2400mm, four at 2000mm, four at 1077mm). A competent estimator packs those into the fewest stock lengths they can. My app was just... buying 14 separate lengths and ignoring the offcuts entirely. So I asked Claude to write a cutting-stock solver. I had to look up what that was first. It's a classic bin-packing variant — you have bins of fixed sizes (stock lengths) and items to fit into them (your cut pieces), and you want to minimise the number of bins. First-Fit-Decreasing is a well-known heuristic: sort pieces largest-first, then slot each one into the first bin where it fits, opening a new bin only when nothing has room. Claude wrote the whole thing in cutting-stock.ts, with cross-face offcut reuse (an offcut from a wall face can be used on the roof if the length works) and lineage tracking so the output PDF shows things like "roof-2 ← offcut of back-0". That last part I hadn't even asked for explicitly — it came out of describing the problem properly, including the fact that someone on-site needs to know where each piece came from. The result: 14 pieces pack into 10 purchased stock lengths. £622. The count and the subtotal now match the real invoice. What I keep sitting with is this: I didn't understand bin-packing before this week, and I still couldn't write the algorithm without help. But I could describe the problem precisely — real stock lengths, cross-face reuse, lineage labels, a specific invoice to calibrate against. That precision is what produced something useful. The code is 100% Claude's. The problem definition was mine. I think that's the actual skill I'm building. Not reading algorithms. Not writing TypeScript. Getting precise about what the problem really is before asking for a solution. The 2500mm cap survived for months because nobody — including me — had written down exactly what "stock length" meant in this context. Now it's in the schema, tested, and 286 tests are green. On to the next wrong assumption.
Built a cutting-stock solver from scratch (via Claude) to minimise panel waste — one of the meatiest algorithmic features of the week, going from a naive 2500mm cap to real stock lengths and a proper optimisation pass.
1/ My app was quoting a garden bar pod at ~3x the real panel cost. The root cause: every panel was billed against a dead 2500mm cap, regardless of actual size. Fixing it meant building a proper cutting-stock solver. I had no idea what that was a week ago. 2/ The real product (a 40mm PIR insulated panel) only comes in fixed stock lengths: 3100, 3600, 4100mm... up to 8100mm. You don't buy a panel per wall. You buy a length, cut your pieces from it, and reuse the offcuts elsewhere. The app wasn't doing any of that. 3/ So Claude wrote a First-Fit-Decreasing bin-packing solver in src/bom/cutting-stock.ts. It collects every required piece — wall-column heights, roof slope lengths — sorts them largest-first, and packs them into real stock lengths with cross-face offcut reuse. 4/ Before: 14 required pieces → 14 stock lengths billed @ 2500mm each. ~£1,200–£1,900. After: those same 14 pieces pack into 10 purchased lengths. ~£622. That matches Ben's actual order #8496 — the real invoice I'm calibrating against. [VERIFY once more orders come in] 5/ The solver also writes lineage into the PDF layout pages. A roof panel now shows "roof-2 ← offcut of back-0" so whoever is on site can see exactly where to cut and what to keep. That detail cost nothing extra. It just fell out of tracking the offcuts properly. 6/ The thing I keep noticing: I didn't understand bin-packing before this week. I still can't write the algorithm myself. But I could describe the problem precisely enough — real lengths, cross-face reuse, lineage labels — that Claude built something that matches a live invoice. Precision in the prompt is the skill. That's what I'm actually learning. 7/
Added a /api/health endpoint just to diagnose whether the DB was actually connecting — a beginner debugging step that turned out to be necessary.
Last Saturday morning I realized I had no idea if my database was actually connecting. The app wasn't crashing. Nothing was throwing an obvious error. But something felt off, and I kept second-guessing whether data was really getting written and read or whether I was just operating on false confidence. As someone who didn't write a single line of this codebase myself — it's 100% generated by Claude — I don't always have the intuition to know where to look when something feels wrong. So I did the most beginner thing possible: I asked Claude to add a /api/health endpoint whose only job is to poke the database and report back. Connected or not. Latency if possible. That's the whole thing. It took maybe five minutes to add. Commit a66a374, Saturday morning, probably before I'd finished my coffee. What I didn't expect was how much calmer I felt afterward. Not because the endpoint revealed some dramatic problem — I'm honestly not sure yet what it confirmed or ruled out [VERIFY: whether the suspected connection issue showed up in the health check results]. But because I'd gone from "I think the DB is fine?" to "I have a way to actually check." That's a different mental state to work from. I found out later that health check endpoints are completely standard in production systems. Teams build them deliberately, hook them into monitoring, use them for load balancer checks. I built mine out of pure anxiety. But I got to the same place. That's something I keep noticing on this project: a lot of "best practices" exist because someone, at some point, was just desperate for visibility. The practice got formalized later. I'm living the desperate-for-visibility phase in real time. If you're building something and you have a nagging feeling about whether a piece of infrastructure is actually working — don't just hope. Add the window. A small endpoint, a log line, a console.log you delete later. Something that gives you a straight answer. Blind confidence is fragile. Even a tiny health check is something you can point to and say: yes, this part works. Or no, here's where it's broken. Either answer is more useful than the fog.
Added a /api/health endpoint just to diagnose whether the DB was actually connecting — a beginner debugging step that turned out to be necessary.
1/ I didn't know if my database was actually connecting. So I built a /api/health endpoint just to find out. That's where I am as a beginner. "Is this thing on?" is a valid debugging step. 2/ The app looked fine on the surface. No crashes, no obvious errors. But I had a nagging feeling the DB connection was silently failing somewhere. I couldn't see inside. So I added a window. 3/ /api/health does one thing: hits the DB and reports back. Connected or not. That's it. Claude wrote the whole endpoint. I just knew I needed *something* that would give me a straight answer. 4/ Turns out this is a real pattern — health checks are standard in production systems. I stumbled into it from desperation, not best practices. [VERIFY: whether the DB connection issue I suspected was actually confirmed by the endpoint] 5/ The lesson I'm taking: when you can't see what's happening, build a tiny thing that shows you. Don't guess. Add the window. A 10-line endpoint saved me probably hours of blind poking. 6/
Octokit accidentally got bundled into claude.ts because sync logic lived in the wrong layer — had to move GitHub sync up to the caller layer (button + cron) to fix the build.
I spent part of this week staring at a build error that made no sense to me on the surface: Octokit, the GitHub API client, was getting bundled into `claude.ts`. The file that handles all my Claude AI calls had no business knowing GitHub existed. But there it was. My entire codebase is AI-generated — Claude writes it, I describe what I want and debug what breaks. So when something goes wrong, my job is to understand just enough to explain the problem back. This one took me a while. The root cause was that the GitHub sync logic — the code that pushes content up to a repo — was living in the wrong place. It was sitting in the same layer as the Claude calls, which meant anything that imported `claude.ts` pulled Octokit along for the ride. Build tools don't care that you "didn't mean" to create that dependency. The import graph is the import graph. The fix was conceptually simple once I understood it: move the GitHub sync up to the caller layer. The button a user clicks to trigger a sync? That's allowed to know about GitHub. The cron job that runs it automatically? Same. But `claude.ts` itself should be ignorant of GitHub entirely. It just does AI stuff and returns results. What the caller does with those results — including syncing them somewhere — is the caller's problem. I didn't come up with this architecture. Claude did, originally. But it drifted, probably across several sessions where I was adding features without a clear picture of the whole. That's the risk of building this way: the AI will give you something coherent in the moment, and then coherence can quietly erode as you keep adding things. What I'm taking from this: "it works" and "it belongs here" are two different questions. The sync code worked fine. It was just in the wrong place, and that wrongness only became visible when the build complained. For anyone else building with AI-generated code — the layer a piece of logic lives in matters. If you're getting weird bundling issues or unexpected dependencies, it's worth asking where the code *is*, not just what it *does*. That question is approachable even if you're not a seasoned engineer. I'm not, and I got there eventually.
Octokit accidentally got bundled into claude.ts because sync logic lived in the wrong layer — had to move GitHub sync up to the caller layer (button + cron) to fix the build.
1/ My AI-generated codebase bundled a GitHub library into an AI file and I had no idea why the build was broken. Here's what I learned about "layers" the hard way. 2/ The file `claude.ts` handles all the Claude AI calls. Somehow Octokit — the GitHub API client — was getting pulled into its bundle. That's wrong. Claude shouldn't know GitHub exists. 3/ What happened: the sync logic that pushes content to GitHub was sitting *inside* the same layer as the Claude calls. So when anything imported `claude.ts`, it dragged Octokit along with it. 4/ The fix was moving the GitHub sync up to the "caller layer" — the button that triggers a sync, and the cron job that does it automatically. Those callers are *allowed* to know about GitHub. `claude.ts` isn't. 5/ I didn't architect this. Claude did. But I had to understand it well enough to describe the problem back to Claude so it could fix it. That's the loop I'm in: bug → confused → figure out just enough → fix. 6/ The takeaway: where code *lives* matters as much as what it *does*. A function that works perfectly in the wrong layer still breaks your build. 7/
Hit Vercel Hobby's 60-second function limit in production and had to rearchitect GitHub sync to run per-repo instead of all at once to stay under it.
Last week the GitHub sync in Build-in-Public Studio stopped working in production. Not with an error. It just silently timed out and did nothing, every time. The cause was Vercel Hobby's 60-second serverless function limit. My original sync design loaded all watched repos, pulled their commits, and generated drafts in a single function call. That's a perfectly reasonable design until you have more than one or two repos and the whole thing blows past a minute. Vercel kills the function, no drafts get generated, no alert fires. The app just looks like it's doing nothing. I don't have a strong enough mental model of serverless execution to have anticipated this. I knew there was a time limit in the abstract. I did not think carefully about what happens when you hit it. The fix — which Claude designed after I described the constraint — was to decompose the sync into per-repo units. Instead of one function call doing everything, the app now fires one call per watched repo, sequentially. Each call syncs that repo from GitHub and generates drafts scoped to it. The target is ~50 seconds per call, which keeps a real buffer under the cap. As a side effect, the UI got better. The GenerateNowButton now shows per-repo progress in real time as each call completes, instead of a single spinner for the whole batch. The cron endpoint was updated to mirror the same loop, so scheduled runs and manual runs behave identically — one less class of "works manually, breaks on schedule" bugs. There was one small wrinkle: notes that aren't linked to any specific repo (repo = null) weren't being included in the per-repo generation passes. Claude caught this and added them to every per-repo call so they don't get orphaned. I didn't know "decompose by repo" was the right architectural move. I knew the number I had to stay under and I knew what was currently blowing past it. That was enough to get to a working solution — but it's a good reminder that I'm building on a platform with real constraints I don't fully understand yet, and those constraints only become visible in production. The takeaway, honestly: if you're on Vercel Hobby and you have any function that touches external APIs in a loop, time it in production early. Local dev will lie to you about this every time.
Hit Vercel Hobby's 60-second function limit in production and had to rearchitect GitHub sync to run per-repo instead of all at once to stay under it.
1/ My app silently stopped working in production last week. No error email. Just... nothing syncing. The cause: Vercel Hobby's 60-second function limit. I'd been blowing past it every time. 2/ The original design was simple: one button press → sync all watched GitHub repos → generate drafts. Worked fine locally. In prod, the whole thing would just time out and die quietly after 60s. 3/ The fix Claude wrote: instead of syncing everything in one call, fire one serverless function *per repo*, sequentially. Each call finishes in ~50s. Never touches the limit. 4/ The GenerateNowButton now loops through your watched repos one at a time and shows per-repo progress as it goes. The cron job mirrors the same pattern so both paths behave identically. 5/ I didn't design this architecture. I described the constraint to Claude and it figured out the per-repo decomposition. I wouldn't have known that was the right move — I just knew the timeout number I had to stay under. 6/ The thing I keep learning: production constraints are where the real decisions get made. Local dev hides all of this. You only find the 60-second wall when real users hit it. 7/
jsonb double-encoding bug silently corrupted every analysis stored since the Neon migration — a subtle data-layer bug that only surfaced after the stack swap
Every analysis my app had stored since the Neon migration was silently corrupted. Not flagged. Not errored. Just quietly wrong, sitting in the database while I kept building on top of it. The bug lived in a supabase-shim layer I wrote during the stack migration. When writing jsonb columns, the shim was doing the obvious thing: `${JSON.stringify(obj)}::jsonb`. Pass the object, stringify it, cast it to jsonb. That's what every example shows. That's what feels right. What I didn't know — and what took a production outage to surface — is that postgres.js with `prepare:false` describes the statement before executing it, resolves the parameter type to jsonb, and then applies its own JSON.stringify serializer on top of mine. So the database was receiving a stringified string. A jsonb scalar of type 'string', not 'object'. Double-encoded, every single time, on every single write. Nothing complained. The write succeeded. Postgres said OK. The row was there. It only fell apart on read. When the client tried to access `structured.reconfiguration_ideas[0]` — the first index of what should be an array from the AI analysis — it got a character-indexed object instead, because spreading a string in JavaScript gives you exactly that. AppErrorBoundary caught the crash and took down every page. The fix itself was almost embarrassingly small: a `jsonb()` helper in lib/db.ts that uses postgres.js's own `sql.json` wrapper instead of manual stringify, with a comment documenting the footgun for anyone (me, future me, Claude) who touches it later. Eight call sites replaced. The ::jsonb cast removed entirely. But the data already in the database was still corrupted, so there was also a repair migration — an idempotent SQL script to fix every affected row in-place — plus `parseIfString` coercion guards added to every query that reads jsonb, so that if a malformed row somehow slips through, it degrades to a placeholder rather than killing the whole app. The part that I keep thinking about: this bug was completely invisible at write time. No type error, no DB constraint violation, no warning in the logs. The footgun is that it works. It just stores the wrong thing, and you don't find out until something downstream tries to use the data as what it was supposed to be. I'm a non-developer. I built this entire codebase with Claude. Subtle data-layer bugs like this — the kind that require reading the internals of a library to diagnose — are exactly where I'd be fully stuck on my own. Claude found this by reading the postgres.js source and tracing the serialization path. That's not me being modest about my own abilities. That's just what happened. Both fix commits have the co-author line: `Claude Opus 4.8 (1M context)`. That's not a footnote or a disclaimer. It's accurate attribution. The takeaway, if there is one: silent data corruption is the worst kind of bug because there's no signal. If you're migrating DB clients and you have jsonb columns, go read how your new client handles parameter serialization before you assume the obvious pattern is correct. It might be working fine and storing the wrong thing at the same time.
jsonb double-encoding bug silently corrupted every analysis stored since the Neon migration — a subtle data-layer bug that only surfaced after the stack swap
1/ Every analysis stored since my Neon migration was silently corrupted. Not one error. Not one warning. Just quietly broken data, sitting there for days. Here's the bug that took down every page of my app — and how Claude found it. 2/ The stack: postgres.js as my DB client, a supabase-shim layer I wrote during the migration, and jsonb columns storing AI analysis results. The shim was writing jsonb values like this: `${JSON.stringify(obj)}::jsonb` Looks fine. It is not fine. 3/ postgres.js with `prepare:false` describes the statement first, resolves the param type to jsonb, then applies its OWN JSON.stringify on top of mine. So the DB was storing a jsonb scalar STRING — a stringified string — instead of an object. Double-encoded. Silently. Every time. 4/ Nothing crashed on write. The data went in, postgres said OK, life seemed good. It only blew up on READ, when the client tried to do `structured.reconfiguration_ideas[0]` and got a character-indexed mess instead of an array. AppErrorBoundary caught it. Every page went down. 5/ The fix was a one-line helper — a `jsonb()` typed-parameter wrapper in lib/db.ts that uses postgres.js's own sql.json helper instead of manual stringify. No more ::jsonb cast. No more double-encoding. 8 call sites replaced. 6/ But the data already in the DB was still corrupted. So: a migration (0008) to repair every affected row in-place, plus `parseIfString` guards on every query that reads jsonb, so a bad row degrades to a placeholder instead of killing the app. Defense in depth, after the fact. 7/ The thing that sticks with me: this bug was invisible at write time. No type error. No DB error. The footgun is that it WORKS — it just stores the wrong thing. Claude caught it by reading the postgres.js source. I would have stared at this for days. 8/ I'm a non-developer building this entire codebase with AI. Bugs like this — subtle, runtime, data-layer — are exactly where I'd be completely lost alone. The co-author on both fix commits: Claude Opus 4.8. Not a footnote. The actual engineer.
Entire Property-Reconfig-App migrated off Supabase to Neon + Better Auth + Cloudflare R2 + Vercel in a single session — a full stack swap on a live app, written entirely by AI
Yesterday I replaced the entire backend of my app in a single day. Not one service — all of them. Supabase was handling auth, the database, file storage, and eight edge functions. By the end of the session, every one of those was running on a different provider: Neon for the database, Better Auth for auth, Cloudflare R2 for storage, and Vercel for the functions. Every line of code was written by Claude. I described what I wanted. Claude built it. I want to be specific about what that means, because "AI wrote my code" is a pretty hollow claim without the details. The migration ran across three prompts. The first one consolidated 31 existing schema migrations into 4 clean ones and stood up the Neon database — including rewriting every Row Level Security policy from Supabase's `auth.uid()` to a session-variable pattern that works without Supabase in the loop. I don't fully understand the internals of that pattern. I read the SQL, asked a couple of questions, and said it looked right. The second prompt is where things got interesting. It wired up a new auth system (passwordless email OTP), moved file storage to R2 with a proper per-user tenancy guard, and ported eight Supabase Edge Functions to Vercel API routes in batches. The core of the app is a 2,200-line server-sent events streaming pipeline that calls Anthropic's API. Claude wrapped it in a shim layer that mimicked the exact Supabase surface it was already using — so the streaming logic didn't have to change at all, just the data layer underneath. One moment stood out. The PDF report generator had a logic divergence from the canonical calculation library — a field that had been renamed somewhere along the way. Claude caught it, returned a 501 stub for that route, and left a note in the commit message explaining exactly what needed reconciling before it could produce correct output. I didn't ask for that. It just flagged the risk and stopped rather than generating plausible-but-wrong PDFs. The hardest bug in the whole migration was a trailing space in an environment variable. `BETTER_AUTH_URL` had a stray space that made Better Auth build a malformed URL and crash at initialization — which took down every function that imported auth. That's the kind of thing that would have taken me hours to find. Claude spotted it in the smoke-test pass. I'm not a backend developer. I wouldn't have known to design a two-role database boundary (one role for app queries under FORCE RLS, one for system-level cache writes that bypass it), or how to handle multi-segment auth routes on Vercel dev, or the right way to do background work with `waitUntil` after switching from Deno edge functions to Node. But I could read the plan, ask questions when something seemed off, and make the call to proceed. The thing I keep coming back to: this wasn't autocomplete. It was closer to having a senior engineer who could hold the entire migration plan in their head, execute it in careful batches, and tell me when something was too risky to proceed. [VERIFY: I'm still testing the app end-to-end post-migration, so I can't claim zero regressions yet — but the TypeScript compiler is clean and auth is working.] I'm building in public partly to figure out what this actually means. Is this a permanent shift in what a solo non-technical founder can build? Or is it a fragile moment that only works while the models are new and the problems are still within their context window? I don't know. But it's working right now, and that feels worth documenting.
Entire Property-Reconfig-App migrated off Supabase to Neon + Better Auth + Cloudflare R2 + Vercel in a single session — a full stack swap on a live app, written entirely by AI
1/ I replaced the entire backend of my app in a single session. Supabase → Neon + Better Auth + Cloudflare R2 + Vercel. Every line of code: written by Claude. I just described what I wanted. Here's what that actually looked like. 2/ The app is a property investment analysis tool. It had Supabase doing everything — auth, database, file storage, edge functions, RLS. Replacing one piece of that is a weekend project. Replacing all of it at once is... not something I'd have tried manually. 3/ The migration ran in 3 prompts across one day. Prompt 1: consolidate 31 schema migrations into 4, stand up Neon, rewrite every auth.uid() RLS policy to use a session variable instead. Claude wrote the SQL. I read it and said "looks right." 4/ Prompt 2: swap supabase-js for postgres.js, wire up Better Auth with email OTP, move file storage to R2, port 8 Supabase Edge Functions to Vercel API routes. The 2,200-line SSE streaming pipeline — the core of the app — got a shim layer so the streaming logic didn't have to change at all. 5/ One thing that genuinely surprised me: when a route was too risky to port cleanly (the PDF report generator had a logic divergence from the canonical lib), Claude flagged it, returned a 501 stub, and left a note explaining exactly what needed reconciling first. I didn't ask it to do that. It just did. 6/ The hardest bug was a trailing space. BETTER_AUTH_URL had a stray space in the env var. Better Auth built a malformed URL and crashed on init, taking down every single function that imported auth. Claude found it. I would have stared at that for hours. 7/ I'm not a backend developer. I couldn't have designed the FORCE RLS + session-variable pattern, or the two-role DB boundary (app user vs. system owner), or the R2 tenancy guard. But I could read the plan, ask questions, and say "go." That's the whole model. 8/ Full stack swap on a live app, one session, zero backend experience. I don't know if this is the future of software or just a weird moment in time — but it's working, and I'm building in public to find out. /
Juggling three separate AI-generated codebases (Property-Reconfig-App, Stashery, Build-in-Public Studio) in the same week — each at a different stage, each 100% Claude-written.
This past week I had three codebases open at the same time. Every single line across all three was written by Claude. Not "AI-assisted" — I mean I have never typed a function, a component, or a CSS variable into any of them. Claude wrote it all. Here's what made the week feel strange: each project is at a completely different stage, and managing them back-to-back made that stage difference really obvious. Property-Reconfig-App is the oldest. This week it logged eleven production releases — a max_tokens hotfix on a cost annex, an auth migration from OTP-only to email and password (because the OTP flow was causing real pain for a real user named Vicky), a forgot-password bug, a photo carousel scroll affordance. Mature, shipping, consequential. Working in that codebase feels like being a project manager. Claude writes; I decide what to build next and whether what just shipped is actually correct. Stashery is somewhere in the middle. This week Claude applied a full WyCo design system to it in one commit — CSS variable tokens for light and dark mode, a theme toggle that persists in localStorage and reacts to OS changes, new fonts, every page restyled. The diff was genuinely large. My job was mostly to review it, test that the themes flipped cleanly, and decide whether the amber accent for Stashery felt right. It did. That's the whole contribution I made to that commit. Build-in-Public Studio is brand new. This week's commit was a scaffold — dark theme, teal accent, WyCo docs dropped in. It doesn't do anything yet. That's fine. The interesting part was that starting a third Claude-generated project now has a kind of ritual feel. There's a pattern to how I set them up, what docs I drop in upfront, what I tell Claude about the project's personality before it writes a line. What I keep noticing is that "I don't write the code" doesn't mean the work is easy or that there's nothing to do. It means the work is different. On the mature app, the work is judgment — knowing when a bug is actually a design flaw, knowing when to defer a feature. On the mid-stage one, the work is taste. On the new scaffold, the work is vision — what is this thing, actually? I don't have a tidy conclusion here. I'm still figuring out how to think about being a developer who builds this way. But running three projects in parallel, all at different stages, all Claude-written, made the shape of that work a lot clearer to me than any single project could have.
Juggling three separate AI-generated codebases (Property-Reconfig-App, Stashery, Build-in-Public Studio) in the same week — each at a different stage, each 100% Claude-written.
1/ This week I had three separate AI-generated codebases open at the same time. Each one 100% written by Claude. Each at a completely different stage of life. 2/ Property-Reconfig-App: eleven production releases in one sprint. Auth migration, PDF deferral, photo carousel, Before|After AI renders. This thing is *running*. 3/ Stashery: mid-build. Claude just applied a full design system in a single commit — dark mode tokens, theme toggle, font swap, every page restyled. The diff was enormous. I mostly just read it. 4/ Build-in-Public Studio (this tool): day one. A scaffold, some WyCo docs, a dark theme with teal accent. Doesn't do anything yet. That's the point. 5/ Same week. Same developer. Zero lines of code written by hand across any of them. 6/ What's strange is how different it *feels* to manage each one. The mature app needs judgment calls. The middle one needs taste. The new scaffold just needs a direction. 7/ Turns out "I don't write the code" doesn't mean there's nothing to do. It just changes what the work actually is. /7
Property-Reconfig-App analysis pipeline was silently truncating output because max_tokens was fixed regardless of how many ideas Claude generated — scaled it dynamically per idea count.
A button in my app did nothing. No error. No loading state. The user clicked, the UI acknowledged it, and then absolutely nothing happened. The button was a DIY toggle — it lets users flip individual property ideas between "hire a contractor" and "do it yourself" and see the cost difference. It had worked fine in testing. Then I shipped a change that let the analysis pipeline generate more ideas per property, and quietly, the toggle broke. The root cause was embarrassingly simple once I found it: I had hardcoded max_tokens at 6144 for the pass that writes cost breakdowns. That number was tuned back when the pipeline produced 3 ideas maximum. When I shipped parallel processing in Pass 2 and it started producing up to 6 ideas, nobody — meaning me, and the Claude sessions I used to write this code — updated the token budget. Claude would start generating the JSON response, hit the ceiling mid-object, and stop. The result parsed as `{ ideas: [] }`. Technically valid JSON. No exception thrown. The status column flipped to 'complete' because the code only checked whether the annex existed, not whether it had any content. The frontend saw status='complete' plus a non-null annex and decided everything was fine. The DIY toggle appeared enabled. But every `annex.ideas.find(idea_id)` call returned null, so clicking did nothing visible. The thing that finally made it click was a diagnostic SQL query. I pulled a broken analysis and compared two columns: recommendation_ids had 6 entries, annex_idea_ids was empty. Six inputs, zero outputs. That's not a logic error — that's a hard cutoff. I fixed it two ways. First, make max_tokens scale with how many ideas actually exist: `Math.max(6144, 1024 + ideas.length * 1500)`. Turns out my token-per-idea estimate of 1500 was also wrong — empirical runs showed each idea consumes roughly 1800–2500 tokens [VERIFY: this varies by property complexity], so I had to bump the per-idea allocation to 3000 in a follow-up commit. For 6 ideas that's now ~19k tokens; for 8 ideas, ~25k. More than enough headroom. Second: if ideas[] is still empty after parsing, flip the status to 'failed', not 'complete'. The toggle disables. The user gets an honest signal. Silent failure is strictly worse than loud failure — a broken toggle with no explanation is the worst possible outcome. The broader thing I keep relearning here: when Claude returns valid JSON with an empty array, the first question shouldn't be "did I write bad logic?" It should be "did it run out of tokens?" Empty-but-valid is a truncation fingerprint. I've now added that as a rule in CLAUDE.md so future me — and future Claude sessions — remember to check token headroom whenever a schema gets wider.
Property-Reconfig-App analysis pipeline was silently truncating output because max_tokens was fixed regardless of how many ideas Claude generated — scaled it dynamically per idea count.
1/ A button in my app did nothing. No error. No spinner. Just... nothing. Took me an embarrassingly long time to find the cause: Claude was silently cutting off its own output mid-JSON. 2/ The pipeline asks Claude to generate cost breakdowns for each property idea. max_tokens was hardcoded at 6144 — fine when there were 3 ideas. Then I shipped a change that let Pass 2 produce up to 6 ideas. Nobody updated the token budget. 3/ Claude hit the token ceiling mid-response and stopped writing JSON. The parser saw `{ ideas: [] }` — technically valid, not an error. Status flipped to 'complete'. The frontend read the annex as present and enabled the DIY toggle. But annex.ideas was empty, so every click found nothing and did nothing. 4/ The diagnostic that cracked it: I ran SQL on a broken analysis and saw recommendation_ids had 6 entries but annex_idea_ids was []. Six inputs, zero outputs. That's not a logic bug — that's truncation. 5/ Fix 1: make max_tokens scale with idea count. Math.max(6144, 1024 + ideas.length * 1500) 3 ideas → 6144 (unchanged). 6 ideas → 10024. 8 ideas → 13024. Still not enough — empirical token usage per idea turned out to be ~1800-2500, not 1500. Had to bump again to 3000/idea. 6/ Fix 2: if ideas[] is still empty after parsing, flip status to 'failed', not 'complete'. The toggle disables. The user sees an honest signal. Silent failure is worse than loud failure every time. 7/ The thing I keep relearning: when Claude returns an empty array inside valid JSON, the first question should be "did it run out of tokens?" not "did I write bad logic?" Empty-but-valid is a truncation fingerprint, not just an edge case.
Switched Property-Reconfig-App AI renders from text-to-image to FLUX Kontext Pro img2img — so the 'After' render actually preserves the real room photo as a base.
For the first few weeks, the AI renders in my Property Reconfig App were quietly lying. Not obviously — the images looked fine in isolation. But this week I added a proper Before/After UI that shows the real room photo next to the AI-generated "After." The moment I put them side-by-side, the problem was obvious. The Before photo showed a specific room: a particular window, a door in a specific corner, walls at real angles. The After showed a completely different room that happened to have similar furniture in it. Doors moved. Windows disappeared. Walls rearranged themselves. The AI had hallucinated an entirely new space from the prompt alone. This was logged as a known issue — "AI renders don't preserve actual room layout" — and it sat there until the Before/After UI made it embarrassing enough to fix today. The fix was switching from text-to-image to img2img. Specifically, FLUX Kontext Pro via Replicate. Instead of generating a room from scratch using only a text prompt, Kontext takes the Before photo as a base image and edits it. The walls stay where they are. The window stays where it is. The AI works on top of the real room rather than inventing one. The architectural change was smaller than I expected. The edge function that generates renders now has two paths: if a before_url comes in with the request, it routes to Kontext (img2img). If there's no Before photo — maybe the idea touches a room that wasn't photographed, or the room classifier came back sparse — it falls back to the original flux-1.1-pro text2img path. Same vendor, same API token, roughly the same cost per render (around £0.03-£0.04). The UI caption already adapts to say which mode fired. One small detail that mattered more than I expected: Kontext has an aspect_ratio: 'match_input_image' option. The After comes back the exact same dimensions as the Before. They actually read as a pair now, not two unrelated images crammed into the same slot. I should be honest that this whole codebase was written by Claude — I'm not a developer, and I'm learning what these architectural decisions mean by watching them break in production. The text-to-image path made sense to me in the abstract. It was only when the Before/After UI forced a direct visual comparison that I understood what "preserves room structure" actually means in practice, and why it matters for a product that's supposed to show a specific person what their specific room could look like. The morning after the main switch, there were four more fix commits. Kontext kept regenerating the whole scene — changing camera angles, rebuilding walls — until I tightened the prompt to explicitly ban it. "Do not alter the camera angle. Do not alter the room structure." That explicit. The floor plan markers were also escaping their bounding box and had to be clamped server-side. That follow-up cleanup is probably the most honest signal that something is actually working: you ship the thing, you watch it misbehave in specific ways, and you fix the specific things. Vague problems get vague fixes. This one got four targeted commits in twelve hours, which feels like progress. The takeaway, if there is one: putting two related outputs side-by-side — Before and After, real and generated — is one of the fastest ways to see what's actually broken. I'd been looking at the After renders alone for weeks and thought they were fine.
Switched Property-Reconfig-App AI renders from text-to-image to FLUX Kontext Pro img2img — so the 'After' render actually preserves the real room photo as a base.
1/ The AI renders in my app were generating fake rooms. Wrong doors. Wrong windows. Walls that don't exist. Every "After" was basically a hallucination that had nothing to do with the actual property. 2/ The fix was switching from text-to-image to img2img — specifically FLUX Kontext Pro. Instead of generating a room from a prompt alone, it takes the real Before photo as a base and edits *that*. The room structure is preserved. It's the same room, just reconfigured. 3/ The whole codebase is AI-generated (Claude wrote it), so I didn't fully understand why the renders looked so wrong until the Before/After UI landed this week. Seeing them side-by-side made the disconnect impossible to ignore. Totally different rooms. That's a bad product. 4/ Architecture change, roughly: When a before_url is present → route to flux-kontext-pro (img2img) When it's absent → fall back to flux-1.1-pro text2img Same vendor (Replicate), same API token, same ~£0.03-£0.04 per render. 5/ One detail I liked: Kontext has an aspect_ratio: 'match_input_image' option. The After comes back the exact same shape as the Before. They read as a true pair rather than two unrelated images forced into the same UI slot. 6/ It still degrades gracefully. If there's no matching Before photo — sparse room classifications, an idea that touches a room not in the gallery — the text2img fallback fires and the caption adapts. No broken states. Just a slightly weaker render. 7/ Four follow-up commits the next morning to stop Kontext regenerating the whole scene and keep floor plan markers inside bounds. Turns out "preserve the room" needs to be said very explicitly in the prompt. "Do not alter the camera angle, do not alter the room structure" — that kind of explicit. 7/
Supabase RLS silent failure: an INSERT policy was missing on a table, writes were rejected with no error surfaced — discovered, documented as Lesson L11, and fixed.
For about two hours this week, my app was running Apify actor jobs it didn't need to run, burning API credits on data it was supposed to already have. The cache wasn't working. Except the cache *was* working — the writes were just disappearing. Here's the thing about Supabase Row Level Security: when a write is rejected by a missing policy, you don't get an error. You get `error: null` and `rowCount: 0`. The request succeeds at the wire level. The row just doesn't land. There's no exception to catch, no log line to grep for. The database says "fine" and does nothing. My sold_comps table had RLS enabled from the initial migration. It had a SELECT policy. It did not have an INSERT policy. So every time the app tried to cache a fresh batch of comps, Supabase quietly rejected the write and returned nothing. Then the next lookup came in, found zero cached rows, and triggered another Apify run. I was in an infinite "cache miss" loop with no indication anything was wrong. What made this worse — and this is the part I'm most embarrassed about — is that I was discarding the write result entirely. `lookupComps` called `writeComps` and never captured the return value. Even if Supabase had surfaced something useful, my code was throwing it in the trash. Two bugs, stacked on top of each other, neither visible in isolation. The diagnosis only clicked when I noticed the pattern: two consecutive lookups for the same property produced two separate Apify runs. That shouldn't happen if caching works at all. From there it was a quick `SELECT * FROM pg_policies WHERE tablename = 'sold_comps'` — one policy, SELECT only. No INSERT. Done. The fix was two changes. First, a new migration: `0005_sold_comps_insert_policy.sql`, adding an INSERT policy for any authenticated user. This is a shared global cache, not org-scoped data, so the abuse vector is bounded [VERIFY: worth revisiting if this table grows in sensitivity]. Second, `lookupComps` now captures the `writeComps` result and logs failures with a `[comps]` prefix — useful for triangulating issues in Vercel logs later — but doesn't fail the user-facing request. Fresh data still comes back even if the cache write errors. I documented this as Lesson L11 in `claude.md`, the running list of hard-won lessons I feed back into Claude at the start of each session. The lesson includes an audit query you can run to find any other RLS-enabled tables in your schema that your app writes to — because if this happened once, it can happen again on any table where I forgot to add an INSERT policy. The whole codebase is AI-generated. Claude Opus wrote both the fix and the migration. I wrote the lesson, because that part required actually understanding what went wrong — and that's the part that sticks. If you're using Supabase with RLS: after every new table, ask yourself not just "what do I need to SELECT?" but "what do I need to INSERT, UPDATE, DELETE?" Default-deny is a feature. It just requires you to be deliberate about every operation, not just the reads.
Supabase RLS silent failure: an INSERT policy was missing on a table, writes were rejected with no error surfaced — discovered, documented as Lesson L11, and fixed.
1/ Supabase swallowed my writes and told me nothing. No error. No warning. Zero rows. Just silence. Here's the 3-part failure that caused it — and the fix that's now Lesson L11 in my codebase. 2/ The symptom: my app was calling Apify to fetch sold comps, the actor runs showed "success" in the Apify console, but the sold_comps table stayed empty. Every lookup triggered a fresh Apify run. The cache was never actually caching. 3/ Root cause #1: RLS default-deny. My sold_comps table had Row Level Security turned on — but only a SELECT policy from the initial migration. No INSERT policy. Supabase's default when RLS is on and no policy matches: silently reject the write. error: null, rowCount: 0. 4/ Root cause #2: I was discarding the error anyway. lookupComps called writeComps but never captured the return value. Even if Supabase had surfaced something, my code was throwing it away. Two bugs, perfectly stacked. Neither visible alone. 5/ How I diagnosed it: two consecutive cache lookups → two Apify runs. That's the tell. If the cache write had landed even once, the second lookup would've been a hit. Then: SELECT * FROM pg_policies WHERE tablename = 'sold_comps'; — only one policy. No INSERT. Case closed. 6/ The fix: - 0005_sold_comps_insert_policy.sql — adds INSERT policy for any authenticated user - lookupComps now captures writeComps result and logs errors with a [comps] prefix for Vercel log triangulation - errors still don't surface to the user (fresh data comes back either way) 7/ The lesson (now L11 in claude.md): When you enable RLS on a table, audit every operation your app does against it — not just SELECT. And never discard a return value from a write. The whole codebase is AI-generated. Claude wrote the fix. I wrote the lesson. 8/
Mid-build pivot: abandoned an in-process scheduler and migrated the whole DB layer from SQLite to Supabase + Vercel in the same day the app was born — then deleted db.ts entirely.
My app was a few hours old when I decided to tear out its database layer. Here's what happened. Claude had built an in-process scheduler — something that would run jobs on a timer inside the app process. It worked locally. Then I was reading about Vercel and hit a wall: serverless functions spin up per request and then shut down. There's no persistent process to run a scheduler inside. The whole thing would silently do nothing in production. So the scheduler got scrapped. That was the first pivot. But that decision pulled on a thread I hadn't expected. If I'm deploying to Vercel, SQLite — which stores data as a local file — doesn't work either. Vercel's deployment environment is ephemeral. Every new deploy could be a clean slate. Any data written to a SQLite file could just disappear. I didn't fully understand this going in. I understood it just enough, when I read it, to know it was a real problem. So within the same afternoon the app was created, I had Claude migrate the schema to Supabase, rewrite the entire database layer using the Supabase JS client, and delete db.ts. Not archive it. Not comment it out. Delete it. There was one casualty in the move that I wouldn't have caught on my own: the timestamp parser broke. SQLite stores timestamps in a format that's subtly different from the ISO strings Postgres returns. Claude flagged it and fixed it — but that's exactly the kind of mismatch that causes a confusing bug at the worst possible moment if it slips through. The whole thing — four commits, scheduler abandoned, DB migrated, timestamp parser patched — took about two hours. What I keep thinking about is the shape of this process. I'm not writing any of the code. Claude is. But I'm the one reading enough to know that a serverless environment breaks an in-process scheduler, and I'm the one deciding to pivot instead of pushing forward with something that wouldn't work. That's a different skill than coding, and it's the skill I'm actually building here. I think a lot of beginners (myself included, until recently) assume that "not writing the code" means "not really building." This week made me less sure about that. Catching an architectural mismatch before it reaches production, even if you needed a blog post to explain it to you, is still catching it. The takeaway, if there is one: infrastructure decisions have a way of cascading. One wrong assumption about your deployment environment, and you're not just fixing a bug — you're migrating your entire data layer on day one.
Mid-build pivot: abandoned an in-process scheduler and migrated the whole DB layer from SQLite to Supabase + Vercel in the same day the app was born — then deleted db.ts entirely.
1/ On the same day my app was born, I blew up its database layer and started over. SQLite → Supabase + Vercel. In one afternoon. While the app was still being built. Here's what that actually looked like. 2/ The original plan had an in-process scheduler baked in. Claude built it. It ran. Then I read more about how Vercel works and realized: serverless functions don't stay alive between requests. An in-process scheduler on Vercel is a scheduler that never fires. 3/ So the scheduler got abandoned mid-build. Fine. But that decision pulled on a thread — if I'm on Vercel, SQLite (a local file) doesn't make sense either. Vercel deployments are ephemeral. The file would vanish. One pivot led straight to another. 4/ Claude migrated the schema to Supabase, rewrote the DB layer in the Supabase JS client, and then deleted db.ts entirely. That last part felt significant. Not deprecated. Not commented out. Deleted. 5/ There was one quiet casualty: the timestamp parser broke. SQLite stores timestamps one way; Postgres ISO strings look different. Claude caught it and fixed it — but I wouldn't have known to look for that myself. That's the kind of thing that bites you at 11pm if you miss it. 6/ The whole pivot — scheduler abandoned, DB migrated, db.ts deleted, timestamp bug fixed — happened across four commits in about two hours. I don't think I could have done that without AI doing the actual writing. I would've gotten lost in the Supabase docs alone. 7/ I'm a beginner. I didn't architect this. But I made the call to pivot, and I understood (roughly) why. That feels like the right division of labor for where I am. 7/
Built an entire working app (Build-in-Public Studio) from scaffold to dashboard in a single day — 23 commits on 2026-05-25, all AI-generated code, zero prior codebase.
I've never shipped a codebase before. Not a real one — not with a database, API integrations, a dashboard, a publish flow. That stuff always lived behind a wall I didn't have the skills to climb. Yesterday I climbed it. Kind of. I had help. At 2:30am I started with an empty project and a doc full of notes about what I wanted to build: Build-in-Public Studio, a tool that watches a developer's GitHub commits and turns them into posts. By 8:30am — six hours later — I had a working app. SQLite database. GitHub sync via Octokit. Claude drafting posts using tool_use. A dashboard with moment cards you can edit, regenerate, approve, or reject. A copy-and-publish flow with a "did you actually post it?" prompt. A history page with filters and ratings. Pill-style project tabs. 23 commits. Every single line of code written by Claude. I want to be honest about what "I built this" means in that sentence. I didn't write the code. I described what I wanted, read what came back, pointed out what was wrong or missing, and iterated. That's the whole job. It felt less like programming and more like being a very hands-on product manager — except my engineer never needed a break and could context-switch instantly. The thing I keep turning over: it didn't feel chaotic or stitched-together. Each session built cleanly on the last. When I got to the dashboard at 4am, the SQLite schema from 2:53am was already there waiting for it. When I added the history page at 8am, the project-linking I'd set up earlier just worked. I'd expected a lot more breakage than I got. I don't know if yesterday was a fluke. Day-one energy is real, and there's a version of this where the codebase turns into a tangled mess by week three and I'm stuck. [VERIFY after more sustained building.] But I also think there's something genuinely different happening with AI-assisted development for beginners right now — not because the tools are magic, but because the bottleneck shifts. It's no longer "can you write this function." It's "can you think clearly about what you want." That's a bottleneck I can work with. If you've been sitting on an idea because you're not a developer, I'm not going to tell you it's trivial or that anyone can do it. But I will say: the wall is shorter than it used to be. I spent yesterday proving that to myself, one commit at a time.
Built an entire working app (Build-in-Public Studio) from scaffold to dashboard in a single day — 23 commits on 2026-05-25, all AI-generated code, zero prior codebase.
1/ At 2:30am I scaffolded an empty Next.js project. By 8:30am it was a working app — dashboard, GitHub sync, SQLite, Claude drafting, copy/publish flow, history page. 23 commits. One day. Every line of code written by AI. 2/ I've never built a production app before. I don't write code. What I *can* do is describe what I want clearly, read what Claude gives back, and tell it when something's wrong. Turns out that's enough. 3/ Here's what got built, in order: 2:30am → scaffold + dark theme 2:53am → SQLite notes page 3:29am → GitHub commit sync via Octokit 4:07am → Claude drafting with tool_use 4:26am → full dashboard (cards, tabs, edit/regenerate/approve/reject) 7:33am → copy + publish flow 8:08am → history page with filters + ratings 8:24am → per-project tabs 8:30am → pill UI, polish 4/ The thing that surprised me: it didn't feel chaotic. Each prompt built on the last. Claude held the context. I was basically a product manager talking to a very fast engineer who never got tired. 5/ I don't know if this pace is sustainable or if I just got lucky on day one. [VERIFY after a few more weeks of building] But I do know that "I can't code" is no longer the blocker I thought it was. 6/ If you're sitting on an idea because you're not a developer — this is what the alternative looks like now. One person. One day. One working app. Zero code written by hand.
Wired up a /history page with filters, ratings, and voice learning — moments and notes now linked to projects, making the data model feel like a real product.
There's a commit I pushed this weekend that I keep coming back to. On the surface it's straightforward: a /history page with filters, ratings, and voice learning playback. Fine, useful, shippable. But the line in the commit message that actually matters is the second half — "notes and moments linked to projects." That's the thing. Before this change, Build-in-Public Studio stored content. After this change, it understands what the content *belongs to*. Notes attach to a project. Moments attach to a project. The history page isn't just a reverse-chronological dump anymore — it's a filtered, structured view of work that has a shape. I don't have a CS background. I don't naturally think about data models or relational structure. When I'm working with Claude to build this, I describe what I want to *experience* as a user: "I want to click on a project and see all the moments I logged for it." That's my spec. What I got back was code that apparently involves linking records and joining tables — I'm honestly not sure of all the mechanics — but the experience I described is what showed up. That's been the pattern with this whole project. I describe the product, Claude figures out the shape of the data. I'm not sure where that breaks down, but it hasn't broken down yet in ways I can't recover from. The ratings piece is the part I'm most curious about. Right now you can rate a voice learning moment — a 1-5 or similar, I'd have to check the exact implementation [VERIFY] — and that gets stored. What I haven't done yet is make that rating *do* anything downstream. Feed it back into what content surfaces. Weight certain moments. That's where the data model could get genuinely interesting, and it's on the list. But even just having the field there, having the concept of *quality* stored alongside the content — that's a product decision more than a feature. It says: not all moments are equal, and the app should eventually know the difference. The whole codebase is AI-generated. Every line. I think that context matters here because what I'm learning isn't how to code — it's how to describe software clearly enough that good software comes out the other side. The /history page is the first place where I feel like I've described something complex enough to matter, and the output matched what I had in my head. That's a new feeling. I wanted to write it down before it became normal.
Wired up a /history page with filters, ratings, and voice learning — moments and notes now linked to projects, making the data model feel like a real product.
1/ The moment a side project starts feeling like a real product is weirdly specific. For me it was wiring up a /history page and realizing: the data actually *has* structure now. 2/ The page has filters, ratings, and voice learning playback. Fine. But the thing that made it click was simpler: notes and moments are now linked to projects. Not just floating in a list. Attached to something. 3/ Before this, the app stored stuff. After this, the app *knows* what the stuff belongs to. That's the data model maturing. And I didn't architect it — I described what I wanted to Claude, and it figured out the shape. 4/ I'm a beginner. I don't think in foreign keys and join tables. I think in "I want to click a project and see all the moments from that project." Turns out that's enough of a spec. 5/ The /history page also has ratings on learning moments. Which means the app is starting to model *quality*, not just quantity. That feels like a product decision, not a feature. [VERIFY whether ratings feed into anything downstream yet] 6/ 100% of this code was written by Claude. I wrote zero lines myself. The interesting question isn't whether that's "real" — it's what it means to build something you couldn't have built otherwise. I'm still figuring that out. 6/
Built per-project dashboard tabs with pill-style UI and an 'all-actioned' colour cue — first real UX polish pass on the product.
This week I hit a small milestone that felt bigger than the commit messages suggest: the first time I looked at my own product and asked "does this actually feel good?" instead of just "does this work?" The answer was: not quite. So I described what was wrong to Claude and watched it fix things I couldn't have coded myself. Here's what landed. The dashboard now has per-project tabs — pill-style, clickable, clean. If every moment in a project has been actioned, the tab flips to a different colour. It's a tiny feedback cue, but it's the kind of thing that makes a UI feel like it was made by someone who thought about it, rather than assembled from parts. The second piece was embarrassingly overdue: project names were showing up everywhere as "owner/repo" — the full GitHub-style string. Stripping the owner prefix down to just the repo name is a one-line kind of fix in concept, but it touches a lot of display logic. Claude handled it across the codebase in one pass. Both commits landed within seven minutes of each other. What I keep noticing is that the rhythm with Claude isn't "write code, run code, debug code." It's closer to "notice something that feels off, describe the feeling, review the result." The bottleneck isn't typing or syntax — it's developing enough taste to spot what's wrong in the first place. That might sound like a trivial reframe, but for someone who isn't a programmer, it's actually clarifying. I'm not pretending to be an engineer. I'm doing something more like product direction: deciding what matters, describing it precisely, and iterating on the output. The whole codebase is 100% AI-generated — that's not a caveat, it's the premise of what I'm building toward. I want to find out how far that premise goes. Can a non-coder ship something people actually pay for, using only taste and clear communication as inputs? This UX pass was the first time the product started to answer that question with something other than "technically yes." It started to feel like a real thing. More to do. The tabs are a start, not a finish. But the threshold from "functional" to "considered" is one I didn't expect to feel so distinct — and now that I've crossed it once, I know what I'm aiming for on the next pass.
Built per-project dashboard tabs with pill-style UI and an 'all-actioned' colour cue — first real UX polish pass on the product.
1/ My AI-generated codebase just got its first real UX polish pass. Pill-style tabs. Per-project filtering. A colour cue that flips when every item in a project is actioned. I didn't write a single line of it. Here's what that process actually looks like. 2/ The feature: a dashboard that groups your build-in-public moments by project, with tabs you can click between. Sounds simple. But "simple" is where you learn the most when you're a non-coder shipping with Claude. 3/ The first commit added the tabs and the colour cue logic — when every moment in a project is actioned, the tab shifts colour. A small thing, but it's the kind of feedback loop that makes a UI feel *alive* rather than just functional. 4/ The second commit cleaned up something that had been bugging me: project names were displaying as "owner/repo" everywhere. Stripping the owner prefix sounds trivial but it's the difference between a UI that feels hacked together and one that feels considered. 5/ Both commits landed within 7 minutes of each other. That's the rhythm I'm finding with Claude — describe what feels off, get a fix, immediately spot the next rough edge. No IDE. No syntax knowledge. Just: "this looks wrong, here's why." 6/ This was the first time I sat with the product and asked "does this actually feel good to use?" rather than "does this technically work?" That shift — from functional to considered — might be the most important threshold for a solo product. 7/ The whole codebase is 100% AI-generated. I'm learning what it means to be a builder without being a programmer. Still figuring out where that line is. If you're watching this unfold, I'll keep posting the honest version — including when it breaks.
Deleted db.ts entirely after porting the DB layer to the Supabase JS client, including hunting down a timestamp parser bug caused by Postgres ISO string format differences.
I deleted db.ts this week. The whole file. Ported the database layer to the Supabase JS client, confirmed things mostly worked, and removed the file entirely. It felt disproportionately satisfying for something that wasn't really a feature. A bit of context: this whole codebase is 100% AI-generated — I write the prompts, Claude writes the code. So db.ts wasn't something I had deep personal attachment to. But it still represented a lot of accumulated decisions, and replacing it wholesale with the Supabase client felt like real progress toward a simpler architecture. The port itself went smoother than I expected. The Supabase JS client is well-documented enough that Claude had a solid handle on the API surface, and most of the translation was mechanical. I was actually feeling good about it. Then I hit the timestamp bug. Postgres, when returning datetime values through the Supabase client (or maybe just through the underlying driver — I haven't fully pinned this down [VERIFY]), formats timestamps as "2026-05-25 20:36:59" — with a space, not a "T". My parser was written assuming the standard ISO 8601 format with the T separator. So "2026-05-25T20:36:59" would parse fine. The Postgres version would not, and would not complain loudly about it either. It just returned bad data. I spent a while confused about why anything involving dates was behaving strangely before I actually logged the raw value coming back from the database and saw the space sitting there. One character. The fix was trivial. The hunt was not. The thing I keep taking away from bugs like this: when you replace a whole layer at once, the logic usually ports okay. It's the serialization edges — the format assumptions baked into parsers, the tiny shape-of-the-data things — where bugs are waiting quietly. I'd have caught this faster if I'd thought to check "what does the raw response actually look like" before assuming the formats matched. Still learning. Still shipping. The file is deleted, the timestamps parse correctly, and the codebase has one less moving part than it did last Sunday.
Deleted db.ts entirely after porting the DB layer to the Supabase JS client, including hunting down a timestamp parser bug caused by Postgres ISO string format differences.
1/ I deleted an entire file this week and it felt better than shipping a feature. 2/ The file was db.ts — a custom database layer I'd built up over time. This week I ported everything to the Supabase JS client and just... removed it. 100% AI-generated code, gone. That's a weird feeling. 3/ The port itself was mostly mechanical. Claude knew the Supabase client API well enough that most of the rewrites were straightforward. What I did NOT expect: a timestamp bug waiting on the other side. 4/ Postgres returns ISO strings with a space instead of a "T" — "2026-05-25 20:36:59" not "2026-05-25T20:36:59". My parser assumed the T. Everything that touched a date silently broke. 5/ I don't fully understand why Postgres formats it that way by default [VERIFY — may depend on client/driver settings], but I know it burned maybe an hour before I found it. The fix was one character. The hunt was not. 6/ Takeaway as a beginner: when you swap out an entire layer, assume *something* will break at the data format level. The logic might port fine. The serialization edges won't. 7/ The codebase is still 100% AI-generated. I just know where the bodies are buried now. /
Abandoned an in-process scheduler mid-build and pivoted the entire persistence layer to Supabase + Vercel — a same-day architectural U-turn.
On Sunday afternoon I was mid-build on a scheduler — the kind that lives inside the app process, managing job queues and timing in code I can read but don't fully own yet — and I stopped. Closed the file. Deleted the work. And by the time I pushed two commits three minutes apart, the entire persistence layer had pivoted to Supabase and Vercel. Same day. The decision took longer than the execution. Here's what actually happened: I kept writing code (well, kept prompting Claude to write code) that was building *around* the real problem instead of solving it. The in-process scheduler was accumulating complexity — queue logic, retry handling, timing edge cases — and none of it was the thing I was trying to ship. It was infrastructure I'd need to understand, maintain, and eventually debug at bad moments. The honest difficulty of being a beginner here isn't that I don't know how to build things. It's that I don't have the instincts yet to know when complexity is justified and when it's a trap I wandered into by inertia. Experienced engineers probably feel that wrongness in their gut after a day or two. I felt *something*, but I wasn't sure I trusted it enough to blow up the work. So I asked Claude to pressure-test the architecture. Not "write me a better version" — genuinely interrogate whether the approach made sense given where the project is. That conversation confirmed what my discomfort was pointing at: managed infrastructure is the right call when you can't fluently debug the thing you built. Supabase handles persistence. Vercel handles deployment. Neither requires me to understand their internals to use them safely. What I keep turning over is how the U-turn felt in the moment versus how it reads in the commit history. Mid-session it felt like losing ground — like I'd wasted hours on a dead end and was now starting over. But the two commits at 18:40 and 18:43 tell a different story: a clean schema migration, a project link, forward motion. The abandoned scheduler doesn't appear anywhere in the log. It just... stopped existing. That's what fast iteration is actually supposed to look like, I think. Not "never go down wrong paths" — that's not available to anyone, definitely not to a beginner. It's catching the wrong turn early enough that the cost is a few hours and a decision, not a half-built system you're afraid to touch. [VERIFY: confirm schema migration is complete and no scheduler code was left partially wired in before marking this closed.]
Abandoned an in-process scheduler mid-build and pivoted the entire persistence layer to Supabase + Vercel — a same-day architectural U-turn.
1/ Mid-build, I stopped, deleted the work, and changed the entire architecture. Same day. No second-guessing it overnight. Here's why. 2/ I was building a scheduler that runs inside the app process itself. Queuing jobs, managing timing, the whole thing — in code Claude wrote, that I mostly can't read fluently yet. 3/ The deeper I got, the more I felt it: I was building infrastructure *around* the real problem instead of solving it. The scheduler was becoming load-bearing complexity I didn't understand. 4/ So I pivoted the whole persistence layer to Supabase + Vercel. Killed the in-process scheduler entirely. Ran an initial schema migration and linked the project. Two commits, ~3 minutes apart. The decision took longer than the execution. 5/ The honest thing I noticed: as a beginner, I don't have the instincts yet to know *when* complexity is justified vs. when it's a trap I wandered into. I had to ask Claude to pressure-test the approach before I trusted my own discomfort. 6/ It confirmed what I suspected — managed infrastructure (Supabase handles persistence, Vercel handles deployment) is the right call for someone who can't debug their own scheduler at 2am. 7/ The U-turn felt like failure in the moment. Looking at the commits now, it reads like the opposite — catching a wrong turn early is exactly what fast iteration is supposed to look like. 7/
Parallelised Pass 2 and Pass 3 in the property analysis pipeline to fix a UX dead-zone where the app appeared frozen — also added rotating hint text to cover the thinking gap
For about 60-90 seconds in the middle of every property analysis, my app looked completely dead. No spinner, no progress, no feedback. Just a white-ish void while the AI thought. The screen wasn't broken. The SSE connection was open and healthy. What was happening is that Claude — the AI doing the analysis — doesn't stream recommendation cards as it generates them. It runs its full thinking phase, then emits all the tool_use outputs in one burst, typically within a 400ms window at the end. Three cards, all at once, after a minute of silence. The infrastructure was correct. The model just works that way. So I had a choice: accept the gap, or design around it. The first fix was honest about what it was — a band-aid. I added rotating hint text that cycles on a 4.5s timer during the wait, plus an elapsed-time line that says "typically 60-80s for this step." It doesn't shorten the wait. But it turns a frozen screen into one that at least signals something is happening. That shipped first because it was fast and it made the experience meaningfully less alarming. The real fix took a bit more thought. I redesigned Pass 2 into two stages. Pass 2a asks the model for 3-6 free-form category seeds — what kinds of recommendations make sense for this property — and that takes 5-15 seconds. Then Pass 2b fans out N parallel calls simultaneously, one per seed, each producing one complete recommendation card. Because they fire in parallel, each card lands at the client the moment that call resolves. Instead of a 64-second wait followed by a burst, you get cards trickling in progressively as they complete. The tradeoff is cost: roughly 1.5-2.5× the previous Pass 2 spend, around +£0.10 per analysis. At MVP volumes that's about +£5/month. I decided that was worth it for what it does to the feel of the product, and moved on. While I was in there, a smoke test — I've been calling it the "Vicky-test" after the first real user run that exposed it — caught two other problems. Pass 3 was silently returning empty results on properties with 6 ideas because my token budget estimates were badly wrong. I'd assumed each idea needed 800-1200 output tokens; observed behaviour suggested 1800-2500. Doubling the per-idea allocation fixed it. [VERIFY — based on empirical observation, not model documentation] The second problem was Pass 4 getting squeezed out entirely. The pipeline ran sequentially: Pass 1 → 2 → 3 → 4. When Pass 3 ran long on a complex property, it ate into Vercel's 300-second wall-clock limit and Pass 4 simply never got to persist its results. The fix was to run Pass 3 and Pass 4 in parallel using Promise.allSettled — they take different inputs and don't depend on each other, so there's no reason they were sequential in the first place. Wall-clock time is now max(Pass 3, Pass 4) instead of Pass 3 + Pass 4. One thing worth saying plainly: every line of code here was written by Claude. I'm not a developer. I read the diffs, I wrote the specs, I described what I wanted and why, and I directed the fixes. The whole point of this project is to see how far that approach can actually go. This week it went far enough to ship parallel async pipelines, progressive card rendering, and rotating UX copy. The frozen screen is gone. On to the next thing.
Parallelised Pass 2 and Pass 3 in the property analysis pipeline to fix a UX dead-zone where the app appeared frozen — also added rotating hint text to cover the thinking gap
1/ My app had a 60-90 second window where it looked completely frozen. Nothing on screen. No feedback. Just... silence. Here's how I fixed it — in two stages, because the first fix was always a band-aid. 2/ The root cause: the AI (Claude) doesn't stream recommendation cards one by one. It thinks for ~64 seconds, then emits all 3 cards in a 405ms burst. The SSE infrastructure was fine. The model just works that way. The gap was real and there was no hiding it. 3/ Fix A: rotating hint text. While the app waited, it now cycles through short copy lines on a 4.5s timer — "Crunching the numbers…" style — plus "typically 60-80s for this step" on the elapsed counter. Not a real fix. But it turns a frozen screen into a screen that feels alive. 4/ Fix B (the real one): split Pass 2 into two stages. Pass 2a asks the model for 3-6 category seeds. Takes 5-15s. Pass 2b fires N parallel calls — one per seed — simultaneously. Each call emits its card the moment it resolves. Cards land progressively, not in a burst. 5/ The cost: ~1.5-2.5× the previous Pass 2 spend. About +£0.10 per analysis, ~+£5/month at MVP volumes. That felt worth it for the UX difference. Made the call, moved on. 6/ Separately: a smoke test ("Vicky-test") caught that Pass 3 was silently failing on 6-idea properties. Turned out my token estimates were wrong — each idea needs ~1800-2500 output tokens, not the 800-1200 I'd assumed. Doubled the per-idea allocation. [VERIFY exact token counts — based on observed behaviour, not model docs] 7/ And Pass 4 was getting squeezed to death. The old sequence was Pass 1 → 2 → 3 → 4, so when Pass 3 ran long it ate into Vercel's 300s wall-clock limit and Pass 4 never persisted. Fix: run Pass 3 and Pass 4 in parallel with Promise.allSettled. They don't share inputs. Wall-clock is now max(3,4) instead of 3+4. 8/ All of this is 100% AI-generated code — Claude wrote every line. I'm not a developer. I described the architecture, read the commit diffs, and directed the fixes. That's the whole experiment: how far can a non-coder get building something real this way? 9/ Apparently: far enough to ship parallel async pipelines and rotating UX copy in the same week. Still a lot to learn. But the frozen screen is gone./
Switched the Property Reconfig App's AI render from text-to-image to FLUX Kontext Pro img2img (using the actual 'Before' photo), then spent several commits fighting floor plan annotation coordinates that kept escaping their bounding box
For weeks the "After" renders in my property reconfiguration app were technically impressive and completely wrong. The AI would take a prompt describing a room layout change and generate a photorealistic image — of a room that didn't exist. Wrong walls. Invented windows. Doors in places no door had ever been. It looked good in isolation and was useless as a before/after comparison. The disconnect became undeniable this week when I shipped a proper Before | After UI that showed the photos side by side. Putting them next to each other made the hallucination obvious. The "After" wasn't a reconfigured version of the actual room — it was a different room entirely. The fix turned out to be simpler than I expected, once I understood what the problem actually was. Text-to-image models generate from scratch. They have no knowledge of the room they're supposed to be editing. What I needed was img2img — feed the model the real Before photo, and have it edit that rather than invent something new. FLUX Kontext Pro (via Replicate) does exactly that. One commit to wire it in: when a before_url is present in the request, route to Kontext; when it isn't, fall back to the old flux-1.1-pro text path. Same API token, same cost ballpark (roughly £0.03–0.04 per render [VERIFY]). I set aspect_ratio: 'match_input_image' so the After comes back the same dimensions as the Before — they actually read as a pair now. The graceful degradation matters too. Not every room in a property has a photo. When the Before photo isn't available, the old text path still fires and the UI caption adapts. I didn't want to break the sparse-data case just to fix the well-photographed one. What I didn't anticipate was how much fight the floor plan annotations would put up. That took four more commits — about 13 hours from first fix to working state. Coordinates kept escaping their bounding box, landing outside the diagram entirely. The eventual solution: let the model declare its own drawing_bounding_box, then have the server clamp every coordinate to stay inside it. Once the source of truth was explicit, the clamping was reliable. The thing I keep coming back to: I wouldn't have reached for img2img on my own. I didn't know the term, didn't know Kontext existed, didn't know the distinction mattered. Claude both flagged the approach and wrote all the code to implement it. My contribution was recognising that the broken renders were broken — that the before/after pairing looked wrong — and being able to describe what "right" would look like. The whole codebase is AI-generated. That's not a disclaimer. It's the actual story of how this gets built. A beginner who can see what's wrong, paired with a model that knows what to try next.
Switched the Property Reconfig App's AI render from text-to-image to FLUX Kontext Pro img2img (using the actual 'Before' photo), then spent several commits fighting floor plan annotation coordinates that kept escaping their bounding box
1/ The AI renders in my property app were hallucinating doors, windows, and walls that didn't exist in the actual room. Switching to FLUX Kontext Pro's img2img — feeding it the real "Before" photo — fixed that in one commit. Then four more commits to wrangle the floor plan. 2/ The old flow: write a text prompt → flux-1.1-pro generates a brand-new room from scratch. Result: architecturally plausible, completely fictional. The AI invented a room that looked nothing like the one being reconfigured. 3/ The fix: when a Before photo exists, route through FLUX Kontext Pro (img2img). It takes the actual photo as input and edits it, rather than hallucinating from text alone. Same vendor (Replicate). Same cost — roughly £0.03–0.04 per render. [VERIFY exact Kontext pricing] 4/ One detail I liked: setting aspect_ratio: 'match_input_image' means the After render is the exact same shape as the Before photo. They read as a genuine pair. Not before/after from two different realities. 5/ When there's no Before photo available — sparse room classifications, ideas for rooms not in the gallery — it degrades gracefully to the old text2img path. Claude wrote the branching logic. I described the two cases. It handled the routing. 6/ Then the floor plan broke. Annotation coordinates kept escaping their bounding box — markers landing outside the diagram entirely. Four commits across about 13 hours to nail it down. Final fix: the model now declares the drawing_bounding_box itself, and the server clamps all coordinates to stay inside it. 7/ Honest take: I wouldn't have known to use img2img here. Claude flagged that preserving the input image's structure was possible with Kontext. I just had to understand *why* the old approach was wrong — and that took actually seeing the broken renders side by side. The codebase is 100% AI-generated. That's not a caveat. It's the point.
Supabase RLS silently rejected all writes for a week because there was no INSERT policy on the table — the fix was one line, but finding it required documenting it as a reusable lesson
For about a week, every write to my sold_comps table in Supabase was silently disappearing. No error. No failed request. Just zero rows, every time. I'm building Stashery — a real estate comps tool — and the entire codebase is written by Claude. I'm not a developer; I navigate, Claude codes. That context matters here, because this bug taught me something about the gap between "the AI did it right" and "the whole system is configured right." Here's what happened. Early in the project, Claude set up a Supabase table with Row Level Security enabled and added a SELECT policy so users could read their data. That part was correct. But nobody — me or Claude — added an INSERT policy. And in Supabase, RLS default behavior is deny-everything. If there's no policy for an operation, that operation is rejected. Silently. The Supabase client returns `{ error: null }`, the response looks fine, and your row just never lands. We had a second bug running in parallel: the function that called the cache write was discarding the return value entirely. So even the soft error signal Supabase does sometimes surface was going straight into the void. Two bugs, zero signal. The table stayed empty for a week while I assumed the writes were working. What finally cracked it was a behavioral clue, not an error message. I noticed that every cache lookup was triggering a fresh Apify actor run — our external data provider. If the cache writes had been working, the second lookup should've been a hit. Two consecutive lookups, two Apify runs. That was the tell. Once I had the right diagnosis, the fix was almost embarrassingly small: one SQL migration adding an INSERT policy for authenticated users. Five lines. A week of confusion, five lines. The second fix was making the write function actually surface errors — log them with a prefix I can search in Vercel logs, without breaking the user-facing response. Fresh data still gets returned even if the cache write fails. But now I'll know it failed. The part I want to hold onto isn't the fix itself, though. It's what we did after: documented the whole thing as Lesson L11 in claude.md, the file Claude reads at the start of every session. Symptom, root cause, fix recipe, and a SQL query to audit any other RLS-enabled tables in the schema that the app writes to. [VERIFY the audit query catches all edge cases in larger schemas.] That last part — the sibling-bug audit — is the thing I'd have skipped if I were just trying to ship. But the commit message for the fix literally says "Lesson L11 to follow once the fix is verified on prod." We treated the documentation as part of the work, not an afterthought. If you're using Supabase with RLS and you're early in a project: go run `SELECT * FROM pg_policies WHERE tablename = 'your_table'` right now. For every table your app writes to. Silent failures are the worst kind because they let you believe everything is fine.
Supabase RLS silently rejected all writes for a week because there was no INSERT policy on the table — the fix was one line, but finding it required documenting it as a reusable lesson
1/ My app was silently throwing away every database write for a week. No error. No warning. Zero rows saved. Just... nothing. Here's the one-line fix, and why it was so hard to find. 2/ I'm building Stashery with 100% AI-generated code (Claude writes everything, I navigate). We added a "sold comps" cache table in Supabase early on. Supabase has Row Level Security. I turned it on. Claude added a SELECT policy. We moved on. 3/ Nobody added an INSERT policy. RLS default behavior: if there's no policy for an operation, that operation is *denied*. Silently. The write reaches the database, Supabase returns { error: null }, and zero rows land. The wire says success. The table stays empty. 4/ We also had a second bug compounding it: the function that wrote to the cache was discarding the return value entirely. So even if there HAD been an error surfaced, we'd never have seen it. Two bugs, zero signal. 5/ The symptom that finally cracked it: our cache table always had zero rows, but Apify (our data provider) showed successful actor runs. Two consecutive lookups → two Apify runs. If the cache write had worked, the second one should've been a cache hit. That's when we knew. 6/ The fix was one SQL migration: CREATE POLICY "allow_insert_for_authenticated" ON sold_comps FOR INSERT TO authenticated WITH CHECK (true); One policy. One migration file. A week of confusion over. 7/ We also made the write function actually surface errors — log them with a [comps] prefix for Vercel log triangulation, without failing the user-facing request. Silent failures are the worst failures. If something breaks, I want a breadcrumb. 8/ The real lesson got documented as L11 in our claude.md — the file Claude reads at the start of every session. Now every future session knows: audit pg_policies before assuming RLS tables are writable. Check sibling tables too. That's how a beginner makes a bug permanent knowledge. /
Pivoted the app's entire database layer from SQLite (in-process) to Supabase + Vercel the same day it was built — deleted db.ts and rewrote the DB layer, including fixing a timestamp parser for Postgres ISO strings
I built a SQLite database layer and deleted it the same day. Not because it was broken — it worked fine locally. I deleted it because I finally asked the question I should have asked before writing a single line: "does this actually run on Vercel?" The answer was no. Vercel is serverless. Functions spin up cold, run, and die. There's no persistent filesystem sitting there holding your SQLite file between requests. The whole db.ts file that Claude had carefully written — the schema, the query helpers, the connection logic — was built on an assumption that turned out to be wrong for the deployment target I'd already decided on. So at 6:40pm I had a working local database layer. By 8:37pm it was gone, replaced with a Supabase + Vercel setup, new schema migration, and a rewritten DB layer using the Supabase JS client. I want to be clear about something: I did not write any of this code. The original SQLite layer was 100% AI-generated (by Claude). So was the replacement. So was the migration. My job was to describe what I needed, catch the moments where something smelled wrong, and ask the right questions. I'm a beginner. That's the whole point of this project — figuring out how far you can get building real software without already knowing how to build real software. The part that actually cost me time wasn't the pivot itself. It was timestamps. SQLite and Postgres return datetime values in different formats. The parser Claude had written for the original layer expected SQLite's format. When we switched to Postgres, every timestamp that came back from Supabase was getting parsed wrong — silently, no errors thrown, just subtly incorrect date values. The kind of bug that would have been invisible until something time-sensitive broke in production. Once we identified what was happening, the fix was straightforward. But "once we identified" is doing a lot of work in that sentence. Finding it took longer than fixing it. The lesson I'm carrying forward: ask the deployment question before you design the data layer. Not after you've built it. Not while you're building it. Before. "Where is this actually going to run, and what does that environment assume about persistence?" is a five-second question that would have saved me two hours. I didn't ask it. Now I will.
Pivoted the app's entire database layer from SQLite (in-process) to Supabase + Vercel the same day it was built — deleted db.ts and rewrote the DB layer, including fixing a timestamp parser for Postgres ISO strings
1/ Built a SQLite database layer at 6pm. Deleted the whole thing at 8pm. Same day. Same commit session. Here's why — and what broke when I switched to Supabase. 2/ The original plan: SQLite running in-process, simple db.ts file, no external dependencies. Claude wrote the whole thing. It worked. And then I asked: "will this actually run on Vercel?" 3/ Short answer: no. Vercel is serverless. In-process SQLite doesn't survive that — no persistent filesystem, functions spin up and down, your db.ts is basically a very pretty lie. [VERIFY: edge runtime specifically; standard Node runtime on Vercel may behave differently] 4/ So within the same evening: deleted db.ts, linked a Supabase project, migrated the schema, rewrote the entire DB layer against the Supabase JS client. All of it AI-generated. I didn't write a line of this myself. 5/ The thing that actually broke: timestamps. SQLite returns timestamps one way. Postgres ISO strings come back differently. The parser Claude originally wrote assumed the SQLite format — silent wrong dates everywhere. 6/ The fix was a one-line change once we found it. The finding took longer. That's usually how it goes: the bug is small, the search is not. 7/ Takeaway if you're also building with AI on serverless infra: ask the deployment question *before* you build the data layer, not after. I didn't. It cost me an evening. Now I know to ask.
Built an entire app (Build-in-Public Studio) from scaffold to working product in a single day — Stages 1–8 in roughly 6 hours, all AI-generated code
This past Sunday I built an entire app — scaffold to working product — in about six hours. I didn't write a single line of code myself. Every file, every function, every SQL query was generated by Claude. The app is Build-in-Public Studio, the tool I'm using to write this post. The idea is simple: point it at your git history and your handwritten notes, and it identifies moments worth posting about, then drafts the content for you. Two formats per moment — an X thread and an Indie Hackers long-form post. Here's what the day actually looked like. I started a bare scaffold at 2:30 AM (I know). By 2:53 AM there was a working /notes page backed by SQLite. By 3:29 AM it was pulling real commits from GitHub via Octokit and caching them locally. By 4 AM, Claude was drafting posts using tool_use. By 4:26 AM there was a dashboard with moment cards you could edit, regenerate, approve, or reject. An hour later: a copy-open flow that nudges you to record whether you actually published. An hour after that: a /history page with filters, ratings, and a feedback loop that teaches Claude your preferences over time. By 8:30 AM the UI had pill-style project tabs and cleaned-up project names. Eight stages. Six hours. Zero code written by me. I'm a beginner. Not in a false-modest way — I genuinely don't fully understand most of what's running in this codebase. I couldn't have written the Octokit integration from scratch or hand-rolled the SQLite schema with the right foreign keys. What I could do was describe clearly what I wanted, push back when something felt wrong, and keep the momentum going between prompts. That's the actual skill I'm developing: how to think precisely enough about a problem that an AI can build it for you. It's a different skill than coding, and I'm not sure it gets talked about honestly enough in build-in-public spaces. Most posts lead with the shipping and bury the "oh by the way Claude wrote it." I want to flip that. The AI-generated nature of the codebase isn't a footnote here — it's the whole point. I don't know what breaks in Stage 9. That's what I'm shipping next.
Built an entire app (Build-in-Public Studio) from scaffold to working product in a single day — Stages 1–8 in roughly 6 hours, all AI-generated code
1/ I built a full working app today. Scaffold to shipped product, 8 stages, ~6 hours. I wrote zero lines of code. Every single line was generated by Claude. 2/ The app is called Build-in-Public Studio. It's the tool I wish I had: feed it your git commits and handwritten notes, it identifies "moments" worth posting about, then drafts the posts for you. 3/ Here's what went live in those 6 hours: - SQLite note-taking with a /notes page - GitHub sync via Octokit, commits cached locally - Claude drafting posts using tool_use - A full dashboard: moment cards, edit / regenerate / approve / reject 4/ Then a copy-open flow that prompts "did you publish?" and tracks posted state. Then a /history page with filters, ratings, and a voice-learning loop that feeds back into Claude. 5/ I'm a beginner. I don't know how most of this code works under the hood. What I did know was what I wanted the thing to *do* — and I could describe that clearly enough for Claude to build it. 6/ The credibility hook of this project isn't that I'm a great developer. It's the opposite: I'm not one, and it doesn't matter anymore. That's the thing I'm building in public. 7/ Stage 9 starts tomorrow. Curious what breaks first. /
Switched the Property-Reconfig-App AI render from text-to-image to FLUX Kontext Pro img2img, then spent multiple commits wrestling floor-plan annotation coordinates that kept escaping their bounding box.
The AI renders in my property reconfiguration app had a problem I'd been ignoring: they were completely hallucinating the rooms. You'd upload a photo of your actual living room — bay window on the left, chimney breast on the right — and the AI would generate an After image showing a totally different space. New walls. Different window positions. A room that had never existed. It looked plausible in isolation, but the moment you put Before and After side by side, the disconnect was obvious. The fix was switching from text-to-image to img2img. Instead of handing FLUX a prompt and letting it invent from scratch, I now hand it the actual Before photo *plus* a prompt, and it edits the real room. The model I switched to is FLUX Kontext Pro, via Replicate — same vendor I was already using, same API token, roughly the same cost per render (around £0.04). The one detail that made the Before/After pairing feel right was setting `aspect_ratio: 'match_input_image'`, so the output is literally the same shape as the input. They read as a true pair now. That part — the actual feature — was one commit. Then I spent four more commits on floor plan annotation coordinates that wouldn't stay inside the floor plan. The app overlays markers on a floor plan drawing: room labels, zone boundaries, that kind of thing. After the img2img switch, those markers kept drifting outside the drawing area. Into the margins. Off the edge entirely. My first fix was clamping the coordinates server-side to the known bounds of the drawing. Still escaping. Second fix was adding explicit instructions to the prompt — don't place markers in margins, don't regenerate the camera angle. Closer, but not solid. The fix that actually held was a change in who defines the boundary. Instead of the server asserting "here are the bounds, stay inside them," the model now declares its own `drawing_bounding_box` as its first output. The server then clamps every subsequent marker coordinate to that declared box. Let the model define the space it's working in, then enforce the constraint against that definition. I don't write any of this code myself — the whole codebase is Claude-generated. But the debugging loop isn't really about code. It's about being precise enough in your description of the problem that the next attempt is actually different from the last one. "Markers escape the bounding box" is an observation. "Let the model declare the box and clamp to it server-side" is a diagnosis. Getting from one to the other across four commits and most of a day — that's the part that requires a human in the loop, at least for now. The takeaway, if there is one: when an AI model keeps violating a spatial constraint you're imposing on it, try letting it define the constraint itself first. You might be fighting the model less than you think, and fighting a mismatch between your coordinate system and its mental model of the space.
Switched the Property-Reconfig-App AI render from text-to-image to FLUX Kontext Pro img2img, then spent multiple commits wrestling floor-plan annotation coordinates that kept escaping their bounding box.
1/ The AI renders in my property app were generating brand-new rooms from scratch. Wrong doors. Wrong windows. Walls that never existed. Classic hallucination problem. I fixed it by switching to img2img — and then spent 4 commits fighting coordinates that wouldn't stay inside a box. 2/ The old flow: send a text prompt → FLUX generates a completely invented room. The new flow: send the *actual Before photo* + a prompt → FLUX Kontext Pro edits the real room instead of dreaming up a new one. Same vendor (Replicate). Same cost (~£0.04/render). Wildly better results. [VERIFY once you have more user data] 3/ The key detail that made it work: `aspect_ratio: 'match_input_image'` Before and After are now literally the same shape. They read as a true pair instead of two unrelated images sitting next to each other. 4/ Then came the floor plan annotation coordinates. The AI was placing markers — room labels, zone boundaries — and they kept escaping outside the drawing area. Into margins. Off the page entirely. 5/ Fix attempt 1 (68ecc0b): clamp the coords server-side to the floor plan bounds. Tighten the Kontext prompt directive. Still escaping. 6/ Fix attempt 2 (90bd7ad): ban margin markers explicitly in the prompt. Ban scene/camera regeneration so Kontext stops treating it like a photo shoot. Getting closer. 7/ Fix attempt 3 (3c1f59b): the model now *declares* its own drawing_bounding_box first. The server clamps every marker coordinate to that box afterward. Let the model define the space, then enforce it. That's the one that held. 8/ Final cleanup (5e000fa): dropped a fixture list that was leaking into the Kontext prompt (confusing it), added reliable floor plan polling, and wired in auto-retry on the AI render. 5 commits across ~17 hours for one feature. The feature took 1. The coordinates took 4. 9/ I don't write any of this code myself — it's 100% Claude. But I do have to understand what's wrong well enough to describe it. "Markers escape the bounding box" is a description. "Let the model declare the box, then clamp to it" is a diagnosis. Getting from one to the other is the actual work./
Discovered that a Supabase RLS-enabled table with no INSERT policy silently rejects writes — no error, no warning, data just disappears — and logged it as a named lesson (L11) in the project docs.
I spent a chunk of this week debugging a cache that was never caching anything, and the thing that broke it was so quiet I nearly missed it entirely. Quick context: I'm building Stashery, and the entire codebase is written by Claude. I direct, Claude codes. I'm not a professional developer — that's kind of the point. I'm learning by doing, and I document every hard lesson in a file called claude.md so the AI (and future-me) doesn't repeat them. This week's lesson became L11. The feature: when a user looks up sold comps, the app calls an Apify actor to fetch them, then writes the results to a sold_comps table in Supabase as a cache. Next time someone looks up the same property, we hit the cache instead of running the actor again. The symptom was strange. Apify showed successful actor runs. The API was returning real data. But the table stayed empty. And because it stayed empty, every single lookup triggered a fresh actor run — meaning the same property was getting fetched twice in a row with nothing in between. No errors anywhere. Just wrong behavior. The diagnosis took a while. Eventually Claude helped me trace it back to two bugs sitting on top of each other. Bug one: sold_comps had Row Level Security enabled from the initial schema migration, with only a SELECT policy. No INSERT policy. Supabase's default-deny model means "no policy" is not "open access" — it's a hard block. The write was reaching the database, getting silently rejected, and returning `{ error: null, rows: 0 }`. Not an error. Just nothing. Bug two: the function calling writeComps was discarding the return value entirely. So even `{ error: null, rows: 0 }` was going nowhere. The failure had no surface area at all. Two quiet problems, perfectly compounding each other. The fix was a new migration — `0005_sold_comps_insert_policy.sql` — adding an INSERT policy for any authenticated user. And then actually capturing the writeComps result, logging errors with a `[comps]` prefix so I can triangulate them in Vercel logs later. Errors don't fail the user-facing request; fresh data still comes back. But now at least I'd know. What I wrote into L11, and what I'd tell anyone starting with Supabase: RLS is default-deny, which is the right default for security, but it means a missing policy doesn't produce noise. It just produces silence. If your writes are disappearing and you can't figure out why, run an audit against pg_policies before you question your application logic. The claude.md file also now has a sibling-bug query — a SQL snippet that finds any other RLS-enabled tables in the schema that the app writes to, so I can check they all have the right policies before something else goes quiet on me. [VERIFY: query accuracy against your specific Supabase schema setup] The honest takeaway: the most dangerous bugs aren't the ones that throw errors. They're the ones that succeed politely and lie about what they did.
Discovered that a Supabase RLS-enabled table with no INSERT policy silently rejects writes — no error, no warning, data just disappears — and logged it as a named lesson (L11) in the project docs.
1/ Supabase RLS will silently eat your writes and not tell you. No error. No warning. Just... zero rows. The data disappears. Took me an embarrassingly long time to figure out why. 2/ I'm building Stashery with 100% AI-generated code (Claude writes everything, I direct it). This week a feature was broken in a way that made no sense. Apify was running fine. The API was returning fresh data. But the cache table stayed empty. 3/ The symptom: two back-to-back lookups triggered two separate Apify actor runs. The cache was supposed to prevent that. It clearly wasn't writing anything. The code "succeeded" at every step. No thrown errors anywhere. 4/ Root cause: the sold_comps table had RLS enabled, but only a SELECT policy from the initial schema migration. No INSERT policy. Supabase's default-deny behavior means: no policy = no access. The write hit the wire, got silently rejected, returned { error: null, rows: 0 }. 5/ There was a second bug compounding it. The function that called writeComps was just... discarding the return value. So even if there had been an error, it would have gone nowhere. Two quiet failures, perfectly stacked. 6/ The fix was two things: — a new migration adding an INSERT policy for authenticated users on sold_comps — capturing the writeComps return value and logging errors with a [comps] prefix for Vercel log triangulation The user-facing lookup still returns data either way. Errors log, don't explode. 7/ I documented this as Lesson L11 in the project's claude.md. The lesson includes a sibling-bug audit query to find any other RLS-enabled tables the app writes to without an INSERT policy. If you use Supabase RLS: run that audit before you ship, not after. 8/ The thing that gets me is how confident everything looked. Successful HTTP responses. No console errors. Just silently wrong state. When your writes disappear with no noise, check pg_policies before you check your logic. [VERIFY on other Supabase versions/configs] 8/
Pivoted the entire persistence layer from in-process SQLite to Supabase + Vercel the same day it was built — abandoned the in-process scheduler, migrated schema, deleted db.ts, and fixed a Postgres ISO timestamp parser bug.
On Sunday I finished building a persistence layer backed by SQLite. By Sunday evening I had deleted all of it. Not because the code was bad — it wasn't, Claude wrote clean stuff. But I'd built the right thing for the wrong environment. Here's what I didn't fully understand going in: Vercel runs serverless functions. Stateless, ephemeral, spun up on demand and then gone. An in-process SQLite database needs a persistent filesystem. A scheduler that lives in memory needs a process that stays alive. Neither of those things exist on Vercel in the way I'd assumed. So I had a working SQLite layer locally, with its own little in-process job scheduler ticking away, and it would have completely fallen apart the moment it hit production. Silent failures. State that disappears between requests. The kind of bugs that are genuinely hard to diagnose because everything looks fine until it doesn't. The pivot was: Supabase for the database, Vercel's own infrastructure for scheduling, and a full schema migration to wire it all up. I deleted db.ts — the whole file, gone — and Claude rewrote the DB layer against the Supabase JS client. The last thing to fix was a timestamp parser bug. Postgres returns ISO strings in a slightly different format than what the old code expected. Not a big difference — just a "T" and a timezone offset that the parser wasn't handling. But it was enough to cause silent failures on every timestamp read. Claude caught it, fixed it, and I understood what went wrong well enough to explain it here. The thing I keep coming back to: the code was correct, and it was still wrong. Correct for a different set of constraints. When you're learning by building — which is basically the only way I know how — you sometimes have to build the wrong version first to understand why the right version is right. What I'd tell someone earlier in this situation: before you build the persistence layer, spend twenty minutes understanding where your app is actually going to run and what that environment does and doesn't give you. It's not glamorous research. But it's cheaper than a same-day rewrite. The rewrite took a few hours instead of a few days because Claude handled the mechanical parts. That's the real leverage of building this way as a beginner — the cost of being wrong is lower, so you can afford to learn by doing.
Pivoted the entire persistence layer from in-process SQLite to Supabase + Vercel the same day it was built — abandoned the in-process scheduler, migrated schema, deleted db.ts, and fixed a Postgres ISO timestamp parser bug.
1/ I built a full SQLite persistence layer, then deleted it the same day. Not because it was broken. Because I realized it was the wrong tool entirely. 2/ The setup: an in-process SQLite DB running inside the app, with its own scheduler. It worked locally. But "works locally" and "works on Vercel" are two different sentences. 3/ Vercel's serverless functions are stateless and ephemeral. An in-process scheduler — one that lives in memory and ticks on a timer — just... evaporates between requests. SQLite on a serverless host is the same problem. No persistent filesystem. 4/ So same day I finished building it, I pivoted. Swapped SQLite for Supabase. Abandoned the in-process scheduler. Migrated the schema. Deleted db.ts entirely. Claude wrote all of it. I just had to understand enough to know why it was wrong. 5/ The last bug was sneaky: Postgres returns ISO timestamp strings in a format the old parser didn't expect. Not "2026-05-25 20:36:59" — Postgres gives you "2026-05-25T20:36:59+00:00". One wrong assumption in the parser, silent failures everywhere. 6/ The fix was small. The lesson wasn't. When you're building with AI-generated code, the code can be correct and still be wrong — wrong for the environment, wrong for the constraints you haven't fully mapped yet. 7/ I'm a beginner. I didn't know Vercel + SQLite was a bad combination until I'd already built the thing. That's fine. Now I know. And the pivot took one day instead of one week because Claude handled the rewrite. 7/
Built an entire SaaS tool (Build-in-Public Studio) from scaffold to working product in a single day — ~20 commits spanning SQLite notes, GitHub sync, Claude drafting, a real dashboard, and a history page, all 100% AI-generated code.
Yesterday I built a SaaS product in a single day. Start to finish — scaffold, database, GitHub integration, AI drafting, a real dashboard with edit/approve/reject flows, and a history page with a voice-learning loop. About 20 commits between 2:30am and 8am. I wrote none of the code. Claude wrote all of it. I want to be precise about what that means, because "100% AI-generated" gets treated as either a magic trick or a disclaimer depending on who's reading it. For me it's neither. It's just the constraint I'm working under. I'm a beginner. I don't know how to write a GitHub sync with Octokit, or wire up SQLite in a Next.js app, or structure tool_use calls to Claude. But I know what I want the software to do, and I've gotten decent at describing that clearly enough that Claude can build it. The product is called Build-in-Public Studio. The pitch is simple: it watches your git activity and your notes, finds the moments worth posting about, and drafts them for you in your voice — X threads and Indie Hackers posts. The irony that I built it using exactly the workflow it's designed to support is not lost on me. The first real test of the drafting tool was drafting a post about building the drafting tool. The part that still surprises me is the pace. 2:30am was a blank folder. By 4am there was real data persistence and GitHub commits flowing into a SQLite cache. By 4:26am the dashboard had actual moment cards with tabs and actions. By 8am there was a history page feeding ratings back to Claude so it can learn what you like over time. I don't think this pace was possible — for me, specifically — before AI coding assistants. Not because I was slow, but because I would have stopped at every unfamiliar abstraction and spent hours figuring out the right way to do it. Now I describe what I want, Claude proposes an approach, I say whether that sounds right, and we build it. The bottleneck shifted from "can I write this?" to "do I know what I want?" That second question turns out to be harder than it looks. Knowing what you want well enough to describe it unambiguously is a real skill. I'm still learning it. There were moments yesterday where Claude built exactly what I asked for and I realized I'd asked for the wrong thing. That's on me, not the tool. The takeaway, if there is one: the "I can't code" constraint doesn't have to mean "I can't build." It might just mean you need to get very good at the part that comes before the code — knowing what the thing should do, and why, and for whom. That's the skill I'm actually building. The commits are just evidence it's working. [VERIFY: pace of future builds will tell whether yesterday was a fluke or a repeatable pattern]
Built an entire SaaS tool (Build-in-Public Studio) from scaffold to working product in a single day — ~20 commits spanning SQLite notes, GitHub sync, Claude drafting, a real dashboard, and a history page, all 100% AI-generated code.
1/ I built a full SaaS product in a single day. Scaffold to working dashboard. SQLite, GitHub sync, Claude drafting, history page, publish flow. I wrote zero of the code. Claude wrote all of it. Here's what that actually looked like: 2/ 2:30am — scaffold. Dark theme, teal accent, docs wired in. A blank canvas that could become anything. Commit message: "chore: scaffold project." The most deceptive commit message I've ever pushed. 3/ By 3am: SQLite notes page. Save a note, see recent notes. First real persistence. First moment where it felt like a product and not a folder. 4/ 3:29am: GitHub sync via Octokit. Pull commits, cache them in SQLite, debug page to verify it worked. I didn't know what Octokit was a week ago. I still can't write the code. But I understood what it needed to do — and that was enough. 5/ 4:07am: Claude drafting. The tool calls tool_use to call Claude, which drafts post variants, which is the whole point of the product. A tool that uses AI to build a tool that uses AI. I noticed this was funny at 4am. I still think it's funny. 6/ 4:26am: Real dashboard. Moment cards. Tabs. Edit, regenerate, approve, reject. The thing started to look like software a person would actually use. 7/ 7:33am: Copy + Open flow. Did-you-publish prompt. "Posted" state on a card. The last-mile UX stuff that makes something feel finished instead of just functional. 8/ 7:52am → 8:08am: History page. Filters. Ratings. A feedback loop back into Claude so it learns your voice over time. Two commits, 16 minutes apart. I was tired. It shipped anyway. 9/ ~20 commits. One day. One person who can't write code. The whole thing is AI-generated. That's not a caveat — it's the point. The credibility of this tool is that it was built with exactly the workflow it's trying to teach. 10/
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext Pro img2img — using the actual 'Before' photo as input — and spent days fixing the resulting prompt control issues.
There's a version of AI-generated room renders that looks impressive in a demo: vivid, photorealistic, stylistically coherent. And then there's the version a real user cares about — does this look like *my* room? My property reconfiguration app had the first version. The AI would generate a beautiful render of a redesigned space, but it bore no resemblance to the actual property. Same colour scheme, different windows. Different proportions. Different light. Useless for someone trying to visualise what their specific home could look like. So I switched the render pipeline to FLUX Kontext Pro, an img2img model that takes the "Before" photo as a conditioning input. Instead of generating a room from a text description, it edits the existing photo. The same walls, the same windows, the same floor — but with the proposed changes applied on top. In theory, this is exactly what the feature needed. In practice, it opened up a new category of problems I hadn't encountered with pure text-to-image. The model kept doing things it wasn't asked to do: regenerating the camera angle, introducing furniture that wasn't in the prompt, placing annotation markers on the floor plan outside the bounding box that the AI itself had declared. Each of these required a separate fix — clamping coordinates server-side, tightening the prompt directives, banning specific behaviours explicitly, adding auto-retry for renders that came back malformed. The fixture list in the Kontext prompt turned out to be actively confusing the model — too many objects for it to reason about while also preserving the source image. Dropping it improved coherence noticeably. The Before | After side-by-side is live now. It doesn't always look perfect. But it mostly looks like the same room, and that's the bar that actually matters for this product. Building with AI image models right now means a lot of time in this gap between "it works in the demo" and "it works reliably for real input." The engineering is mostly prompt constraint and defensive retries, not clever algorithms. Unglamorous, but it's the work.
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext Pro img2img — using the actual 'Before' photo as input — and spent days fixing the resulting prompt control issues.
1/ Generating an AI render of a room redesign is easy. Generating one that actually looks like the *same room* — same windows, same proportions, same light — is a different problem entirely. 2/ My property app used to generate renders with a text-to-image model. The results looked good in isolation. But they looked nothing like the actual room. Same house? You couldn't tell. 3/ This week I switched to FLUX Kontext Pro — an img2img model that takes the original "Before" photo as input and edits it, rather than generating from scratch. In theory: the same room, but transformed. 4/ In practice: a week of fixes. The model kept regenerating camera angles. Adding furniture from outside the prompt. Placing floor plan annotation markers in the wrong spots — outside the bounding box the AI had defined. 5/ Fixes, in order: - Clamp annotation coords to a declared drawing_bounding_box - Explicitly ban scene/camera regeneration in the prompt directive - Ban margin markers on the floor plan - Add auto-retry for failed renders - Drop the fixture list from the Kontext prompt entirely (it was confusing the model) 6/ The Before | After side-by-side view is now live. Does it always look perfect? No. Does it look like the same room? Mostly yes. That gap — "mostly" — is what AI product work actually feels like right now.
Discovered that an RLS-enabled Supabase table without an INSERT policy silently rejects writes — no error thrown, data just disappears.
I spent longer than I should have this week staring at a feature that appeared to work but wasn't saving any data. The app was the sold-price comps feature in Stashery — a sold-price intelligence layer that writes comparable property data to a Supabase table. The code looked fine. No errors in the console. No failed network requests I could see. The data just wasn't there when I looked in the database. The culprit: Row Level Security. Supabase lets you enable RLS on any table, which means database-level access control — reads, writes, and deletes only happen if a matching policy exists for the authenticated user. It's good practice and I'd set it up correctly for reading data. But I hadn't created an INSERT policy. Here's the thing about missing RLS policies: Supabase doesn't throw an error when a write gets rejected this way. It doesn't log a warning you'd see in the dashboard. It silently drops the operation and returns as if everything went fine. Your application code sees a successful response. The row never arrives. The fix itself was small — add the INSERT policy, and update the writeComps function to actually surface errors rather than swallow them. Two changes. Maybe ten minutes of work once I understood the problem. The diagnosis took considerably longer. I've started keeping a file called claude.md in my projects — a running log of lessons I've learned the hard way, written as explicit rules for Claude to follow in future sessions. The idea is that every painful discovery becomes a guard rail. This one became Lesson L11: an RLS-enabled table without an INSERT policy silently rejects writes. If you're new to Supabase: when you enable RLS, you need to create a separate policy for every operation — SELECT, INSERT, UPDATE, DELETE. They don't inherit from each other. And make your write functions loud about failures from day one. Silent errors are the hardest bugs to track down, and they're almost always fixable in five minutes once you know what to look for.
Discovered that an RLS-enabled Supabase table without an INSERT policy silently rejects writes — no error thrown, data just disappears.
1/ Silent data loss is the worst kind of bug. No error. No stack trace. Your write just... doesn't happen. This week I hit exactly that in Supabase, and it took longer than I'd like to admit to figure out why. 2/ The setup: I had a table with Row Level Security enabled — which is good practice in Supabase. I had a SELECT policy. I had an UPDATE policy. I did not have an INSERT policy. 3/ What happens when you try to insert a row and there's no INSERT policy? Supabase doesn't throw an error. It doesn't log a warning. It just silently rejects the write. The data never arrives. Your app carries on like nothing happened. 4/ The fix was two lines: add the INSERT policy, surface the error from writeComps so it's visible if it fails again. The diagnosis was... not two minutes. 5/ I've now logged this as Lesson L11 in my claude.md file — a running list of things I've learned the hard way so I don't feed Claude wrong assumptions next time. If RLS is on, you need a policy for every operation you care about. SELECT, INSERT, UPDATE, DELETE — each one is separate. 6/ For other beginners using Supabase: always add error surfacing to your write functions early. Silent failures are the hardest bugs to find. Make your app loud about them.
Pivoted Build-in-Public Studio from in-process SQLite to Supabase + Vercel within hours of finishing the first build — abandoning the scheduler that came with it.
I finished the first full build of Build-in-Public Studio — GitHub sync, notes, Claude drafting, dashboard, the works — and felt genuinely proud of it. Then I started thinking about deployment. The app used Node's built-in SQLite module. It's fast to scaffold, it's well-supported, and Claude writes it cleanly. But in-process SQLite means the database lives on the same machine as the server. On a serverless platform like Vercel, there is no persistent machine. Every function invocation is stateless. Your database just... doesn't exist between requests. I'd built the whole thing on a foundation that would evaporate in production. So Stage 9b was a pivot. Delete db.ts entirely. Migrate the schema to Supabase. Swap every query to the Supabase JS client. Fix the timestamp parser — because Postgres formats ISO strings differently to SQLite's output and the parser I had written assumed the SQLite format. Four commits, a few hours. The refactor itself wasn't the interesting part. Claude handled most of it cleanly once I described what needed to change and why. The interesting part was the hour I spent before writing a single prompt, actually understanding the difference between in-process storage and a hosted Postgres instance — understanding it well enough to explain it. That's the recurring pattern I'm noticing building this way. Claude can execute fast. What slows things down is me not yet having the mental model to describe the problem accurately. Every time I hit a wall, it's because I'm trying to prompt my way out of a concept I haven't understood yet. The prompt that works always comes *after* the understanding, not instead of it. A more experienced developer would have caught the SQLite-on-serverless problem at the design stage. I caught it at 6pm on the same day I shipped. Beginner tax. But it's a tax you pay once — the next app I build, I'll know to ask this question earlier. [VERIFY: confirm Vercel was the target deployment platform before posting]
Pivoted Build-in-Public Studio from in-process SQLite to Supabase + Vercel within hours of finishing the first build — abandoning the scheduler that came with it.
1/ I finished building the first version of my app at ~9am. By 6:30pm the same day I had deleted the database layer and started over. Not because it was broken. Because I'd made the wrong architectural call from the start. 2/ The first version used Node's built-in SQLite — fast to scaffold, works locally, Claude knows it well. But SQLite in-process doesn't survive serverless deployments. If I ever want to run this on Vercel, the database disappears between requests. 3/ So Stage 9b was a pivot: rip out db.ts entirely, migrate the schema to Supabase, swap every query to the Supabase JS client, and fix the timestamp parser because Postgres ISO strings aren't formatted the same way SQLite outputs them. Four commits. A few hours. 4/ The thing I keep noticing: Claude can execute a refactor like this fast. What takes time is *me* understanding what I'm changing and why. I had to actually understand the difference between in-process SQLite and a hosted Postgres instance before I could describe the pivot clearly. 5/ That moment of "wait, why won't this persist on Vercel" is the kind of thing a real engineer would catch in the design phase. I caught it at 6pm on launch day. Beginner tax. It's real. But you do pay it off.
Built an entire app (Build-in-Public Studio) from scratch in a single day — scaffolded to functional dashboard across 20+ commits on 2026-05-25.
On Sunday I opened a blank repo at 2:30am and by 9pm the same day I had a working product. Not a prototype — an actual app with GitHub sync, a SQLite notes layer, Claude-powered draft generation, a dashboard with per-project tabs, a history page with ratings, and a copy/publish flow. I wrote zero lines of that code myself. Every function, every route, every database migration came from Claude. My job was to describe what I wanted in clear stages, read what came back, and decide what to build next. The app is called Build-in-Public Studio — and yes, I'm building it to document building it. It watches your git commits, lets you jot notes throughout the week, then surfaces moments worth posting about and drafts the threads and long-form posts for you. The week I'm describing right now will eventually feed through it. What I didn't expect: the hard part was never "how do I write this." It was "what should this actually do." Every hour I was making product decisions — what belongs on the dashboard, how ratings should work, when to link notes to projects. Those decisions are mine. Claude can't make them for me. For a beginner, that's a genuinely different kind of challenge than learning syntax. I used to think not knowing how to code was the wall. Turns out the wall is knowing what you want clearly enough to describe it well. I went through about nine stages in one day — scaffold, notes, settings, GitHub sync, Claude drafting, the real dashboard, the copy/open flow, history and filtering, and project linkage. Each stage was its own conversation with Claude: describe the goal, review the output, test it, catch what's wrong, describe the fix. Phase 0 is done. Phase 1 — summaries, batch processing, scheduling — is scoped and next. If you'd told me a year ago that a non-developer could ship a functional SaaS tool in a single day, I'd have been skeptical. Now I'm the example. [VERIFY: confirm exact stage count and phase names before posting]
Built an entire app (Build-in-Public Studio) from scratch in a single day — scaffolded to functional dashboard across 20+ commits on 2026-05-25.
1/ At 2:30am on Sunday I scaffolded a new app from nothing. By 9pm that same day it had a working dashboard, GitHub sync, SQLite notes, Claude drafting, a history page, and project tabs. I wrote zero lines of that code myself. 2/ The app is called Build-in-Public Studio — a tool that watches your git commits, lets you jot notes, then asks Claude to find the moments worth posting about and draft the X threads and Indie Hackers posts. Meta: I'm building it to document building it. 3/ The day went like this: Stage 1 → scaffold + dark theme Stage 2 → SQLite notes page Stage 3 → settings + API key validation Stage 4 → GitHub sync via Octokit Stage 5 → Claude drafting with tool_use Stages 6-8 → real dashboard, copy/open flow, history with ratings 4/ The part that still surprises me: I never felt stuck on "how do I write this code." I felt stuck on "what should this actually do" — product decisions, not implementation. For a beginner, that's a completely different kind of hard. 5/ The whole codebase is AI-generated. Not "AI-assisted" — Claude wrote every function, every route, every migration. My job was to describe stages clearly, read the output, and decide what came next. That's a skill too. I'm still learning it. 6/ Still ahead: scheduling, post summaries, batch processing. But a full app in one day, built by someone who couldn't have written it themselves? That happened. [VERIFY: exact stage count before posting]
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext Pro img2img — using the actual property photo as the base image
The AI renders in my property reconfiguration app had a problem I'd been ignoring: they looked like AI images. That sounds obvious, but it matters. The point of the renders is to help someone visualise what a specific property could look like after reconfiguration work. If the output is a generic "nice living room" that shares no visual DNA with the actual property, it's not really useful. It's just eye candy. This week I switched the render pipeline from text-to-image to img2img using FLUX Kontext Pro. The difference is that instead of generating an image from a text prompt, the model takes the actual before photo of the room as a base and edits it — keeping the architecture, the light, the proportions — and applies the proposed changes on top. The outputs are not perfect. They're illustrative. But they start from the real space, which makes them meaningfully more useful as a communication tool. Getting it to work cleanly took more iterations than I expected. The main issues were: First, floor plan annotation coordinates kept landing outside the image bounds. The model would declare marker positions that were valid in its own frame of reference but wrong relative to the actual image. The fix was to make the model declare its own drawing_bounding_box explicitly, then clamp all coordinates to that box server-side. Claude implemented this; I described the symptom after looking at broken renders. Second, Kontext would occasionally recompose the entire shot — different camera angle, different crop — which defeats the purpose of img2img. I had to add explicit instructions to the prompt banning scene or camera regeneration. A couple of iterations to get the wording tight enough that the model stopped doing it. This is the debugging loop I keep finding myself in: Claude builds something, it mostly works, the edge cases reveal themselves through actual use, I describe what I'm seeing, Claude fixes it. Running that loop on AI render quality is a strange experience — I'm using one AI to fix the output of another AI, and my main contribution is looking at images and saying "that marker is in the wrong place." I'm fine with that being my contribution. The renders are better now.
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext Pro img2img — using the actual property photo as the base image
1/ The AI renders in my property app used to generate images from a text prompt. They looked like AI images. This week I switched to img2img — using the actual property photo as the base — and the difference is significant. 2/ The model is FLUX Kontext Pro. Instead of "here's a description of a room, make an image," it's "here's a photo of this specific room, apply these changes to it." The output keeps the bones of the real space. 3/ The use case: someone uploads photos of a property, the app analyses reconfiguration ideas, and the AI renders show what those ideas might look like. The renders are illustrative, not architectural. But they're a lot more useful when they start from reality. 4/ Getting the floor plan annotation coordinates to behave took several fix commits. The model would place markers outside the image bounds. Had to make the model declare a drawing_bounding_box and clamp all coordinates to it server-side. 5/ Also had to ban the model from regenerating the overall scene or camera angle — Kontext would sometimes "helpfully" recompose the shot entirely, which defeats the point of img2img. 6/ I didn't design any of this. Claude implemented it. My job was to look at the output renders, notice what was wrong, and describe the problem clearly. That loop ran about four times before the renders looked right. 6/
Discovered that an RLS-enabled Supabase table without an INSERT policy silently rejects writes — no error, just nothing happens
I want to document a bug I hit this week because it cost me more time than it should have and the fix was one line of SQL. I'm building Stashery, one of a few projects I'm running simultaneously. It uses Supabase for the database with Row Level Security enabled. I added a new table for sold comparables, wired up the query, and the writes silently failed. No error. No exception. The data didn't appear. Nothing in the console told me why. I checked the query. I checked the schema. I checked environment variables. I checked whether the Supabase client was initialised correctly. All fine. The problem was that I had a SELECT policy on the table but no INSERT policy. When RLS is enabled on a Supabase table, every operation type needs its own explicit policy. Having a SELECT policy doesn't carry over to INSERT. And when an INSERT is blocked by RLS, Supabase doesn't throw an error — it just rejects the write quietly. The fix was adding one INSERT policy. Done in under a minute once I knew what I was looking for. The discovery, on the other hand, took a while. That's the part I want to be honest about. I'm a beginner, I'm building three projects at once with Claude writing all the code, and debugging is the place where "Claude writes it" doesn't fully protect you. Claude wrote the original table setup. Claude also didn't know I'd forget the INSERT policy, because at the time I set it up, the feature didn't need writes yet. The code was fine when it was written. The bug appeared later. I've been keeping a file called claude.md in each project — a running log of lessons learned that Claude can reference at the start of each session. I added this as Lesson L11: "RLS-enabled table without INSERT policy silently rejects writes." It's now something Claude will flag proactively on future projects when it sets up a new table. That's the workaround I've found for the beginner knowledge gap: every time something costs me real time to debug, I write it down where it can be used next time. Slowly the claude.md files are becoming a personal database of mistakes I don't have to make twice. If you're using Supabase with RLS: SELECT, INSERT, UPDATE, and DELETE each need their own policy. SELECT working is not a sign that anything else will work.
Discovered that an RLS-enabled Supabase table without an INSERT policy silently rejects writes — no error, just nothing happens
1/ Spent time this week debugging a Supabase write that wasn't working. No error in the console. No exception thrown. The data just... didn't appear in the table. 2/ The table had Row Level Security enabled. I had a SELECT policy. I did not have an INSERT policy. Supabase didn't tell me this was the problem. It silently dropped the write. 3/ This is the kind of bug that will make you question your entire mental model before you find it. Is it the query? The schema? The client? An env var? It's none of those. It's a missing policy you didn't know you needed. 4/ The fix was one SQL line. The discovery was not one SQL line. I logged it in claude.md as Lesson L11 because I know I'll hit this again on the next project. 5/ If you're using Supabase with RLS: enabling RLS without any policies = all operations blocked by default. You need explicit policies for SELECT, INSERT, UPDATE, DELETE separately. SELECT working doesn't mean INSERT will. 6/ I'm a beginner and I'm building in public, which means I get to be honest about the fact that this cost me real time. Maybe it saves you some. 6/
Pivoted Build-in-Public Studio from SQLite + in-process scheduler to Supabase + Vercel on the same day it was built — mid-stream infrastructure swap
Here's something that happened on May 25th that I think is more instructive than the "built an app in a day" headline. A few hours after getting Build-in-Public Studio working locally, I ripped out its entire database layer and replaced it. Same day. Mid-stream. The original version used SQLite through Node's built-in node:sqlite module. It worked fine locally — clean, fast, no external dependencies. But I'd always planned to deploy on Vercel, and at some point during the day it clicked that Vercel's serverless functions don't have a persistent filesystem. Every function invocation is stateless. SQLite writes to disk. The data would just disappear between requests. So I stopped, assessed, and pivoted to Supabase. Claude migrated the schema, deleted the old db.ts file, and ported every database call to the Supabase JS client. The commits landed as feat(9b.1) and feat(9b.2) — tidy, named, done. But there was a bug hiding in the migration that I want to document, because it's the kind of thing that's easy to miss and annoying when you do. SQLite stores timestamps as text, in whatever format you happen to write them. Postgres returns proper ISO 8601 strings with timezone offsets. They look almost identical in a log. They are not the same to a date parser. The code that compared timestamps started returning wrong results — not errors, just wrong results — and I had to go looking for why. Once I described the symptom to Claude, it fixed the parser immediately. That part took two minutes. But I had to notice the symptom first. No amount of AI assistance changes that step. I think this is the most honest thing I can say about building with Claude as a non-technical founder: the implementation speed is genuinely shocking, but the product judgment — noticing that serverless and SQLite don't mix, noticing that a date comparison is returning suspicious results — that's still on you. Claude doesn't watch your app. You do. The infrastructure pivot cost maybe 90 minutes on a day that was already 18 hours of building. That felt like a win. But it only went smoothly because I caught the deployment constraint before I shipped, not after.
Pivoted Build-in-Public Studio from SQLite + in-process scheduler to Supabase + Vercel on the same day it was built — mid-stream infrastructure swap
1/ A few hours after launching Build-in-Public Studio locally, I scrapped its entire database layer and rewrote it. Not because it was broken — because I realized the architecture wouldn't survive deployment. 2/ The original build used SQLite via Node's built-in node:sqlite module. Fine for local. But the plan was always to deploy to Vercel, and Vercel's serverless functions don't have a persistent filesystem. SQLite just... disappears between requests. 3/ So at 6pm-ish, on the same day I built the app, I: migrated the schema to Supabase, deleted db.ts entirely, ported every DB call to the Supabase JS client, and fixed a timestamp parsing bug where Postgres ISO strings weren't matching what the old SQLite layer returned. 4/ The timestamp bug is the bit I want to flag. SQLite stores timestamps as text in whatever format you wrote them. Postgres returns proper ISO 8601 with timezone. They look similar. They are not the same. The parser broke silently. 5/ I didn't catch it from reading code. I caught it because a date comparison returned wrong results and I went looking. Claude fixed it once I described the symptom. But I had to notice the symptom first. 6/ That's the part of AI-assisted development nobody talks about enough — the AI writes the fix instantly, but you still have to be the one watching for the smell. Beginner or not, that part doesn't get automated. 7/
Built an entire app (Build-in-Public Studio) from scaffold to working product in a single day — ~20 commits across 18 hours
On May 25th I sat down at 2:30am and scaffolded a new project. By 9am it had a working dashboard, GitHub integration, Claude-powered post drafting, a publish tracking flow, and a history page with a feedback loop back into Claude. I went to bed that night with a real, usable app. I did not write a single line of code. The app is called Build-in-Public Studio. The idea is simple: it watches your git commits, reads any notes you've logged, and drafts posts for you — X threads and Indie Hackers long-forms. It's the tool I'm using right now to write about building it, which is a kind of recursion I didn't fully plan but enjoy. I'm documenting this because the timeline still surprises me when I look at the commit log. Scaffold at 2:30am. SQLite and a notes page by 2:53am. GitHub sync via Octokit by 3:29am. Claude drafting with tool_use by 4:07am. A full dashboard with approve/reject/edit/regenerate cards by 4:26am. Copy-open flow and publish tracking by 7:33am. History page with filters, ratings, and a voice-learning loop back into Claude by 9am. Those aren't aspirational bullet points — they're commit timestamps. I'm a beginner. I couldn't write a React component from scratch if you gave me an afternoon. What I've learned to do is describe what I want with enough precision that Claude can fill in the implementation, then read the output carefully enough to catch when something's wrong. That's the whole skill. It turns out that skill is transferable across every layer of the stack — UI, database, API integration, auth. The domain knowledge lives in Claude. The product judgment lives with me. The thing I keep noticing is that the pace doesn't decay the way I'd expect. In my mental model of building software, you go fast at the start and slow down as complexity accumulates. That hasn't been my experience here. The 9am commits are as clean and named as the 2am ones. I don't think I'm doing something magic. I think I'm just describing what working with a genuinely capable AI coding partner actually looks like when you lean into it fully, which most people still aren't doing. This project is my attempt to document that honestly — including the parts that break, the pivots, the moments where I have no idea why something doesn't work. The takeaway: if you're a non-technical person with a real product idea, the constraint is no longer "can you code." It's "can you think clearly about what you're building." Those are different problems.
Built an entire app (Build-in-Public Studio) from scaffold to working product in a single day — ~20 commits across 18 hours
1/ On May 25th I built a full working app in one day. Scaffold at 2:30am, real Claude-powered drafting by 4am, history page and GitHub sync and a full dashboard by 9am. I wrote zero code. Claude wrote all of it. 2/ The app is called Build-in-Public Studio. It watches your git repos, reads your commits, and drafts X threads + Indie Hackers posts for you. The thing I'm using right now to post about building it. 3/ Here's the rough timeline: - 2:30am scaffold + dark theme - 2:53am SQLite + notes page - 3:29am GitHub sync via Octokit - 4:07am Claude drafting with tool_use - 4:26am full dashboard with approve/reject/edit - 7:33am copy-open flow + publish tracking - 9:04am history page with ratings and voice learning 4/ None of those commits are "got it working eventually." Each one is a named feature landing clean. That's the thing that surprised me most — the pace didn't drop off. 5/ I'm a beginner. I can't write a React component from scratch. What I can do is describe what I want clearly, read the output carefully, and notice when something's off. Turns out that's most of the job. 6/ By end of day it had pivoted its own infrastructure once (more on that in another post). The app that drafts my build-in-public posts is itself being built in public. I find that funny and also slightly chaotic. 7/
Parallelised two analysis passes in Property-Reconfig-App and bumped token limits after the pipeline was silently truncating results.
This week I fixed two bugs in my property analysis pipeline that had one thing in common: neither of them threw an error. The first was a token limit problem in Pass 3 — the pass that generates cost breakdowns for each renovation idea. I'd set a fixed max_tokens ceiling. When the number of ideas was small, it was fine. When the analysis surfaced more ideas, the model was hitting the ceiling and cutting off mid-response. The pipeline continued, downstream code ran, and you'd just get incomplete cost data with no indication anything had gone wrong. The fix was to scale max_tokens with the idea count — more ideas, higher ceiling. Simple once you see it. I also added a loud failure if the ideas array comes back empty, because an empty array is another silent failure mode that's genuinely hard to debug. If something upstream went wrong, I want to know immediately, not three steps later when a UI component renders nothing. The second fix was parallelisation. Pass 2 — the pass that generates recommendations — was running sequentially. It's now split into Pass 2a (category identification) followed by Pass 2b, where each individual recommendation runs as its own concurrent Claude call. All of them fire at the same time, and the pipeline waits for all of them together. This is faster. It also means each call has a tighter scope, which I think produces better output — the model is focused on one recommendation at a time rather than trying to hold the whole analysis in context. Both of these were improvements I wouldn't have thought to make if I was just checking whether things worked. Things did work, technically. The pipeline ran. It just ran slowly and produced quietly degraded output. I'm learning that 'does it error?' is a much lower bar than 'does it work well?' and building AI pipelines means spending a lot of time in the gap between those two questions.
Parallelised two analysis passes in Property-Reconfig-App and bumped token limits after the pipeline was silently truncating results.
1/ Two quiet bugs in my AI analysis pipeline that were making results worse without throwing a single error: 1. Pass 3 was truncating — max_tokens was fixed regardless of how many ideas were being processed 2. Pass 2 was a bottleneck — everything ran sequentially when it didn't need to 2/ The token bug first. Pass 3 generates cost breakdowns per idea. I had a fixed max_tokens ceiling. With more ideas, the model was hitting the limit and cutting off mid-response. No error. Just incomplete output. Fix: scale max_tokens with idea count. Obvious in hindsight. 3/ Also added a loud failure if ideas come back empty. Silent empty arrays are one of the worst failure modes in AI pipelines — everything downstream looks fine, there's just nothing in it. 4/ The parallelisation: Pass 2 was split into 2a (categories) and 2b (one rec per category, parallelised). Each recommendation now runs its own Claude call concurrently. Faster. And each call has a tighter scope, which probably improves the output too. 5/ Neither of these would have shown up as an error in logs. The pipeline would have 'worked.' You'd just get fewer ideas, or slower results, or truncated cost data. Silent degradation is harder to catch than crashes. Worth building loudness in early.
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext Pro img2img — using the actual property photo as the base image.
One of the features in my property reconfiguration app is AI-generated renders — you upload photos and floor plans, run the analysis, and get a visual of what the space could look like after the recommended changes. Until this week, those renders were text-to-image: the model read a description of the room and generated something from scratch. The results looked like AI images, which is to say they looked nothing like the actual property. I switched to img2img using FLUX Kontext Pro. When a "Before" photo is available, it becomes the base image. The model edits the real photo rather than inventing a new room. The output is a Before | After pair where the After is grounded in the actual space — same walls, same proportions, same light. That's the good part. Here's what broke immediately after switching. First, the floor plan annotation markers — little pins showing where recommendations apply — started landing outside the drawing boundaries. The coordinates were being calculated relative to the full image canvas, not the floor plan drawing inside it. Fix: clamp the coords to the drawing's bounding box. Then the model started ignoring the "edit this photo" instruction and regenerating the scene entirely — new camera angle, new lighting, basically a fresh image that happened to be in the same room category. Fix: explicitly ban scene and camera regeneration in the Kontext prompt directive. Prompt engineering is more literal than I expected. Then the floor plan markers started appearing in the margins of the floor plan image — the white border area — instead of on the actual drawing. Fix: have the model declare its own drawing_bounding_box as part of its output, then clamp marker positions to that box on the server side. Three separate bugs, three separate fixes, all in the same day. What I find interesting about this kind of work is that I have to understand the problem precisely enough to describe it — not implement it, just describe it. "Markers are landing in the margins" is a different bug than "coordinates are wrong," and if I'd described it wrong, the fix would have been wrong too. That diagnostic skill is something I'm actually getting better at, even though I've never written a line of this code myself.
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext Pro img2img — using the actual property photo as the base image.
1/ The AI renders in my property app used to generate a room from scratch based on a text description. This week I switched to img2img — the model now edits the actual photo of the property. The difference is significant. 2/ The model is FLUX Kontext Pro. When a "Before" photo is available, it gets passed as the base image. The model edits it rather than hallucinating a new room. Before | After side-by-side, using the real space as the starting point. 3/ Getting there wasn't clean. Problems I hit after switching: - Floor plan annotation markers were landing outside the drawing bounds - Kontext kept regenerating the scene/camera angle instead of making targeted edits - Markers were appearing in the margins of floor plans 4/ Fixes, in order: - Clamp annotation coords to the drawing bounding box - Have the model declare its own drawing_bounding_box, then clamp to that - Explicitly ban scene/camera regen in the Kontext prompt directive - Explicitly ban margin markers Prompt engineering is real engineering. 5/ The honest version: I don't write this code. Claude does. But I have to understand the problem well enough to describe it precisely — "the markers are landing in the margins" is a different problem than "the coordinates are wrong," and the fix is different too.
Discovered that an RLS-enabled Supabase table without an INSERT policy silently rejects writes — no error thrown, data just disappears.
Here's a Supabase gotcha that cost me real time, and I want to put it somewhere searchable so it costs someone else less. I had a table with Row Level Security enabled. I was writing data to it from a server action. The writes appeared to succeed — no error thrown, no exception caught, the function returned fine. But the data wasn't in the table. It took me a while to even frame the question correctly. When nothing errors, you start doubting everything. Is the data being written to the wrong table? Is there a caching layer eating it? Is my query wrong? The actual cause was boring and easy to miss: RLS was on, but I'd never defined an INSERT policy for that table. I had SELECT covered. I didn't have INSERT. Supabase's behaviour in this case is to silently reject the write. Not a 403. Not a database error. Just... nothing happens. The row doesn't appear. Once I understood that, the fix was straightforward — add the INSERT policy, and also update the writeComps function to actually surface errors instead of swallowing them. That second part is as important as the first. If I'd been loudly failing from the start, I'd have found this in two minutes instead of however long it actually took. I build everything with AI-generated code, which means bugs like this are genuinely harder to spot because I'm not reading through the generated code line by line. The code looked right. The logic was right. The missing piece was a config decision I'd made earlier and forgotten about. This is now Lesson L11 in my claude.md — a file I keep specifically to document things like this, so that when I'm prompting Claude on the next project, I can paste in the relevant lessons and avoid repeating the same mistakes. If you're using Supabase with RLS: go check every table right now and make sure you have policies for SELECT, INSERT, UPDATE, and DELETE separately, not just whatever you happened to think about at migration time.
Discovered that an RLS-enabled Supabase table without an INSERT policy silently rejects writes — no error thrown, data just disappears.
1/ Supabase RLS bug that ate 45 minutes of my life (probably more): A table had Row Level Security enabled. Writes were silently failing. No error. No exception. The data just wasn't there. 2/ The cause: RLS was on, but there was no INSERT policy defined for that table. Supabase doesn't throw when this happens. It just quietly rejects the write. Your code thinks it succeeded. 3/ The fix was two things: - Add the missing INSERT policy - Surface the error from writeComps instead of swallowing it The second part matters as much as the first. Silent failures are worse than loud ones. 4/ This is now documented as Lesson L11 in my claude.md — the living file where I log every hard-won thing about working with AI-generated code on Supabase. If you're using Supabase + RLS: always check you have policies for every operation, not just SELECT.
Built an entire app (Build-in-Public Studio) from scaffold to Supabase-backed production in a single day — 23 commits on 2026-05-25.
Yesterday I did something I wouldn't have believed was possible for someone who can't write production code: I built an entire app, end to end, in a single day. Not a toy. Build-in-Public Studio is a real Next.js app — it syncs your GitHub commits via Octokit, stores notes and moments in a database, sends them to Claude for drafting, and gives you a dashboard to edit, approve, or reject posts before they go out. It has a history page with ratings, per-project tabs, a copy-and-publish flow, and a settings page for API key validation and watched repos. That's 23 commits between 2:30am and 8:40pm. Every line of code written by Claude. None written by me. The moment I want to talk about isn't the speed — it's the pivot that happened late in the day. I'd built the whole thing on SQLite running in-process, which felt fine until I remembered that Vercel doesn't give you a persistent filesystem. The whole DB layer was wrong for the deployment target. So I asked Claude to port it. It rewrote the entire database layer to Supabase JS, deleted the old db.ts file entirely, and fixed a timestamp parser that was choking on Postgres ISO strings instead of SQLite's format. One prompt, one clean rewrite, no leftover cruft. I didn't patch around the problem. I described the problem, and the right solution appeared. What I find genuinely interesting about this way of working is the separation it creates. I don't understand every line that was written. I'm not going to pretend otherwise. But I understand the product completely — what each page is for, what each feature should feel like, where the next friction point is. That's a different skill than programming, and it turns out it's enough to ship. The whole premise of Build-in-Public Studio is that a solo builder's git history is full of stories worth telling, but pulling those stories out takes time most builders don't have. I'm building the tool that solves that problem, using the workflow the tool is designed to document. It's a bit circular, and that's intentional. Phase 0 is done [VERIFY exact phase boundary]. Next up is summaries, batching, and scheduling — the bits that make it actually useful as a daily habit rather than a one-off demo.
Built an entire app (Build-in-Public Studio) from scaffold to Supabase-backed production in a single day — 23 commits on 2026-05-25.
1/ I scaffolded a new app at 2:30am and had it reading GitHub commits, drafting posts with Claude, and storing everything in Supabase — by 8:40pm the same day. 23 commits. One day. Zero lines written by me. 2/ The stack is 100% AI-generated. I describe what I want, Claude writes the code. I'm the product manager, not the programmer. This particular app is Build-in-Public Studio — a tool that watches your git activity and turns it into posts. I'm building it in public, with itself, eventually. 3/ The rough order of the day: - Scaffold + dark theme - SQLite notes page - GitHub sync via Octokit - Settings with API key validation - Claude drafting via tool_use - Real dashboard with moment cards - Copy → publish flow - History page with ratings - Per-project tabs - Then... pivot the whole DB layer to Supabase 4/ That last pivot is the interesting part. I'd built the whole thing on in-process SQLite. Then I realised I needed a proper hosted DB for Vercel deployment. Instead of patching around it, Claude rewrote the entire DB layer, deleted db.ts, and fixed a timestamp parser for Postgres ISO strings. Clean. 5/ The honest takeaway: I don't fully understand everything that was written today. But I understand the product. I know what each page does, what each feature is for, and what the next problem is. That division of labour is the whole experiment.
Scaffolded two new projects in one week (Build-in-Public Studio and Bloomr) while maintaining two existing apps — all code AI-generated.
At the end of this week I counted four active projects in my GitHub. Two that have been running for a while, one that went from scaffold to Supabase-backed production in a single day, and one that's just a scaffold with pre-work archived to a docs/research folder. None of the code is mine. All of it is generated by Claude. I also shipped a shared design system — WyCo Digital — across two of the projects this week. Same colour tokens, same font choices, same component primitives. The idea is that as these products grow, they look like they come from the same person, or at least the same taste. I'm a beginner. I've said that from the start. I can read code well enough to catch obvious mistakes and I understand roughly what most things do, but I could not write a working Next.js API route from memory. That's not changing. What I've noticed is that the constraint isn't the code anymore. The AI writes fast and it writes correctly most of the time. The constraint is me: deciding what to build, in what order, with what tradeoffs. Knowing when a technical approach is going to hit a wall — like an in-process scheduler on a serverless host — before building too much on top of it. Documenting lessons so the AI's institutional memory doesn't reset every session. Four projects. One week. No code written by hand. I don't say that to suggest everyone should work this way. I say it because when I started I assumed the limiting factor would be my lack of technical skill. It turns out the limiting factor is the same thing it always was: having good ideas and making good calls about them.
Scaffolded two new projects in one week (Build-in-Public Studio and Bloomr) while maintaining two existing apps — all code AI-generated.
1/ This week I started two new projects, shipped features across two existing ones, and wrote zero lines of code myself. Everything is AI-generated by Claude. 2/ The four projects right now: Property-Reconfig-App (property investment analysis), Stashery (auction lot tracking), Build-in-Public Studio (the tool generating this post), and Bloomr (just scaffolded). 3/ I also applied a shared design system — WyCo Digital — across Stashery and the Property app this week. Same colour tokens, fonts, and components. One design language, multiple products. 4/ This is not a flex. It's actually a question I'm sitting with: what does it mean to be a "developer" when the code is written by AI? What's my actual job? 5/ My answer so far: product decisions, architecture calls, knowing when to throw something away, and writing down every lesson so the AI doesn't repeat mistakes. The coding itself is the easy part now.
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext Pro img2img — using the user's actual Before photo as the base.
The AI render feature in my property reconfiguration app had a problem I'd been ignoring: it was making things up. When a user uploaded photos of their property and asked for renovation ideas, the AI would generate a render of what the changes might look like — but it was generating entirely fictional rooms. Same general description, completely different space. Not useful if you're trying to convince yourself or a client that a specific room could look different. This week I switched the render pipeline to FLUX Kontext Pro in img2img mode. Instead of generating from a text prompt, it now uses the user's actual Before photo as the base and edits it. The result is an After image that's recognisably the same room. Getting there was messier than I expected. First problem: the floor plan annotation coordinates. The model was placing markers outside the visible image area — technically valid coordinates, visually useless. The fix was to have the model declare its own drawing_bounding_box at the start, and then have the server clamp all marker positions to that box before rendering. [VERIFY: this is how I understand the fix from the commit, but the exact clamping logic is in code I didn't write] Second problem: prompt drift. Kontext kept treating the directive as permission to regenerate the entire scene — new camera angle, new lighting, new composition. I had to add explicit bans to the prompt: no scene regeneration, no camera changes, no margin markers. Round two of fixes. Then an auto-retry for AI renders that came back blank. Four fix commits across one day to get it stable. The Before | After side-by-side is now one of the more compelling parts of the app. There's something genuinely different about showing someone a render of their actual room versus a room that's described similarly. Whether it's good enough for a real audience — that's what a demo this week is supposed to answer.
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext Pro img2img — using the user's actual Before photo as the base.
1/ The AI render feature in my property app was generating rooms from scratch — totally fictional spaces that had nothing to do with the user's actual property. 2/ This week I switched it to FLUX Kontext Pro img2img. Now it uses the user's Before photo as the base image and edits it. The After render is the same room, changed. 3/ Getting the floor plan annotations right was painful. The model was placing markers outside the image bounds. I had to have Claude: declare a drawing_bounding_box, then clamp all coordinates to it on the server. 4/ There were also prompt drift issues — Kontext was regenerating the camera angle and scene entirely instead of making targeted edits. I had to ban specific behaviours in the directive: no scene regen, no camera changes, no margin markers. 5/ After four fix commits in one day, the Before | After side-by-side is working. It's a genuinely different thing to show someone a render of their actual room rather than a room that happens to be described similarly.
Supabase RLS silently rejected all writes — no error, just data that vanished — until a missing INSERT policy was added.
Here's a quiet little nightmare that ate more time than I want to admit. I was building the sold-price comps feature in Stashery — a tool for tracking auction lots. The app was writing data to a Supabase table. The code ran without throwing. No 500 errors, no console warnings. But when I checked the database, the rows weren't there. I went through the usual suspects: wrong table name, bad data shape, environment variables pointing at the wrong project. Everything looked fine. The actual cause: Row Level Security was enabled on the table, but I'd only written a SELECT policy. There was no INSERT policy. Supabase, silently and politely, rejected every single write. The client-side code got back what looked like a success response — or at least nothing that looked like failure — and moved on. One INSERT policy later, everything worked. This is the kind of bug that's particularly rough for an AI-generated codebase. When Claude scaffolds a new table with RLS enabled, it doesn't automatically know which operations your app will need policies for. And if the error isn't surfaced clearly, neither you nor the AI has anything to debug. My fix to the process — not just the code — was to document this as Lesson L11 in a claude.md file I keep in each repo. It's a running log of things that went wrong, written in plain language, that I paste into context at the start of each session. The idea is that the AI reads it and doesn't repeat the same class of mistake. L11 now reads roughly: "RLS-enabled table without an INSERT policy silently rejects writes. Always add policies for every operation the app needs." If you're using Supabase: go check your tables right now. If RLS is on and you're not seeing data you expect to see, a missing policy is the first thing to look at.
Supabase RLS silently rejected all writes — no error, just data that vanished — until a missing INSERT policy was added.
1/ The bug: data was being written to a Supabase table. No errors thrown. Rows just... didn't appear. Took an embarrassingly long time to find. 2/ The cause: Row Level Security was enabled on the table but there was no INSERT policy. Supabase silently rejected every write. The app code surfaced nothing — it thought the insert succeeded. 3/ The fix was one SQL line — add an INSERT policy. But the lesson is the real thing: RLS without an INSERT policy is a silent trap. Not a 403. Not a warning. Just a void. 4/ I'm documenting every lesson like this in a claude.md file in the repo. This one is L11. The AI that writes my code can now read that file and not make the same mistake in future projects. 5/ If you're using Supabase with RLS: always check you have policies for every operation you need — SELECT, INSERT, UPDATE, DELETE. The table won't complain if you miss one.
Built an entire app (Build-in-Public Studio) from scaffold to Supabase-backed production in a single day — ~23 commits across one Sunday.
Yesterday I shipped an entire app in a single day. Not a prototype. Not a "v0.0.1 with three buttons." A thing with a database, GitHub integration, AI drafting, a dashboard with tabs and filters, and a history page that feeds approved posts back into Claude as training signal for future drafts. I cannot write TypeScript. I've never set up a Next.js project from scratch. The entire codebase is AI-generated — I describe what I want, Claude writes the code, I commit it. Here's roughly how the day went: 2:30 AM — scaffold, dark theme, basic layout. Stage 1 done. 3:30 AM — SQLite via node:sqlite, /notes page with save and recent-notes list. Stage 2. 4:00 AM — GitHub commits pulled via Octokit, cached locally, /debug/commits page. Stage 3. 4:30 AM — Settings page: API key validation, watched repos, preferences. Stage 4. 7:50 AM — Claude drafting via tool_use. Real X threads and Indie Hackers posts, not lorem ipsum. Stage 5. 8:30 AM — Real dashboard: moment cards, tabs, edit/regenerate/approve/reject. Stage 6. 8:00 AM to noon — /history page, per-project tabs, pill-style UI, a colour cue that turns a tab green when everything in it is actioned. Then at 6 PM I had to make a painful call. The in-process scheduler I'd built wouldn't work on Vercel — serverless functions don't keep memory between requests. I had to abandon it and migrate the entire DB layer to Supabase. That migration took about two hours. Port all the queries to Supabase JS, delete db.ts, fix a timestamp parser that was silently breaking because Postgres returns ISO strings and SQLite doesn't. By 8:40 PM it was done. The app I'm describing right now is the exact tool that will generate this post's X thread and suggest it to me as a moment worth sharing. The takeaway I keep sitting with: the bottleneck wasn't writing code. It was making decisions — what to build next, when to throw something away, what a "good enough" UI actually looks like. Those are the things AI can't do for me. Everything else, apparently, it can.
Built an entire app (Build-in-Public Studio) from scaffold to Supabase-backed production in a single day — ~23 commits across one Sunday.
1/ I shipped an entire SaaS in one day. Not a landing page. A working app — notes, GitHub sync, Claude drafting, a dashboard, history, and a database. All AI-generated code. Zero prior knowledge of the stack. 2/ It started at 2:30 AM with a scaffold and a dark theme. By 3:30 AM there was SQLite and a /notes page. By 4 AM, GitHub commits were being pulled via Octokit and cached locally. 3/ By 7 AM, Claude was generating actual post drafts using tool_use. By 8 AM there was a real dashboard — moment cards, tabs, edit/regenerate/approve/reject flows. 4/ By midday there was a /history page with filters, ratings, and a voice-learning loop that feeds approved posts back to Claude to improve future drafts. 5/ Then at 6 PM, I threw all of that out. SQLite + an in-process scheduler wasn't going to survive Vercel's serverless model. Pivoted to Supabase. 6/ Re-ported the entire DB layer to Supabase JS, deleted db.ts, fixed a timestamp parser that choked on Postgres ISO strings instead of SQLite's format. Done by 8:40 PM. 7/ The app I'm building is literally the tool I used to identify this moment. The whole codebase is AI-generated — I can't write TypeScript. That either makes this impressive or terrifying. Possibly both.
Parallelised the property analysis pipeline's slowest passes to cut the wait during a key UX gap.
The property analysis in the Property-Reconfig App runs through several 'passes' — each one a separate Claude call that builds on the last. It's pipeline architecture, basically. And pipelines have a problem: every slow step is felt by the user. This week I tackled the worst gap: Pass 2, where the app goes from detecting property categories to generating full recommendations. It's the longest step, and it was showing the user nothing — no progress, no indication it was working. On a slow connection, I imagine it felt like a crash. Two separate fixes, both shipping the same week. The UX fix was simple and immediate: add a rotating hints panel during Pass 2. While the model is thinking, show the user something contextual — not a generic spinner, but actual information about what the analysis is doing. This is one of those changes that takes maybe an hour and makes the whole product feel more alive. The speed fix was more interesting. I split Pass 2 into two stages: Pass 2a detects the categories (fast, single call), and Pass 2b generates one recommendation per category. The key insight is that each recommendation is independent — they don't need each other. So they can run in parallel. Same output, noticeably less wall time. There's a thing I keep noticing with this kind of optimisation: you have to understand *why* something is slow before parallelism helps. Running independent calls in parallel is great. Running dependent calls in parallel is either useless or broken. I can't write async JavaScript, but I can map out which calls depend on which, and that's what Claude needs to build it correctly. Also fixed in this pass: Pass 3 was silently truncating its cost analysis output when there were more ideas than the token budget allowed. Bumped max_tokens from 1500 to 3000 per idea, and added a loud failure mode so truncation can't hide again. Silent truncation is almost as bad as silent data loss — you get output that looks complete but isn't.
Parallelised the property analysis pipeline's slowest passes to cut the wait during a key UX gap.
1/ There's a moment in the Property-Reconfig App where the user has uploaded their photos and is waiting for AI analysis. It was taking too long. The UI showed nothing. People would wonder if it had crashed. Two changes fixed it. 2/ Fix A (UX): rotating hint messages during the longest gap — Pass 2. This is the 'thinking gap' between category detection and generating recommendations. Nothing was happening visually. Now it cycles through contextual hints so users know it's working. 3/ Fix B (speed): split Pass 2 into two stages and run them in parallel. Pass 2a: detect categories (fast). Pass 2b: generate one recommendation per category (slow, but now parallel). Same output. Less wall time. 4/ Also bumped Pass 3's max_tokens from 1500 to 3000 per idea. Pass 3 was silently truncating its cost analysis when there were more ideas than expected. The output looked fine — it was just quietly cut off mid-thought. More tokens, louder failure mode if it happens again. 5/ What I find interesting: both fixes required understanding *why* it was slow, not just that it was slow. Parallelising the wrong thing does nothing. You have to know which calls are independent before you can run them side by side. Claude helped write it. I had to see it first. 6/
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext img2img — then spent days taming it.
Last week I added a Before | After AI render feature to the Property-Reconfig App — show a photo of the current room, generate an AI version of what it could look like after renovation. The switch to FLUX Kontext Pro img2img (using the actual property photo as the base image) was one commit. The fallout from that commit was four more days of fixes. I want to write about what that actually looked like, because I think there's something honest here about what building with AI models in production is really like. Fix one: floor plan annotation markers were appearing outside the image bounds. The model was placing coordinates that made sense to it, not coordinates that fit the canvas. Server-side clamping fixed this — clamp every coordinate to the image dimensions before rendering. Fix two: Kontext kept regenerating the scene. img2img is supposed to preserve the room and modify details. Kontext was treating the prompt as license to reimagine the whole thing — different camera angle, different lighting, different room shape. I had to explicitly ban scene and camera regeneration in the directive. You can't assume the model knows what 'preserve the room' means without being told very directly. Fix three: the model would declare its own drawing_bounding_box and then place markers outside it anyway. The model's self-declared constraints are not reliable. The server now clamps to the declared box regardless, because trusting the model to follow its own rules is a losing bet. Fix four: the floor plan generation poll was flaky — sometimes it just never resolved. Added reliable polling with auto-retry. Five commits. Four days. One feature. The thing I keep learning is that AI models in production aren't deterministic tools you configure once. They have tendencies. They interpret prompts loosely. They do creative things you didn't ask for. Wrangling that — finding the right constraints, the right server-side guards, the right retry logic — is a real engineering problem. I can't write the code, but I can see the problem clearly enough to describe it. That's the work.
Switched Property-Reconfig-App's AI renders from text-to-image to FLUX Kontext img2img — then spent days taming it.
1/ Adding a Before | After AI render to a property app sounds simple. Swap in an img2img model, point it at the uploaded photo, done. It was not done. It took four days and five fix commits to make it behave. 2/ The switch: replaced text-to-image with FLUX Kontext Pro img2img. The idea — use the actual property photo as the base, generate a realistic 'after' render from it. In theory, much better results. In practice, chaos. 3/ Problem 1: floor plan annotation markers were appearing outside the image bounds. Fix: clamp coordinates server-side. Also tighten the Kontext edit directive so it stops doing things it wasn't asked to do. 4/ Problem 2: the model kept regenerating the scene and camera angle. This is the opposite of what img2img is for — you want it to keep the room, change the details. Fix: explicitly ban scene/camera regen in the prompt. And ban margin markers. 5/ Problem 3: the model declared its own drawing_bounding_box, then placed markers outside it anyway. Fix: server clamps all coordinates to the declared box. The model's word is not law. 6/ Problem 4: the floor plan poll was unreliable — sometimes never resolving. Fix: reliable poll with auto-retry on the AI render step too. Five commits. Four days. One feature. 7/ The thing about AI models in production: they're not deterministic tools. They have tendencies. They ignore constraints. They do creative things you didn't ask for. Wrangling that is a real engineering problem, even when you can't write the code yourself. 8/
Supabase RLS silently rejected all writes — no error, just data that never appeared.
This week I lost a few hours to silent data loss on Stashery, and the cause was one missing line of SQL. Stashery uses Supabase for its database, with Row Level Security enabled on the sold_comps table. RLS is the thing that makes sure users can only see and touch their own data. It's genuinely useful. It's also quietly unforgiving. Here's what happened: I had a SELECT policy set up, and an UPDATE policy. I did not have an INSERT policy. When I tried to write new comps, Supabase returned what looked like a successful response — no error thrown, no exception caught. But the row was never created. The data just didn't exist. I spent a while debugging the wrong things: the server action logic, the client-side call, the response structure, whether I was reading the return value correctly. Everything looked fine from the application's perspective. The problem was at the database layer, one level below where I was looking. With RLS enabled, Supabase's default stance is deny everything unless a policy explicitly allows it. A missing INSERT policy doesn't produce an error — it just silently blocks the write. This is probably the correct security behaviour, but it's deeply uncomfortable to debug when you're not expecting it. The fix itself was small: add the INSERT policy, and add proper error surfacing to the writeComps function so that if this happens again, at least it shouts. I logged it as Lesson L11 in the project's CLAUDE.md file. I've started keeping one of these in every project — a running list of traps that have caught me, written in a way that Claude can reference. When I'm describing a bug to Claude, it can look back and say 'this matches L11' rather than us both starting from zero. The lesson itself is simple: if you're using Supabase RLS and your writes are silently disappearing, check every policy type, not just the ones you've touched recently.
Supabase RLS silently rejected all writes — no error, just data that never appeared.
1/ Silent data loss is the worst kind of bug. No error. No crash. The write just... didn't happen. Data never appeared. This week I hit exactly that on Stashery, and it took a while to see why. 2/ The setup: a Supabase table with Row Level Security enabled. I had a SELECT policy. I had an UPDATE policy. I did not have an INSERT policy. 3/ Supabase with RLS enabled defaults to DENY ALL. Missing a policy doesn't throw an error — it silently rejects the operation. The call returns success. The row doesn't exist. 4/ I'd been debugging the wrong layer entirely — checking the server action, checking the client call, checking the response shape. The problem was one missing SQL line in a migration. 5/ The fix: add the INSERT policy, surface the error properly so this can't hide again. Then I logged it as Lesson L11 in the project's Claude docs. 6/ I now keep a CLAUDE.md lessons file in every project. When something like this bites me, it goes in there — so Claude (and I) don't repeat it. Building in public with AI means documenting the traps, not just the features. 7/
Built an entire app (Build-in-Public Studio) from scaffold to Supabase-backed production in a single day, across ~20 commits.
Last Sunday I opened a blank folder at 2:30am and by 9pm I had a working, Supabase-backed SaaS with a real dashboard, GitHub sync, Claude-powered content drafting, a history page with filters, and a copy-to-clipboard publish flow. That's Build-in-Public Studio — the tool I'm building to write these posts. The whole codebase is 100% AI-generated, by Claude. I didn't plan for it to be a one-day build. I just kept going because the feedback loop was fast enough that stopping felt wrong. Each stage took maybe 30-40 minutes. Scaffold, then SQLite notes, then Octokit for GitHub sync, then Claude drafting via tool_use, then a real dashboard, then a history page, then the publish flow. Twenty commits between 2am and 9pm. Then I hit a real constraint. I'd built the scheduler as an in-process thing — a background job that runs inside the Next.js server. That works fine locally. It does not work on Vercel, where functions are stateless and cold-start constantly. I only figured this out because I was reading about the deployment and something didn't add up [VERIFY: I should double-check whether this was from docs or a runtime error]. So around 6:30pm I made the call to pivot: Supabase for persistence, Vercel for hosting, no in-process scheduler. Deleted db.ts entirely. Ported the whole DB layer to Supabase JS. Hit one quiet bug — the timestamp parser expected SQLite's format and Postgres ISO strings are subtly different. Fixed it, logged Stage 9b.2 complete. The thing I keep coming back to is this: as someone who can't write code, the bottleneck was never the code. It was whether I understood the problem well enough to describe it clearly. In-process scheduler on serverless — that's a real architectural concept I had to grasp before Claude could help me fix it. I didn't need to implement it. I needed to see it. That's a different skill than programming, and it's apparently one I've been developing without realising it.
Built an entire app (Build-in-Public Studio) from scaffold to Supabase-backed production in a single day, across ~20 commits.
1/ On Sunday I went from empty folder to a working, Supabase-backed SaaS in one day. Not a weekend. One day. ~20 commits between 2am and 9pm. The whole codebase is 100% AI-written. Here's what actually happened. 2/ The sequence, roughly: - 2:30am scaffold + dark theme - 2:53am SQLite notes page - 3:29am GitHub sync via Octokit - 4:07am Claude drafting with tool_use - 4:26am real dashboard with moment cards 3/ Then I hit a wall: an in-process scheduler doesn't survive serverless cold starts. So at 6:43pm I made the call — pivot to Supabase + Vercel, abandon the scheduler, redo the DB layer. 4/ That pivot took two commits and about 4 hours. Deleted db.ts entirely. Ported everything to Supabase JS. Fixed a timestamp parser that choked on Postgres ISO strings instead of SQLite's format. 5/ By 9pm it was done. Logged as Stage 9b.2 complete. I didn't plan to build this in a day. I just kept going because Claude kept shipping. As a non-developer, that feeling is still genuinely strange. 6/ The thing I keep noticing: the bottleneck is never "can the code be written." It's "do I understand the problem well enough to describe it?" That's a different skill than coding, and it turns out I have some of it. 7/