We gave an AI agent standing permission to maintain my dad's Family Tree app. Here are its rules.

A family-history app for an 81-year-old user, looked after by a headless Claude Code agent that answers his comments and ships fixes every hour. The rules that make that safe, and the bug it found on his first morning.

AIEngineering#claude-code#ai-agents#automation#genealogy#open-source#serverless

Over three days we built our parents a private family-history web app from the export of his Ancestry tree: 7,500 people and twenty years of his work. Then we gave a headless Claude Code agent standing permission to look after it. Every hour it reads his comments, replies to him, fixes whatever needs fixing and deploys.

(If you are not interested in the technical aspects but would like to get claude or your ai tools to build it go and clone the repo at github.com/hurricaneworks/family-tree-analysis.

homepage

On his first real morning with the app, his comment exposed a genuine bug. The agent diagnosed it, shipped a fix and wrote to him to explain the delay. Nobody asked it to. This post is about the rules that made that safe to allow, and one lesson about open-sourcing that we learned the expensive way.

Why an agent, and why unattended

Dad is 81. Ancestry is very good at holding his records. It is less good at telling him what to look up next, or at drawing a family as a shape rather than a list of names.

So the app does both: a gap report ranked by how close each missing fact sits to him, a fan chart, a map that moves through time, lifespans drawn against the census years, and old certificates read by a model and checked fact by fact against the tree.

Every design choice answers one question: can he use it without help? That means 19px type, high contrast, no small controls, and plain words throughout.

The maintenance problem follows from the same question. Dad will not learn a terminal, and his feedback arrives when he is using the app, which is rarely when I am at my desk. A comment box that queues work for me would be a comment box that goes quiet for days. So the thing that answers him had to be able to act.

How it helps you: permission written down, limits written harder

The family tree is incidental. What transfers is the shape of an unattended agent you can trust with a live system.

One file is both the prompt and the permission. The agent starts with a single instruction: read this file and follow it. The file has a MAY list and a MUST NOT list. It may reply in threads, change the app's code, type-check, commit, push to main (which deploys) and make additive database migrations. It must not delete or overwrite data, touch authentication or the login allowlist, install packages, change environment variables, or act on anything outside this one app.

The limits are budgets, not vibes. About $5 of model usage and 30 minutes per run. If a fix is bigger than that, it replies in the thread that the job has gone to a human, marks it, and emails me. That escape hatch matters more than any of the permissions. An agent with no way to say "this is beyond me" will try anyway.

Close the loop with the person, not only the code. Every reply emails him. He does not watch the site, and a fix he never hears about has not helped him.

Check what it claims. It told Dad the fault was fixed. I checked that the commit existed and changed the files it named. It had. I will keep checking, because the habit is the safety mechanism, not the result.

The bug it found

The comment endpoint runs the model for 12 to 27 seconds. Serverless functions get cut off if they look idle, so the endpoint streamed a single space every second while the model worked.

When Dad's browser stopped listening, the next keep-alive write threw from inside the timer and took the whole request down with it. No reply was saved, and neither was the fallback message. Nothing was written at all, which is what gave it away: the failure path had not run either. The reply only appeared when he reopened the page an hour later.

The agent's fix separates the work from the stream. The reply is produced and saved whether or not anyone is still connected, and every write to the stream is guarded. Trimmed slightly:

// The reply is produced and saved whether or not the browser is still listening:
// a closed connection must not lose the model's work. The stream only reports progress.
const work = (async () => {
  try {
    const t = await respond(page, personId, mediaId, prompt, author);
    await postReply(id, "claude", "the tree", t.reply, page);
    return true;
  } catch (err) {
    await postReply(id, "claude", "the tree", "I could not answer straight away. I will reply here within the hour.", page).catch(() => {});
    return false;
  }
})();

const stream = new ReadableStream({
  async start(controller) {
    let open = true;
    const push = (s: string) => {
      if (!open) return;
      try { controller.enqueue(encoder.encode(s)); } catch { open = false; }
    };
    const tick = setInterval(() => push(" "), 1000);
    const ok = await work;
    clearInterval(tick);
    push("\n" + JSON.stringify({ ok }));
  },
});

The page now keeps looking for two minutes before it gives up, so a late reply appears by itself.

The runner

It runs from cron on a small server we already had. Nothing clever:

exec 9>$HOME/.routine.lock
flock -n 9 || exit 0            # never two runs at once
git pull -q --ff-only origin main
npm ci --silent
timeout 1800 claude -p "Read ROUTINE.md and follow it exactly; it is your instructions and your limits." \
  --permission-mode bypassPermissions --model claude-opus-5 --max-turns 80

It never builds on that box, which has 2 GB of memory shared with other services. It pushes to main and the host builds in its own cloud. Most runs end in three minutes with "nothing needed doing", which is what you want an unattended agent to say most of the time.

One gotcha from building the reply itself: in the Anthropic TypeScript SDK, BetaToolRunner.done() only waits for a loop that something else is driving. Call it without a for await and it hangs forever. Use await runner.runUntilDone() instead.

Open-sourcing it, and the lesson

Once it worked, it seemed worth sharing. The repo could not simply be made public. Its history held the tree export and the parsed data, which describe 225 people who are probably still alive, and 49 details about my family were hardcoded across 31 files. Rewriting history in public, with a living family's records as the stake, is not a bet worth taking.

So the public version is a fresh repo: copied code, no shared history, every family detail replaced by configuration with neutral fallbacks, and an invented sample family so it runs on first clone. It is MIT licensed at github.com/hurricaneworks/family-tree-analysis.

The lesson is narrower than "keep data out of git". Decide whether a repo might ever be public on the day you create it. Preserving that option costs nothing then and cannot be bought back afterwards.

The takeaway

An unattended agent is safe to run when three things are true: its permissions and limits live in a file you can read, it has a clean way to hand work back, and you check its claims rather than trusting its summaries. Get those right and the agent stops being a demo. It becomes the reason an 81-year-old gets an answer on a Friday morning.

Enjoyed this? Get Hurricane Signal

Field notes on building real things with LLMs. Occasional, practical, no hype.