Where Did I Save That Article? Crawl, Cache, Retrieve

Part 5 of 8: Durable AI for InterlinedList


I have a problem, and I bet you have it too. I read something good, I drop the link into a post or a document or a row in some list, and then three weeks later I want it back and I have no idea where it went. Was it a message? A doc? A row in that reading-list I built? So I scroll my own feed like an archaeologist. It’s a slow-moving disaster. The whole point of writing something down is being able to get it back, and I’d built the writing-down part beautifully while ignoring the getting-it-back part entirely.

So this is the post where I brainstorm fixing that. Two pieces come from the Temporal plan, and they run in order. First, the link-crawl-and-cache phase turns saved links into a real cache. Then the retrieval phase makes that cache searchable, semantic and keyword, with cited snippets, so “where did I save that article about X” actually returns the article about X. None of this is shipped. AI assistance in the product is still “Coming Soon.” This is me thinking out loud with the intent to build.

What Actually Exists Today (Less Than You’d Hope)

Before I design anything, I made myself write down what the codebase does right now, because designing against a fantasy version of your own product is how you build the wrong thing.

Today, link metadata gets fetched for messages only. When you post something with a URL, lib/messages/metadata-fetcher.ts pulls the Open Graph tags and the result gets stored inline on the message as a JSONB blob: Message.linkMetadata. That’s it. There’s no cache table. There’s no dedup, so if you and forty other people all post the same great article, that URL gets fetched forty-one times. Documents and list rows (both Subscriber features) don’t get scanned for links at all. And search? Search today is Postgres ILIKE substring matching across three separate routes: messages, lists, documents. It’s a contains query. Type “kubernetes” and you get rows where the literal string “kubernetes” appears. Miss the spelling, miss the synonym, miss the concept, and you get nothing. There is zero vector infrastructure anywhere in the repo. I checked.

That’s the honest starting line. It’s also a decent starting line, because two of the hard parts are already solved and I’d be a fool to rewrite them.

The Crawler: Reuse the Fetcher, Reuse the Guard

So, the caching step. A durable workflow scans a user’s messages, documents, and list rows for URLs, fetches each one, extracts the readable content, and stores it in a new LinkContent cache table keyed by a hash of the URL. Deduplicated across all your content, and optionally across all users. It runs incrementally: only new or changed URLs get fetched, so the second run is nearly free.

The building blocks already exist. lib/messages/link-detector.ts gives me extractUrls and detectLinkslib/messages/metadata-fetcher.ts gives me fetchLinkMetadata. And critically, lib/security/ssrf.ts gives me safeFetch and assertUrlAllowed, the SSRF guard the codebase already fought to get right. This work is mostly generalizing the message-only path to also cover docs and list rows, then bolting a real cache behind it so a URL is fetched once and never again.

This is the single most important thing to say to Claude when you hand it this task, so I’ll say it here first: build on safeFetch and assertUrlAllowed. Do not write a new fetch().

I cannot overstate this. Left to its own devices, an LLM asked to “write a crawler that fetches URLs and extracts content” will cheerfully write you a fresh fetch(url) with a nice timeout and some error handling, and it’ll look completely reasonable, and it’ll have reintroduced the server-side request forgery hole this codebase already closed. It won’t check whether the URL resolves to 169.254.169.254 and hands back your cloud metadata. It won’t block the redirect that bounces from a public host to localhost. The existing safeFetch does all of that, and it re-validates on every redirect hop. A brand-new fetcher is a security regression wearing the costume of a feature.

So the instruction isn’t “write a crawler.” The instruction is:

// Crawler activity: the reuse contract, spelled out for the agent.
// 1. detectLinks/extractUrls from lib/messages/link-detector.ts to find the URLs
// 2. hash each URL, check the LinkContent cache, skip if already fetched & unchanged
// 3. fetch ONLY through safeFetch from lib/security/ssrf.ts (never a raw fetch)
// 4. extract readable text mechanically (HTML to text), NO LLM in this path
// 5. upsert into LinkContent keyed by urlHash; dedup means fetch-once-ever
async function crawlUserLinks(userId: string) {
  const urls = await collectUrlsFromMessagesDocsAndRows(userId); // reuse extractUrls
  for (const url of dedupeByHash(urls)) {
    if (await cacheHasFresh(url)) continue;         // incremental: skip known URLs
    const html = await safeFetch(url);              // SSRF guard, NOT a new fetch()
    await upsertLinkContent(url, extractReadableText(html));
  }
}

Note what’s not in there: a language model. HTML-to-readable-text is mechanical. You strip the tags, you pull the article body, you’re done, no tokens spent. An optional cheap-model summary of each page could sit on top later, opt-in, for people who want it, but the base extraction costs nothing per link beyond the fetch. Pair that with URL-hash dedup (a shared link is fetched once, ever, across the whole system) and this becomes the cheapest proposal in the whole plan for what you get out of it. Which is why I like it. It’s mostly plumbing, mostly reuse, and it barely touches an LLM.

Retrieval: The Part That Needs New Ground

The crawl phase gives me the cache. The search-over-my-links phase is what makes it findable. This is “search over my link index and my own docs, lists, and messages, returning cited snippets.”

And I have to be honest that this one needs real new infrastructure, because the vector story doesn’t exist yet: not partially, not disabled behind a flag, not anywhere. So the retrieval phase needs a pgvector extension, an embedding column or table, an embed activity that turns text into vectors, and a search route that ranks by both vector similarity and the existing ILIKE match. Hybrid. It supplements today’s substring search rather than ripping it out. Keyword matching is still the right answer for exact-string lookups, and it needs no model at all.

The cost math here is the reassuring part. Embeddings are orders of magnitude cheaper than text generation: you’re paying to turn text into a vector, not to write prose. You compute each embedding once and cache it, sharing the same cache the crawl phase already built. And because retrieval comes first, any eventual answer-synthesis step runs over a small, already-narrowed set of retrieved snippets instead of dumping your whole corpus into a prompt. Keep the expensive context small and this stays affordable.

The incremental pipeline is what keeps it fresh without a nightly full re-scan: on every content write (new post, edited doc, added row) enqueue a re-embed activity for just that thing. Batch the embeddings for throughput. The index stays current, and you never re-embed the 99% of your corpus that didn’t change.

The Second Lesson: Don’t Let the Agent Touch the Database Directly

The pgvector migration is where the other working-with-Claude lesson lives, and it’s a governance one.

This repo has a strict database workflow, and it’s strict for a reason the CLAUDE.md states plainly: violating it has broken production before. Additive-only. Idempotent SQL. Hand-written migration files applied to both databases. No prisma db push, no prisma migrate dev, no raw DDL fired at the database from a script.

An LLM does not know this in its bones. Ask it to “add a vector column and an embeddings table” and its instinct (a perfectly good instinct in most repos) is to edit the schema and run prisma db push to sync it. In this repo that’s a loaded gun. So the instruction to the agent isn’t just “add the schema.” It’s: route this through the db-migrations workflow. Write an idempotent, additive migration file (CREATE EXTENSION IF NOT EXISTS vectorCREATE TABLE IF NOT EXISTS, the column adds guarded) and do not run db push or migrate dev against the database. The good news is the migration is cleanly additive: there’s no existing vector infra to alter or drop, so it’s all new tables and columns, which is exactly the shape this workflow handles best.

Two guardrails, then, and I’d hand both to the agent up front rather than catch them in review: reuse safeFetch instead of writing a fetcher, and route the schema change through the migration workflow instead of letting it push. Both are cases where the model’s default behavior looks reasonable and is wrong for this codebase. Tell it about the thing that already exists, and tell it not to reinvent it.

The payoff, if I build it, is the thing I wanted in the first place. I type “that article about durable execution I saved somewhere,” and I get it back with the snippet and a link to wherever I stashed it, whether that was a throwaway post or a row buried in a list. My scattered links stop being a graveyard I dig through and turn into something I can actually ask questions of. Subscriber-gated, computed once and cached, and cheap enough that I’d leave it running without watching the bill.

Next up in Part 6, I want to point this same durable-fan-out machinery at bulk work: tag every untagged message, add twenty rows matching a criterion, backfill previews across everything, and do it without re-billing the model for the forty-six items that already succeeded before item forty-seven fell over.

Adron brainstorming and working on InterlinedList.

The Cron That Double-Posts and the Calendar I Actually Want

Part 4 of 8: Durable AI for InterlinedList


Most of this series is me thinking out loud about features that don’t exist yet. This post is different as the bug is real. It’s in the code today. 😬

Scheduled publishing is a Subscriber feature, and so is the cross-posting it rides on. You queue a post, set a time, and a cron fans it out to Bluesky, Mastodon, LinkedIn, and X when the moment arrives. Both sit behind the subscription, so this bug hits paying subscribers. The machinery that runs it has a hole in it I don’t love.

The Cron That Can Post Twice

Walk through what the code does. app/api/cron/publish-scheduled-messages/route.ts runs every minute. It selects every message where scheduledAt <= now, then loops over them. For each one it fans out to whatever platforms the user configured, collects the resulting URLs, and then, as the last step, updates the message row to null out scheduledAt so the message is no longer “due.”

Read that ordering again. The cross-post happens first. The “mark it done” write happens second. No lock. No idempotency key. No transaction spanning the fan-out and the update. Those two operations are just sequential, and hopeful.

So picture the fan-out succeeding. Your post lands on Bluesky and Mastodon. Great. Now the message.update that’s supposed to clear scheduledAt hits a reaped Neon connection, or a transient network blip, or any of the ten boring things that make a database write fail once in a while. The update throws. The message row still has scheduledAt <= now.

One minute later the cron runs again. The message is still due. It fans out again. Same post, second time, to every platform. That’s your double-post.

The code isn’t naive about this: there’s a withPrismaRetry wrapper on that critical update and a comment that literally says “if this fails the message stays due and is re-published.” Someone saw the cliff and put up a guardrail. But a retry is a mitigation, not a fix. It narrows the window without closing it. If the retries exhaust, or the process dies between the fan-out and the update, you’re back on the cliff. And the fan-out itself is best-effort per platform (LinkedIn can fail while Bluesky succeeds, with no compensation), so a re-run doesn’t even cleanly re-do the same thing. It re-does some of it.

This is a slow-moving disaster. It doesn’t fire on every post. It fires on the unlucky ones, occasionally, in production, and it makes your platform look like it can’t be trusted to post your thing exactly once.

The Working-With-Claude Lesson: Point at the Bug Class, Not the Bug

If you take one thing from this post, take this part, not the fix. It’s worth more.

If I open a session and say “fix the double-post in publish-scheduled-messages,” I get a patch that treats the symptom. Maybe it wraps more things in retries. Maybe it adds a published boolean and checks it. Narrow prompt, narrow fix, and I’ve probably left three other copies of the same hazard sitting in the codebase untouched.

So I don’t open it that way. I say: “Audit this scheduled-publish path for concurrency and idempotency hazards.”

That framing is doing real work. It doesn’t presume I already know what’s wrong. It asks the agent to reason about the class of failure: what happens under a partial failure, what happens if two invocations overlap, what’s the ordering of side effects versus state writes, where’s the operation that isn’t idempotent. The double-publish race falls out of that audit as one instance of a general pattern (side effect before commit, no dedup key). And the audit usually surfaces siblings I hadn’t thought to ask about.

This is the two-step I keep coming back to with Claude on anything gnarly. Audit for the class, then delegate the conversion. First get the agent to characterize the whole shape of the problem. Then, once you both understand it, hand over the mechanical rewrite. Collapse those into one “fix it” prompt and you’ll get a bandage every time.

Most of the value is in the audit. The conversion is mostly typing, and Claude is very good at the typing once you’ve done the thinking with it.

The Fix: A Schedule, a Workflow ID, and a Saga

So this is the conversion I’d delegate. It’s a proposal. None of it is built yet.

Replace the every-minute cron with a Temporal Schedule that starts one workflow per due message. The whole trick is what you use for the workflow ID.

// Proposed: the message id IS the workflow id.
// Temporal refuses to start a second workflow with an id that's
// already running or already completed. That's the dedup.
await client.workflow.start(publishScheduledMessage, {
  workflowId: `publish-message:${message.id}`,
  taskQueue: "publishing",
  args: [{ messageId: message.id }],
});

Temporal deduplicates by workflow ID. If a workflow with publish-message:abc123 has already run (or is running right now), a second start with that same ID is rejected or returns the existing handle, depending on the reuse policy you pick. The message cannot be published twice, because you cannot start its publish workflow twice. The race doesn’t get narrower. It closes. There’s no second racer allowed on the track, so there’s nothing left to race.

That’s a different guarantee than “we retried hard enough that it usually works.” One is a hope with good odds. The other is impossible by construction, and I’ll take impossible.

Then model the fan-out as a saga instead of a hopeful loop. One activity per platform: postToBlueskypostToMastodonpostToLinkedInpostToTwitter, the exact post-status.ts functions the cron already calls, plus the same splitTextForPlatform and resolveLinkedInTarget logic. Each activity retries independently with backoff, and each one’s success is durably recorded before the next runs. If LinkedIn fails after Bluesky succeeded, the workflow knows Bluesky is done and won’t re-do it. It records the half-failure coherently and runs compensation, instead of silently re-posting the whole set on a blind re-run.

And the thing that makes this an easy call: publishing uses zero LLM. No model in this loop at all. No tokens, no inference cost, nothing to govern with a budget. It’s pure durability engineering, a straight win against a present-day production hazard with no ongoing cost attached. If anything in this series jumps the queue, it’s this.

The Calendar I Actually Want

Once publishing is durable, the thing I’ve wanted for ages becomes buildable. This part is squarely future, gated behind AI assistance that’s still marked Coming Soon.

I want to type “give me a month of posts about distributed systems, three a week” and get a filled calendar back. Not a wizard. Not twelve separate button clicks. A month.

The cheap way to build that leans on batching. One LLM call produces all N drafts at once, and batching many items into a single prompt is dramatically cheaper than N separate calls (the cost thesis that runs through this whole series). The workflow takes those drafts, schedules them across the month, and fills the gaps. Then, to keep it cheap, you can reject or regenerate an individual slot. Don’t like Thursday’s post? Regenerate just that one. Only the rejected slot re-bills. You never repay for the twelve you kept.

The same shape covers scheduled digests: a Temporal Schedule that drafts you a weekly summary doc from the week’s messages. But put a cheap guard before the model runs. Did this user post anything this week? If not, skip the LLM entirely. You never pay to generate an empty digest for a quiet week. The guard is a database count. The model is the expensive part. You gate the expensive part behind the cheap check, which is the move I keep making all through this series: spend tokens last, and only when there’s something worth spending them on.

The calendar is the feature I daydream about. The double-publish fix is the one I’d ship first, because it’s a real hole in production right now and closing it costs nothing but the work.

Part 5 goes hunting through everything you’ve saved: the links buried in your messages, docs, and list rows, and how I’d crawl and cache them so “where did I save that article?” finally has an answer.

Adron brainstorming and working on InterlinedList.

Where Does Temporal Even Run When You’re All-In on Vercel?

Part 2 of 8: Durable AI for InterlinedList


In Part 1 I talked myself into Temporal as the orchestration layer for the AI features I want to build. Durable memoization, a model cascade, a hard budget gate: all the mechanics that push the LLM bill down. Good story. Then I hit the question that stops most “let’s just add Temporal” plans cold, and it stopped me too.

Where does it actually run?

InterlinedList is 100% serverless on Vercel. Every route is a function that spins up, does its thing, and gets torn down. That model is wonderful right up until you need something that never stops running. And Temporal needs exactly that.

The Two Parts That Don’t Fit

Temporal has two moving pieces, and neither of them is a serverless function.

The first is the Temporal Service, the durable-state backend. It’s the thing that remembers where every workflow is, what’s completed, what’s pending, and what to retry. It’s stateful infrastructure. You don’t invoke it and let it die.

The second is the Worker. A worker is a long-lived process that polls a task queue, picks up work, and runs your activities. Polls it continuously. As in, forever. A Vercel function that shuts down after a request cannot be a Temporal worker, because the polling loop dies the instant the function returns. You can’t flag your way out of this in a config file. It’s a straight-up mismatch between “poll a queue for hours” and “respond in seconds then vanish.”

So for a while this felt like a dead end. Adding Temporal seemed to mean un-serverless-ing the whole app, and I did not build InterlinedList on Vercel just to go stand up a fleet of always-on boxes.

One New Always-On Thing. That’s It.

The resolution I kept circling back to is smaller than the fear suggested. I introduce exactly one new always-on component: a small @temporalio/worker process running somewhere cheap and boring, like Fly.io, Railway, or a small VM. Something in the five-to-twenty-dollars-a-month range. Alongside it, either self-hosted Temporal OSS or a low-tier Temporal Cloud namespace for the service.

That’s the entire footprint. One worker, one service. Vercel doesn’t change shape at all.

What does change is what the Vercel routes do. They stop doing work and become thin clients. Authenticate with the getCurrentUser / getCurrentUserOrSyncToken helpers I already have, gate with isSubscriber, start a workflow, then hand the UI a workflow id to poll. The route never does the long-running work itself. It just points at it.

The route sketch is almost embarrassingly short:

export async function POST(request: NextRequest) {
  const user = await getCurrentUserOrSyncToken(request);
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  if (!isSubscriber(user.customerStatus)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });

  const handle = await client.workflow.start(generateArtifactWorkflow, {
    taskQueue: "ai",
    workflowId: `gen-${user.id}-${crypto.randomUUID()}`,
    args: [{ userId: user.id, prompt }],
  });
  return NextResponse.json({ workflowId: handle.workflowId });
}

Auth, gate, start the workflow, return the id. That 16k-token document generation that would blow right past the function’s duration budget? It never runs in the function. The function kicks it off and gets out of the way.

The Reuse Win: No Second Codebase

This is the part that made me stop worrying. The worker is not a new codebase. It imports the same lib/ code the app already runs.

The activities (the actual units of work Temporal executes) call into modules that already exist or are already planned. safeFetch from lib/security/ssrf.ts for any link crawling, so the SSRF guards apply exactly as they do today. detectLinks from lib/messages/link-detector.ts. The post-status.ts modules under lib/bluesky/ and lib/mastodon/ for cross-post fan-out. The DSL parser and validator in lib/lists/dsl-*.ts for anything list-shaped. And the AI writer library from the companion genai plan, lib/ai/, which doesn’t exist yet but will be wrapped as activities rather than rewritten.

Zero business-logic duplication. That matters more than it sounds. It means the IDOR checks and subscription gating I already trust stay exactly where they live. A worker activity that writes a list row runs through the same where: { id, userId } ownership filter as the web route. I’m not maintaining two versions of “can this user do this” and praying they stay in sync. There’s one version, and the worker just calls it.

What the Foundation Actually Contains

Before any feature ships, there’s a foundation phase, the groundwork everything else in the plan stands on. It’s the worker, the service, the client wiring, and then a small set of cost-control primitives that every later proposal reuses:

  • An llmCall activity with a model-cascade policy (cheap model first, escalate only when the work demands it) and per-target max_tokens ceilings: roughly 1k for a message, 4k for a list, 16k for a document.
  • An assertBudget activity that reads spend before it lets a call through, so a runaway job throws instead of burning credits.
  • response cache keyed by hash(system + prompt + model), so identical requests never re-bill.
  • Task queues with rate limits tuned to whatever the provider or the user’s BYO key allows.

Net-new dependencies are modest: @temporalio/client on the Vercel side, the @temporalio/worker / workflow / activity trio on the worker host, and one LLM SDK. That’s the whole shopping list. (AI assistance in the product is still Coming Soon. This is me brainstorming the machine that would power it, not shipping it.)

Scaffold the Unfamiliar, Own the Verification

I’ve been doing a lot of this alongside Claude lately, and one lesson keeps repeating.

A durable-execution worker is new territory for me. When something’s unfamiliar the instinct is either to avoid it or to throw the whole thing at the model and hope. Both are wrong. What works is to spec the seams and delegate the boilerplate.

I give Claude the hard constraints, not vibes. “The app is serverless on Vercel and stays that way. Reuse this lib/ code, here are the exact modules. Here are the auth and gating seams. The worker imports, it does not reimplement.” With those rails, Claude is great at generating the worker scaffold and the client wiring, the boilerplate I’d get subtly wrong on the first pass because the framework is new to me.

But I do not let it hand me generated infrastructure and call it done. I own correctness, and for a durable workflow, correctness has a specific proof: a Temporal replay test using TestWorkflowEnvironment that simulates a worker crash mid-workflow and asserts the workflow resumes deterministically, picking up from the last completed activity instead of re-running (and re-billing) the ones already done.

That test does the real work. It’s how “a retry doesn’t re-pay for the LLM call” stops being a claim in a blog post and turns into something I’ve watched go green in CI. The model writes the code I don’t know how to write yet, and I write the test that proves it’s right.

With the foundation standing and provably resumable, Part 3 turns it into the first real feature: a Generate button that survives a timeout and asks before it writes a single thing.

Adron brainstorming and working on InterlinedList.

I’ve Been Brainstorming Durable AI for InterlinedList, and It Kept Pointing at Temporal

Part 1 of 8: Durable AI for InterlinedList


I’ve been chewing on a question for weeks. If InterlinedList is going to have real AI features (the “Coming Soon” ones on the pricing page, the ones I actually want to use myself) what does the plumbing behind them look like? Not the prompts. The plumbing. And every time I sketched it out, I hit the same wall in the same four places.

So I did the thing I’d tell anyone else to do. I stopped sketching in a vacuum and pointed Claude at the actual repository.

The Wall Is Serverless, and I Put It There

InterlinedList is 100% serverless on Vercel. That was a deliberate choice and mostly a great one. It’s also the thing shaping every AI idea I have, whether I like it or not.

A serverless function has a duration ceiling. It has no durable memory between requests, it can’t pause and wait for you to click “yes,” and when it fails partway through expensive work, it has no idea what it already paid for. For a request/response app that serves pages, none of that matters. For anything that calls a language model, all of it matters. A lot.

Here are the four places I kept getting stopped.

Scheduling is already fragile, and this one isn’t hypothetical. InterlinedList has scheduled publishing today (a Subscriber feature). The cron at app/api/cron/publish-scheduled-messages/route.ts runs every minute. I checked vercel.json, it’s a literal * * * * *. It fans a due post out to Bluesky, Mastodon, LinkedIn, and X, then flips the message out of “scheduled” state. There’s no lock and no idempotency key. So if the cross-posts succeed but that final UPDATE fails (a reaped Neon connection, a cold-start hiccup) the message stays “due” and the next tick republishes it. To every platform. Again. That’s a slow-moving double-post waiting for a bad minute, a bug I already own, sitting in production, with zero AI anywhere near it.

Long generation doesn’t fit. A 16k-token document, streamed out of a model, will blow past a serverless function’s budget before it finishes. There’s no clean way to say “keep going, I’ll be back.”

Batch and agentic work has nowhere to live. “Tag every untagged message.” “Add twenty rows to this list matching these criteria.” “Research my saved links and draft a doc.” Those are multi-minute, multi-step, retry-heavy jobs. A function that dies at ninety seconds is the wrong shape for all of them.

And there’s no cost governor. This is the one that kept me up. Every retry re-bills the model. There’s no budget ceiling, no cheap-model-first policy, no response cache, no dedup. One runaway loop and you’ve torched real money.

The Expensive Line Item Is Never the Infrastructure

Once I reframed the money question, the design mostly wrote itself. This reframe runs under the whole series, so it’s worth stating plainly.

The expensive thing about AI features is tokens, not servers. Basically never servers.

A durable-workflow engine (Temporal, in this case) costs on the order of tens of dollars a month, flat. One small always-on worker, maybe a managed namespace. That number does not move when your users get busy. The model bill does, and it moves a lot.

So the question isn’t “can I afford Temporal.” It’s “does Temporal make the model bill smaller.” And the reason it kept showing up in my brainstorm is that its mechanics do that, structurally:

  • Durable memoization. A completed step is persisted. When a six-step pipeline fails at step five, the retry re-runs step five, not the four already-billed model calls before it. A retry never re-invokes an LLM call you already paid for. This is the single biggest saver in the whole list.
  • Model cascade. Cheap model first (Haiku, Flash) and escalate to an expensive tier only when the work demands it, or the user explicitly asks for long-form.
  • Batching. Tag fifty messages in one call instead of fifty calls. The per-request overhead and the duplicated context collapse.
  • Rate-limit obedience. Cap concurrency to stay under provider limits, so you don’t trip a 429 storm whose retries waste both wall-clock and, on some providers, tokens.
  • Response and URL dedup. Identical prompts get served from cache. A link that’s already been fetched isn’t fetched again. An embedding is computed once, ever.
  • A hard token-budget gate. Check spend before each call and refuse to exceed a per-user ceiling. A leaked key or a runaway agent can’t burn unbounded credits.
  • Confirm-before-spend. Pause and wait for the user to approve the plan before generating the expensive part. You don’t pay to build fifty list rows against a schema the model guessed wrong.

Every one of those pushes the bill down. The infrastructure that enables them is a rounding error against what it saves. And I want to be clear that “cheap as possible, relative to running raw Claude or ChatGPT calls” is why the architecture looks the way it does in the first place. I didn’t bolt cost control on at the end. I designed around it, and everything else grew out of that constraint.

Brainstorm Against Your Real Code, Not a Whiteboard

Now the part about working with an LLM to build this, because that’s what this series is modeling.

I didn’t design this on a whiteboard. I opened Claude, gave it the real repo, and asked it to map the idea onto the code that already exists. It found the every-minute cron and traced the exact failure path where the double-post lives. It pointed at lib/security/ssrf.ts and the safeFetch guard that any link-crawling feature has to route through, and at the link detector and metadata fetcher already sitting in lib/messages/. It also noticed there’s no lib/ai/ directory yet. Nothing generative is built, which lines up with AI being “Coming Soon” rather than shipped.

The output of that session was a grounded proposal (temporal-solutions.md) that complements an earlier BYO-key and MCP plan I’d written. That earlier plan covered what the AI produces and who pays for the tokens. It said nothing about orchestration, durability, or cost. This new one fills that gap, and it fills it against real file paths, not imagined ones.

Steal this if it’s useful. Don’t design architecture in a vacuum and then go hunting for where it fits. Let the agent read the actual repository first. Have it map your idea onto the code that exists, name the real hazards, and pressure-test it into something concrete before a single line of implementation. Explore first. The proposal you get back is grounded because the agent actually went and looked.

There’s a catch I want to name up front, because I try not to write brochure copy. Temporal has parts that fundamentally cannot run on Vercel serverless: a durable-state backend and long-lived worker processes that poll continuously. That’s a real new piece of always-on infrastructure in an app I deliberately built to have none. It’s the scariest unknown in the whole plan, and I’m not going to pretend it away.

Next post, I go straight at it: where Temporal actually runs when you’re all-in on serverless, and how the Vercel side stays a three-line client that just kicks off a workflow.

Adron brainstorming and working on InterlinedList.

That Gap Is Still Widening, But The Bottleneck Was Never Engineering

In my previous post, The Gap Is Widening and It’s Not Slowing Down, I focused on the growing divide between individuals and teams that have embraced Generative AI and those that have not. That divide is real, measurable, and growing faster than many people seem willing to acknowledge. I’m not walking any of that back.

But after watching organizations over the last year attempt to integrate AI into their engineering practices, I’m increasingly convinced the widening gap isn’t actually about AI – at least not entirely. The technology is only exposing something that has existed for decades. The bottleneck was never engineering. It was never software development. It was never the people building things.

The bottleneck has almost always been the machinery *surrounding* the people building things: the approvals, the reporting structures, the committees, the prioritization processes, the disconnected leadership layers, the bureaucracy, the politics – the endless collection of organizational systems that somehow manage to consume vast amounts of energy while producing remarkably little forward progress. Generative AI didn’t create this problem. It simply made it impossible to hide.

Individual Engineers Are Experiencing a Massive Productivity Expansion

There is very little debate left about whether Generative AI increases the productivity of individual contributors. We’ve moved well past that question. The debate today isn’t whether productivity gains exist – it’s about how *much* gain exists and, more importantly, who is actually capable of capturing it.

Engineers today can create prototypes in hours that once required days or weeks. Architectural alternatives can be explored in an afternoon instead of consuming entire sprints of spike work. Documentation can be generated, revised, and maintained at speeds that would have seemed unrealistic even five years ago. Testing frameworks, infrastructure automation, deployment pipelines, and application scaffolding can all be stood up dramatically faster than before.

Even beyond code generation, AI has become an accelerator for thinking itself. It provides rapid feedback loops, architectural critiques, alternative approaches, and research capabilities that allow engineers to move through uncertainty faster than they ever have before. An engineer operating effectively with modern tooling can often accomplish what previously required several engineers. A small team can frequently deliver what once demanded a much larger one. This isn’t speculation anymore – we’re watching it happen every day.

Yet despite these gains, many organizations report only marginal improvements in overall delivery speed. Why? Because software development was never the slowest part of the process.

The Bottleneck Was Hiding Somewhere Else

For years, organizations convinced themselves that software engineers were the constraint. The logic seemed simple enough: if projects are late, development must be slow; if features take too long, engineering capacity must be insufficient; if delivery struggles, more developers must be needed. So organizations hired more engineers, added process around them, and watched delivery timelines stay roughly the same.

Then AI dramatically accelerated the development side of the equation – and something interesting happened. The overall system didn’t accelerate proportionally. Instead, the delays became easier to identify. Features completed quickly sat for weeks waiting for approval. Product decisions that took months were backed by implementation work that took days. Architecture reviews became calendar-management exercises instead of engineering exercises. Governance processes consumed more time than development itself, and procurement delays stalled technology adoption before it could even begin.

The moment development became faster, every other inefficiency suddenly became visible. The tide went out and revealed the rocks – and there were far more rocks than most organizations were prepared to acknowledge.

We’ve Been Trying To Solve This Problem For Over A Century

One of the most frustrating aspects of this situation is that the underlying problem isn’t new. Some of the most influential thinkers in management spent their entire careers attempting to solve exactly these issues, and we largely ignored them.

Chief among them was W. Edwards Deming. His work fundamentally reshaped manufacturing, quality management, and organizational thinking throughout the twentieth century. His influence helped transform post-war Japanese manufacturing and directly shaped the practices that would eventually become Lean methodology and the Toyota Production System. One of Deming’s most important observations was that organizations routinely blame individuals for failures that are actually caused by systems — the worker gets blamed, the engineer gets blamed, the frontline employee gets blamed, while the actual system producing poor outcomes remains untouched and unexamined.

Deming repeatedly argued that management’s primary responsibility was improving the system itself – not creating more reports, not creating more oversight, not creating more bureaucracy, but actually improving the system. This remains one of the most ignored lessons in modern business. When productivity stalls, organizations add process. When communication breaks down, they add meetings. When delivery slows, they add approvals. When uncertainty increases, they add governance layers. The response is almost always additional complexity, rarely simplification – yet simplification is often exactly what’s needed.

The Toyota Way Was Never About Manufacturing

One of the most persistently misunderstood management concepts in business is the Toyota Production System and the principles described in *The Toyota Way*. Organizations study Toyota and immediately focus on manufacturing techniques, kanban boards, and production flow. That completely misses the point.

The true innovation was not manufacturing. It was the relentless pursuit of removing waste from systems. Toyota became exceptional because it continuously questioned every activity that consumed effort without creating value – every unnecessary handoff, every unnecessary delay, every unnecessary approval, every unnecessary process step. Everything was examined through the lens of whether it actually moved something of value forward. If it didn’t, it was a candidate for elimination.

Modern software organizations often claim to embrace these ideas while operating with approval chains that require six or seven layers of sign-off, organizational structures where decisions travel further than the code itself, and workflows designed to optimize reporting while actively damaging delivery speed. The language of Lean has become extremely popular in tech. The discipline required to actually implement it remains genuinely rare. There’s a meaningful difference between saying “we practice continuous improvement” and operating a system that systematically identifies and eliminates its own waste – most organizations are doing the former and calling it the latter.

AI Is Exposing Management Debt

The software industry talks constantly about technical debt, and rightly so – I’ve spent decades fighting it. But I’m increasingly convinced that many organizations suffer more from *management debt* than technical debt, and management debt is considerably harder to see from the inside.

Management debt accumulates when organizations create layers of process that never get removed. It accumulates when reporting structures expand indefinitely, when approval chains grow with every re-org, when every novel problem gets solved by introducing another committee, another meeting, another workflow, or another governance layer. Over time these accumulate into a dense friction system that surrounds every team trying to build and ship something. Unlike technical debt, management debt is often invisible to leadership because leadership frequently created it – and organizations don’t typically build mechanisms for evaluating whether the management decisions made five years ago are still earning their overhead.

Generative AI is now exposing these accumulated liabilities with uncomfortable clarity. If engineers can produce ten times more output and delivery only improves ten percent, leadership should not be asking what’s wrong with the engineers. They should be asking what’s wrong with the system. The answer may be uncomfortable. It may involve examining years of accumulated organizational decisions, questioning structures that have become politically entrenched, and acknowledging that the bureaucracy itself is the liability. But that’s where the actual solution lives.

Systemic Thinking Matters More Than Ever

One of the most valuable disciplines organizations can adopt today is genuine systemic thinking – not as a framework to be installed and presented to the board, but as an actual way of seeing how the organization produces its outputs.

The reason it matters comes down to a simple but uncomfortable idea: organizations are systems, and systems produce exactly what they are designed to produce. Not what leadership intends. Not what the org chart implies. What the actual system, with its real incentives and real workflows, is built to produce. Many organizations want innovation while designing systems optimized for risk avoidance. They want speed while designing systems that optimize for approval coverage. They want accountability while designing systems optimized for blame diffusion. They want creativity while designing systems that reward conformity and punish variance.

The outputs shouldn’t be surprising – the system is behaving exactly as designed. Generative AI doesn’t alter this reality. If anything, it amplifies it. The faster individual contributors become, the more visible systemic dysfunction becomes. You can’t paper over a broken approval process with faster code generation. You just end up with more finished work sitting in queues.

The Competitive Advantage Isn’t AI

Here’s something I genuinely believe will be borne out over the next five years: the next generation of competitive advantage is unlikely to come from simply adopting AI. Everyone will eventually have access to similar models. Everyone will have copilots, agents, and increasingly capable automation. The models will keep getting better and access will continue to become more democratic. Access is not a moat.

The differentiator will be organizational capability – specifically, whether the organization can actually move. Can it make decisions quickly? Can it remove friction from its own processes? Can it empower teams to act without running everything through three layers of approval? Can it identify waste and eliminate it rather than building process around it? Organizations that can answer yes to these questions will compound the productivity gains AI provides and experience something genuinely transformative. Organizations that answer no will continue wondering why expensive AI investments fail to produce the results they see in press releases and conference talks – and they’ll blame the technology.

The Answers Already Exist

What’s remarkable about all of this is that very few of these ideas are new. Deming wrote extensively about them. Lean practitioners have written extensively about them. Systems thinkers like Peter Senge have written extensively about them. Toyota demonstrated them repeatedly over decades. The playbook already exists. It just requires organizational will to actually use it.

Generative AI simply raises the stakes. For decades, organizations could survive despite bureaucratic inefficiencies because software creation itself was difficult enough that organizational dysfunction stayed hidden behind the sheer complexity of engineering work. That cover is disappearing fast. The engineering side of the equation is accelerating rapidly, and the remaining constraints are becoming impossible to ignore.

If I had to estimate — and I’m willing to commit to this number — I’d say the overwhelming majority of organizations, probably 90% or more, still carry enough management debt, process debt, and bureaucratic drag to prevent them from realizing even half of the value Generative AI could deliver. The technology is arriving right on schedule. The organizations are not.

Organizations that fail to introspect, simplify, and dismantle their accumulated management structures will realize only a fraction of what’s possible. Organizations that embrace systems thinking, Lean principles, continuous improvement, and genuine organizational simplification will unlock extraordinary advantages — not because AI magically transformed their business, but because they finally removed the barriers that had been slowing them down all along.

The gap is widening. But the bottleneck was never engineering. It was management. And management now has nowhere left to hide.

Further Reading

These are the thinkers and resources worth going deep on if you want to move beyond reading about these ideas and actually do something about them.

  • W. Edwards Deming — Start with his Fourteen Points for Management, then read *Out of the Crisis*. His work is the foundation for almost everything else on this list.
  • The Deming Institute — The most accessible ongoing resource for understanding and applying Deming’s system of profound knowledge in modern organizations.
  • *The Toyota Way* by Jeffrey Liker — The definitive English-language treatment of Toyota’s actual management philosophy, not just the production tools. The tools without the philosophy are just theater.
  • Toyota Production System — Understanding the origins and evolution of TPS is worthwhile context before diving into the derivative frameworks that have followed it.
  • Lean Enterprise Institute — Practical, applied Lean thinking for people who want to actually implement rather than just read about it.
  • *The Fifth Discipline* by Peter Senge — The foundational text on systems thinking in organizations. If you read one book from this list, make it this one.
  • *The Goal* by Eliyahu Goldratt — Theory of Constraints explained through a novel. Surprisingly readable and genuinely transformative for how you see bottlenecks. The production setting feels dated; the ideas do not.
  • Kaizen / Continuous Improvement — Understanding what kaizen actually means in practice versus how the word gets casually deployed in tech organizations is worth the time. The gap between those two things is significant.