How I let AI run my life (and what it isn't allowed to see)
I keep my finances, plans, health and journal in a markdown wiki that Claude maintains. Here is how it is built, and the four rules that made it safe enough to trust.
Since July I have kept a folder called ~/life. It holds my bank transactions, my ten-year financial plan, a page for every person I care about, my step counts and sleep, a journal, and a backlog of things I keep meaning to do around the house. Most of it is maintained by Claude Code. Some of it is maintained by a small model that never leaves my laptop. The journal is maintained by nobody but me.
The title is a bit of a lie. The AI doesn't run my life, it does the filing. But the filing is what I was bad at, and this post is about how to build the same thing without handing your bank statements and your diary to a model provider. Four rules did most of the work.
The pattern
The starting point is Andrej Karpathy's LLM Wiki. Raw sources go in one folder and are never edited. A model derives a wiki of markdown pages from them. A schema file, CLAUDE.md, tells the model how the wiki is organised and what the rules are. Three operations: ingest a source, query the wiki, lint it.
I changed two things. Pages are organised by domain rather than by concept, because a research wiki accumulates and a life wiki overwrites. My mortgage page is the current state of one mortgage, not an essay. And there is a SQLite sidecar, which is rule one.
Rule 1: numbers in SQLite, words in markdown
A year of bank transactions cannot live in markdown. It floods the context of any model that reads it and it is useless to a human anyway. So the rule is strict in both directions. Never put a transaction row in a page. Never put a sentence in a database. Obsidian cannot render SQLite, so a script writes a monthly summary page from the database. The database is the truth. The summary is what gets read.
The payoff is that every money question goes through SQL, and you can encode the mistakes you have already made into a view. This is the only thing the monthly finance review is allowed to query:
-- Real spending only. Every "how much did I spend" question starts here, never
-- from the transactions table.
--
-- is_internal matters more than it looks: funding the joint account from the
-- personal one is money leaving personal AND later leaving joint, so counting
-- both double-counts it. Monzo marks most of those include_in_spending=1, so
-- its own flag is not sufficient.
CREATE VIEW IF NOT EXISTS spending AS
SELECT t.*, m.name AS merchant_name, a.is_joint
FROM transactions t
LEFT JOIN merchants m ON m.id = t.merchant_id
JOIN accounts a ON a.id = t.account_id
WHERE t.declined = 0
AND t.include_in_spending = 1
AND t.is_internal = 0
AND t.is_load = 0
AND t.amount_pence < 0;
Money is signed integer pence. Every parser deletes its database and rebuilds it from the raw JSON on every run, so a parser bug is fixed by editing the parser, never by re-fetching. On Sunday mornings a job rebuilds each database into a scratch file and compares row counts with the live one, to prove the databases are still fully derived from the sources.
Rule 2: plumbing, not agents
The part I am most pleased with has no AI in it. Nine launchd agents run on a schedule. Monzo at 07:30, Google Health at 07:45, Habitica at 07:50, a lint at 08:10, Trading 212 at 18:10 after the London close, a commit at 23:45. They are Python, they read JSON, they write rows. If something breaks it breaks in a log I can read.

Two launchd tricks are worth stealing. The first is WatchPaths instead of a timer. The nightly distillation writes proposals into an inbox file as checkboxes. Ticking one in Obsidian saves the file, launchd notices, and a script pushes the ticked lines to Habitica. No terminal, no waiting for a window.
<key>WatchPaths</key>
<array>
<string>/Users/marcoendrizzi/life/inbox/distil-proposals.md</string>
</array>
The script rewrites the watched file after a push, which re-triggers the agent, but the second run finds every line marked and exits. A lock file stops two rapid autosaves from posting the same line twice.
The second trick exists because a Trading 212 snapshot once sat unrefreshed for thirteen days before I noticed. Every scheduled script now stamps a JSON file on its success path only, written temp-and-rename so a crash cannot corrupt the other stamps. The lint compares the stamps against each job's expected cadence and flags anything stale:
JOB_CADENCE = {
"commit": "1d", # 23:45
"distil": "1d", # 03:00
"google_health_sync": "1d", # 07:45
"habitica_push": None, # WatchPaths on inbox/distil-proposals.md
"habitica_sync": "1d", # 07:50
"lint": "1d", # 08:10
"monzo_sync": "1d", # 07:30
"promote": None, # WatchPaths on inbox/distil-proposals.md
"rebuild": "1w", # Sundays 09:00
"trading212_sync": "1d", # 18:10
}
A job with None is exempt, because a quiet week there just means nothing was ticked. Claude Code sits on top of all this. It reads the schema, the pages and the databases, answers questions with citations, and files new sources into the right pages. A PostToolUse hook appends every edit to log.md, so neither of us can forget.
Rule 3: decide what the model can't see before deciding what it can do
The deny list was the first thing in the spec, and it shaped every later decision. This is the whole of the project's Claude Code settings:
{
"permissions": {
"deny": [
"Read(./journal/**)",
"Read(./health/**)",
"Read(./sources/health/**)",
"Read(./inbox/**)",
"Read(./.secrets/**)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "/usr/bin/env python3 /Users/marcoendrizzi/life/sync/log_change.py"
}
]
}
]
},
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false,
"filesystem": {
"denyRead": [
"/Users/marcoendrizzi/life/journal/",
"/Users/marcoendrizzi/life/health/",
"/Users/marcoendrizzi/life/sources/health/",
"/Users/marcoendrizzi/life/inbox/",
"/Users/marcoendrizzi/life/.secrets/"
]
}
}
}
Notice the same five paths appear twice. That is because the layers are not interchangeable, and I learned it the hard way.
The permissions.deny block stops the Read, Glob and Grep tools. It does nothing about the shell. A deny rule on Read does not stop cat. The sandbox denyRead list closes that gap at the OS level, so a shell read now fails with "Operation not permitted". The scripts that legitimately need those files run from launchd, outside the sandbox.
Underneath both, git-crypt encrypts journal/ and health/ at rest so the remote only ever holds ciphertext. That protects against someone getting into the repo. It does nothing about a process on my own machine, because any key I can use, any other local process can use. FileVault covers the laptop being stolen.
The lesson that cost me the most is that a rule you have written down is not a rule that is running. Project settings only load when the session starts inside the project. For several days I ran sessions from a different directory, the rules never applied, and I described the protection as active while it was theatre. The schema file now has this section, and it is the one I would copy into any agent-maintained project:
"Working" requires a command and its output, not the act of having written the file. Before saying something is working: run the thing, show the output, and prefer a check that would fail if the claim were false. A grep that matches a stale line is not evidence.
Rule 4: the private stuff gets a local model
The journal is the one thing no cloud model touches. Every night at three a local model reads the entries that have not been processed yet and proposes tasks, people mentions and ideas. It is Qwen 3 at eight billion parameters through Ollama, and the call looks like this:
OLLAMA = "http://localhost:11434/api/chat"
MODEL = "qwen3:8b"
# Enforced by Ollama rather than requested in prose. A model that cannot emit the wrong
# shape does not need to be trusted to emit the right one.
SCHEMA = {
"type": "object",
"properties": {
"tasks": {"type": "array", "items": {"...": "..."}},
"people": {"type": "array", "items": {"...": "..."}},
"ideas": {"type": "array", "items": {"type": "string"}},
"uncertain": {"type": "array", "items": {"type": "string"}},
},
"required": ["tasks", "people", "ideas", "uncertain"],
}
def distil(entry_text):
body = json.dumps({
"model": MODEL,
"messages": [{"role": "user",
"content": prompt_text() + "\n\n---\n\n" + entry_text}],
"format": SCHEMA,
"stream": False,
"think": True,
"options": {"temperature": 0},
}).encode()
req = urllib.request.Request(OLLAMA, data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=1200) as r:
content = json.load(r)["message"]["content"]
return json.loads(content)
Three things to copy. The schema goes in the format field, so Ollama constrains the output and the model cannot return the wrong shape. Reasoning is left on, because with it off the model kept turning things I had already done into tasks. And the model has no filesystem access at all. Text in, JSON out, Python does every write. The proposals land in the inbox as checkboxes and nothing reaches Habitica or a people page until I tick it. The tick is the human gate on what leaves the machine.
This is also why processed entries are tracked in a state file rather than a frontmatter stamp. The journal is immutable, even to the script that reads it.
Integration notes
The syncs are boring by design, but each API had one thing I wish I had known. Secrets live in the macOS Keychain and the scripts read them with security find-generic-password.
- Monzo gives you full history for five minutes after you approve the app on your phone, then only the trailing ninety days. Asking for older returns a 403, not an empty list, so the daily sync clamps to eighty-nine days. Refresh tokens rotate on every use, so persist the new one before making any other call.
- Google Health replaced Fitbit's Web API. Use the
reconcileendpoint for daily values, becauselistreturns one point per source and a watch plus Health Connect double-counts a day. Roll-ups answer underrollupDataPoints, everything else underdataPoints, and reading the wrong key gives zero rows and no error. That is how a year of steps came back empty once. Also, int64 arrives as a string, and a refresh token dies after seven days while your consent screen is in Testing. - Habitica needs an
x-clientheader on every authenticated call since mid 2025, and the error when you omit it looks like a bad credential. Completed to-dos vanish from the main list and must be fetched separately. Everything is pushed as a to-do, never a daily, because dailies punish a missed day. - Trading 212 reports profit in the account currency but prices in the instrument's currency. For a while every US holding wore a pound sign.
If you build one
Build the plumbing first. I built the finance pages before the lint, the log and the hook, and for two weeks the hygiene fell to whoever remembered, which turned out to be nobody. Decide the no-go zones before the capabilities. Verify every claim of "working" with a command that would fail if it were false. And keep the log. It is the only reason I trust anything in the folder, and when a number looks wrong, half the time the answer is me.
The AI does not run my life. It keeps the ledger, and it keeps it out of my head. That turns out to be most of what I wanted.