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.

A Generate Button That Survives a Timeout and Asks Before It Writes

Part 3 of 8: Durable AI for InterlinedList


In Part 2 I talked myself into a durable worker sitting next to Vercel, reusing the lib/ code the app already trusts. That was the plumbing. Now for the first feature I’d actually run through it, a Generate button, and the two things I won’t ship it without.

One: it has to survive a timeout. Two: it has to ask before it writes.

I keep calling those hard requirements because they are. They’re the reason this design points at a durable workflow instead of another POST handler. And to be clear up front, since AI assistance in the product is still marked Coming Soon: none of this exists yet. I’m brainstorming out loud before I hand pieces of it to Claude.

Two Ways a Naive Generate Button Fails

Picture the obvious version. A route that takes a prompt, calls a model, writes the result. Works great in the demo. Then two things happen.

First, someone asks for a long document. Sixteen thousand tokens of generated Markdown streaming back through a serverless function that has a hard duration ceiling. The function dies mid-stream. The user gets nothing, and depending on where it died, maybe a half-written doc. That’s not a fluke. Any generation big enough to be worth doing is big enough to hit the ceiling.

Second, and this is the one that scares me, the model writes straight into your data. For a plain message, fine, you can delete it. For a List schema, that’s a slow-moving disaster. A list’s schema is a set of ListProperty rows parsed from the DSL, and once rows of data hang off those properties, unwinding a wrong guess gets expensive fast. An AI that invents seven columns and commits them before you’ve looked is a terrible first impression. Undoing a bad schema is the wrong goal. You want to never have written it in the first place.

So the design has to fix both: don’t let a long job die in a function, and don’t write anything the user hasn’t confirmed.

Suggest, Then Generate: Two Endpoints on Purpose

The split I keep coming back to is two endpoints, and the difference between them carries the whole safety story.

/suggest returns a validated artifact and writes nothing. /generate writes.

/suggest is the default path. It produces the thing (a doc draft, a list schema, a set of rows), validates it, and hands it back to the UI for a human to look at. Nothing touches the database. /generate is the second half, and it only runs after a person confirms.

In the durable version, this isn’t two disconnected calls. It’s one workflow that pauses in the middle. The route starts a workflow and returns an id. The workflow generates the artifact, then blocks on a Signal (Temporal’s native “wait for an external event”) until a confirm arrives. Long documents don’t block a function while this happens; the UI polls the workflow and reads progress through a Query. The confirm route (/generate/[workflowId]/confirm) sends the signal, the workflow wakes up, and then it writes.

// Sketch of the workflow body (not shipped, this is the design I'd hand off).
export async function generateArtifactWorkflow(input: GenerateInput) {
  // 1. Draft on a cheap tier. Result is memoized by Temporal.
  const artifact = await generateArtifactActivity(input);   // Haiku/Flash draft
  const validated = await validateArtifactActivity(artifact); // untrusted, checked

  // 2. Pause here. No function held open. No tokens spent waiting.
  setHandler(confirmSignal, (decision) => { confirmation = decision; });
  await condition(() => confirmation !== undefined);

  if (confirmation === "reject") return { status: "discarded" };

  // 3. Only now do we write, reusing the same lib/ writers the app trusts.
  return await writeArtifactActivity(validated, input.userId);
}

The part I like: while that workflow is parked on the signal, it costs nothing. No held-open request, no tokens burning, no worker thread stuck. Temporal parks it and moves on. You cannot do that inside a serverless handler, and it’s exactly the behavior a confirm gate needs.

Prompt to List Is Where This Really Pays Off

The flagship case, the one I’d build the whole pattern around, is “build me a list from a sentence.”

You type “a reading list with title, author, status, and a rating out of five.” The workflow generates a DSL schema, not rows. It renders that schema for you to confirm. You look at it, maybe you fix the rating field, you say yes. Only then does it batch-generate rows to populate the list.

Two phases, and the confirm barrier sits between them for a reason: no row-generation tokens get spent until the schema is confirmed. If the model guessed the schema wrong, you caught it before paying to fill fifty rows into the wrong shape. So confirm-before-spend does double duty here, better UX and a cost control at once, which is the same thread running through this whole series. The cheapest token is the one you never send.

And I’m not inventing new validation to do this. The DSL already has the pieces. validateDSLSchema and parseDSLSchema both live in lib/lists/dsl-parser.ts today, and they’re what turns DSL into ListProperty rows for real, human-authored lists right now. The generated schema goes through the exact same door. Then lib/materialize/build-list.ts builds the actual list. The AI path reuses the trusted path; it doesn’t get a shortcut around it. (Lists are a Subscriber feature, so this whole flow lives behind that gate. The workflow checks it before writing, same as every other list-creating action.)

Model Output Is Untrusted Input

One mental flip makes the rest of the design fall out easily. Model output is untrusted input. Treat it exactly like a request body from a stranger.

You’d never take a JSON blob off the wire and write it to your database without validating the envelope shape, checking the types, and confirming the user is allowed to do the thing. A model’s output gets the identical treatment: envelope shape first, then DSL validation, then per-row validation, then ownership and subscription gating. Only something that survives all four gates gets written. If the model hallucinates a field type the DSL doesn’t support, validation rejects it the way it’d reject any malformed request. The model doesn’t get trusted just because it’s ours.

How I’d Hand This to Claude

If there’s one working-with-an-LLM lesson in this post, it’s this: design the contract, delegate the wiring.

There’s a trust boundary running through this feature, and I want to own every inch of it myself. Three pieces I write by hand: the typed artifact envelope (what a generated doc/list/schema/row is shaped like), the validators (envelope, then DSL, then per-row, then gating), and the ownership and subscription checks. That’s where a subtle mistake means a wrong schema gets written or a free user slips past a gate. I don’t delegate that. A plausible-looking version isn’t good enough. I want to have written it.

Everything around that contract is the kind of thing I’d hand to Claude. The workflow body. The signal handler and the condition wait. The polling glue and the Query that streams progress. The confirm route. It’s durable-execution boilerplate: Claude writes it well, and it’s fast to verify against a contract I already defined.

The hand-off is concrete. I give Claude the typed artifact schema and one rule stated plainly: “validate model output like a request body (envelope, then DSL, then per-row, then gating) and never write it through raw. Here are the validators; call them, don’t reimplement them.” Then I let it build the durable plumbing around that. I own the trust boundary, Claude owns the machinery that carries data across it, and the review stays easy because I already know where the sharp edge is. I made sure it’s mine.

A Generate button that can’t die mid-job and won’t write behind your back is the piece I want first. Next in Part 4, I’ll turn to the cron that already double-posts in production today, and the content calendar I actually want built on top of a scheduler that doesn’t.

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.