I have a handful of small apps that recommend things — what to watch on a Friday, where to take the family, what to put on a playlist. All of them are LLM-shaped: gather some facts, hand them to a model with instructions about my taste, get a suggestion back.

For a while, “my taste” was a paragraph inside a TypeScript file.

That’s the problem. Taste changes constantly and code deploys don’t. Every time I wanted to adjust what counts as a good pick — no, not that kind of horror; yes, rewatches are fine; stop suggesting things I already own — I was editing source and shipping a deploy. Preference had become an engineering task, and it shouldn’t be.

So now taste lives in my notes and the apps read it while they run.

The whole mechanism is 24 lines

My Obsidian vault gets published as a static site. I added one endpoint to it. Any note whose frontmatter contains type: <name>-profile gets published to /profiles.json, keyed by <name>:

export async function GET() {
  const { index } = await getVaultContext();
  const profiles: Record<string, unknown> = {};
  for (const e of index.entries) {
    const t = e.frontmatter.type;
    if (typeof t !== 'string' || !t.endsWith('-profile')) continue;
    profiles[t.slice(0, -'-profile'.length)] = {
      path: e.path,
      title: e.title,
      frontmatter: e.frontmatter,
      body: e.body.trim(),
    };
  }
  return new Response(JSON.stringify({ profiles }, null, 2), {
    headers: { 'Content-Type': 'application/json' },
  });
}

That’s it. There’s no registry, no config, no “register a new profile” step. Write a note called watch-profile.md with type: watch-profile in the frontmatter and it appears at profiles.json under watch. Adding a whole new taste domain costs zero lines of builder code.

The contract is one sentence: frontmatter is structured fields, the body is LLM context. Kids’ birthdates live in frontmatter so a consumer can compute ages in code; nothing that can be derived is ever written down as a derived value, because a stale computed number in a prompt is a lie with a long half-life.

The consumer side is 36 lines, and the caching is the opinionated part

An app fetches profiles.json, caches it for fifteen minutes, and — this is the deliberate part — never negatively caches.

If the fetch fails, the app refreshes the cache timestamp but keeps serving the last profile it successfully retrieved. It backs off so it isn’t hammering a dead host, but it does not fall back to “no taste.” The reasoning I wrote at the time: a vault hiccup must never break a pool build. Degrading to last known taste is fine. Degrading to no opinions at all means the model quietly reverts to recommending whatever’s popular, and nobody notices for a week.

When the taste reaches the model, it goes in verbatim — the markdown body, unmodified, as a labeled block in the prompt:

==== TASTE PROFILE (vault:personal/media/watch-profile.md — ground truth) ====

No summarizing, no extracting fields into a template. The note is the prompt fragment. Which means the note has to be written well, and that turns out to be the actual work.

Writing a good profile is a craft, not a config file

My first instinct was to write a genre list. That produces a slot machine — things get filtered by category and nothing gets surfaced.

What works is writing a curator’s brief. The watch profile opens with a behavioral mandate before any preference at all:

Be a curator, not a slot machine. Every pick needs a reason tied to a lane below — “this is your Berg lane, but from a director you haven’t tried” beats a star rating every time. Surface, don’t just filter. The whole point is the great pick he hasn’t heard of, pitched with why it fits. Guess-and-check from ratings is failure.

Then lanes, each with a named anchor so the model has a fixed point instead of a vibe: the old-school comedy canon (Happy Gilmore tier, rewatches welcome forever, view counts should never bury these); true-story military where Black Hawk Down is the stated benchmark; prestige that earns it (the Sorkin/Fincher lane); and new-and-trending, because a hot release he’s heard of beats a deep catalog cut he hasn’t.

My favorite idiom in the whole system is how it handles dislikes. Not a blocklist — a ceiling:

Horror: hard no — with a ceiling, not a wall. The Silence of the Lambs tier is the cap: prestige psychological thrillers with horror texture are really the crime lane and are fine. Slashers, supernatural, gore-for-gore are out; if the scares are the point rather than the story, skip it.

A wall would have excluded Silence of the Lambs. A ceiling with a named example at the boundary tells the model exactly where the line is and lets it reason about new cases. Every “avoid” rule I’ve written since is a ceiling.

The family-trip profile uses a different shape for a different problem: hard constraints that reject versus preferences that rank. No buffet resorts and no bus tours are rejections. Nonstop flights and warm weather are rankings. Collapsing those two into one list is how you get an itinerary that’s technically acceptable and actually wrong.

One more convention that’s carried its weight: inline *(confirm)* tags next to anything I guessed, alongside *(known)* and *(house rule)* for settled facts. The profile only got sharp after I went through and corrected the guesses — which is an argument for writing down your uncertainty rather than asserting things and finding out six recommendations later.

The hard part is a network boundary

Most of my infrastructure lives on a private mesh network. My Cloudflare Workers do not, and cannot — a Worker running on Cloudflare’s edge has no route into my tailnet, and no amount of wanting will give it one.

So the data flows the other direction. A cron script on my home server pulls the app’s payload — including the taste block — and POSTs the whole thing out to the Worker over public HTTPS. Two structurally different trust models for two classes of consumer — things inside the network reach for what they need, things outside get handed snapshots — and once that rule had a name, the architecture stopped being confusing.

Worst-case propagation, end to end: about thirty minutes. Ten for the vault’s auto-commit to fire, five for the site rebuild cron, fifteen for the consumer’s cache TTL. I edit a markdown file on my phone and half an hour later a Cloudflare Worker three thousand miles away is reasoning with the new version. No deploy, no build, no pipeline run.

What’s still broken

Two caveats, because the pattern isn’t as clean as the pitch.

The trips app doesn’t actually do this. It keeps a hand-copied version of the trip profile in its own repo, which means editing the note in Obsidian does not reach it — someone has to re-copy the file. It shipped the same day as the profiles endpoint and never got wired up. The “zero deploys” promise currently holds for one of my two consumers.

And I have a long, detailed golf profile that the system has never once read, because it predates the convention and has no frontmatter at all. The endpoint filters strictly on type: *-profile, so a beautifully written taste document is simply invisible until someone adds one line. Opt-in via a single key is the right design — but it fails silently, which is the wrong failure.

The rollout also nearly didn’t ship, for reasons that had nothing to do with it: this is the change that exposed the frozen-deploy bug from the routing post — the build server had been serving stale code for weeks, and my missing endpoint was what finally gave it away.

Why I’d do it again

The line I wrote in my notes that afternoon still holds up: most apps have no live-config layer — they’re hardcoded or environment-variable-only. Separating what changes (taste, in prose) from what ships (code) turns behavior changes into editorial work instead of engineering work.

The plumbing is paid for. Adding a new taste-driven domain — music, task triage, workout programming — costs one well-written markdown file and a fetch call. I spend my time arguing with myself about what makes a good recommendation, in prose, in Obsidian, on my phone.

That’s a much better use of an afternoon than a redeploy.