I have a set 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 have the same LLM shape. Collect some facts. Give the facts to a model with instructions about my taste, and get a suggestion back.

For a while, “my taste” was a paragraph inside a TypeScript file. My personality had a build step.

That is the problem. Taste changes constantly. Code deploys do not. Each adjustment to what counts as a good pick meant a source edit and a deploy. No, not that type of horror. Yes, rewatches are fine. Stop suggesting things I already own. Preference became an engineering task, and it should not be one.

Now the taste lives in my notes. The apps read the notes while they run.

The full mechanism is 24 lines

My Obsidian vault publishes as a static site. I added one endpoint to it. A note with type: <name>-profile in the frontmatter publishes 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 is all. There is no registry, no config, and no “register a new profile” step. Write a note named watch-profile.md with type: watch-profile in the frontmatter. It appears in profiles.json under watch. A new taste domain costs zero lines of builder code.

The contract is one sentence: frontmatter is structured fields, and the body is LLM context. The kids’ birthdates live in frontmatter, so a consumer can compute ages in code. I never write down a value that code can derive. A stale computed number in a prompt is a lie with a long life.

The consumer side is 36 lines, and the caching holds the opinion

An app fetches profiles.json and caches it for fifteen minutes. Here is the deliberate part: the app never caches a failure.

If the fetch fails, the app refreshes the cache timestamp. It keeps serving the last profile it retrieved. It backs off, so it does not hammer a dead host. But it does not fall back to “no taste”.

I wrote the reason at the time. A vault problem must never break a pool build. A step down to the last known taste is acceptable. A step down to no opinions is not. With no opinions, the model reverts to popular picks, and nobody notices for a week.

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

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

No summary. No extraction of fields into a template. The note is the prompt fragment. So the note must be well written. That is the real work.

A good profile is a craft, not a config file

My first instinct was a genre list. A genre list produces a slot machine. Items get filtered by category, and nothing gets surfaced.

A curator’s brief works. The watch profile opens with a behavioral order, before any preference:

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 come the lanes. Each lane has a named anchor, so the model has a fixed point and not a mood. The old-school comedy canon: the Happy Gilmore tier, where rewatches are welcome forever and view counts must never bury them. 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 that I know beats a deep catalog pick that I do not know.

My favorite idiom in the system is the dislike rule. It is not a blocklist. It is 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 exclude The Silence of the Lambs. A ceiling with a named example at the boundary shows the model the exact line. The model can then reason about new cases. Every “avoid” rule I wrote after that 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. Collapse those two lists into one, and you get an itinerary that passes the rules and is still wrong.

One more convention carried its weight. I put an inline *(confirm)* tag next to each guess. Settled facts get *(known)* or *(house rule)*. The profile only got sharp after I corrected the guesses. Write down your uncertainty. The alternative is to assert things and find 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 they cannot. A Worker on Cloudflare’s edge has no route into my tailnet. No amount of wanting gives it one.

So the data flows in the other direction. A cron script on my home server pulls the app’s payload, with the taste block inside. It POSTs the payload to the Worker over public HTTPS. Two trust models serve two classes of consumer. Things inside the network reach for what they need. Things outside receive snapshots. After that rule had a name, the architecture stopped being confusing.

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

What is still broken

Two caveats, because the pattern is not as clean as the pitch.

The trips app does not do this. It keeps a hand-copied version of the trip profile in its own repo. An edit to the note in Obsidian does not reach it. Someone must copy the file again. The app shipped the same day as the profiles endpoint and never got wired up. The “zero deploys” promise holds for one of my two consumers.

I also have a long, detailed golf profile that the system never read. The profile predates the convention and has no frontmatter. The endpoint filters on type: *-profile and nothing else. So a well-written taste document is invisible until someone adds one line. Opt-in through a single key is the correct design. But it fails silently, and that is the wrong failure.

The rollout also almost did not ship, for unrelated reasons. This change exposed the frozen-deploy bug from the routing post. The build server served stale code for weeks. My missing endpoint finally gave it away.

Why I would do it again

The line I wrote in my notes that afternoon still holds. Most apps have no live-config layer. They are hardcoded, or they read environment variables only. Separate what changes (taste, in prose) from what ships (code). Then a behavior change becomes editorial work, not engineering work.

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

That is a better use of an afternoon than a redeploy.