Horizon - a personal AI assistant that lives in your Obsidian vault
Horizon is a personal AI assistant that runs as a daemon, receives Telegram messages and cron ticks, and stores everything as plain markdown files in an Obsidian vault. Here's how it works and why the design holds up.
Contents
What it is #
Horizon is a personal AI assistant I built that stores all its state in an Obsidian vault — a directory of plain markdown files. There is no database, no hidden state, no proprietary format. The vault is the database. Open Obsidian, and you see everything.
It runs as a daemon on my Linux box. One Dart process, one event loop, one LLM call per event. It accepts input via Telegram messages, fires on cron schedules, and watches vault files for changes. Each event goes into a pipeline: load relevant context, call the model, execute any resulting tool calls, write results back to vault.
Architecture #
The core loop is straightforward. An event arrives — a Telegram message, a heartbeat tick, a vault file appearing — and the harness:
- Loads the standing system prompt from
_horizon/system/standing.md - Reads the capability manifest and selects capabilities relevant to the event
- Makes one LLM call with the assembled context
- Executes validated bash commands from a YAML allowlist
- Writes results back to the vault
Tools are bash command templates defined in _horizon/system/allowlist.yaml. Appending a new entry makes it available on the next event — no restart, no code changes. The validation layer handles path traversal and shell escaping, so the allowlist file itself is safe to edit from a mobile Obsidian client.
The LLM endpoint is configurable. Default is Kimi K2 via CrofAI, though anything with an OpenAI-compatible chat completion API works. Every call goes through the same standing prompt, which keeps prefix caching effective — roughly 70–85% cache hit rate at providers that support it.
Capabilities #
Behavior is defined in markdown files that live inside the vault itself, in _horizon/capabilities/. Each capability is a .md file with YAML frontmatter:
---
id: todo-manager
description: Load when the user mentions tasks, todos, or wants to track something to do later
schedule: 1d
---
The description field is what the orchestrator uses to decide which capabilities to load for a given event. The schedule field, when present, makes the capability eligible for heartbeat ticks at that interval.
What ships out of the box:
- Journaler — appends every interaction to
journal/YYYY-MM-DD.md - Knowledge base — maintains structured entries at
knowledge/<slug>.mdfor facts worth keeping - Todo manager — creates and updates tasks at
todos/<slug>.mdwith YAML frontmatter tracking status, due dates, and context - Relationships — tracks people at
people/<slug>.md, bidirectional connections via [[wikilinks]] - Pot tasks — delegates long-running autonomous work (coding, research, design) to separate Claude agents via the Potentiality orchestrator; results come back into the vault
- Skill reflector — audits recent activity and proposes new capability files when patterns emerge; promotion is manual
- Metacognitive monitor — scans turn records for repeated misses and inefficiencies, surfaces them for review
- Lint capabilities — LLM-driven audit of the capability descriptions themselves for semantic overlap
Editing any of these in Obsidian takes effect on the next event. No restart.
The vault as database #
Every piece of state is a markdown file:
journal/2026-05-14.md
knowledge/nix-flakes.md
todos/write-horizon-article.md
people/alice.md
_horizon/capabilities/todo-manager.md
_horizon/turns/01HXK2...json
This has a few practical consequences:
- Full audit trail. Every turn is recorded in
_horizon/turns/as a structured JSON file. The whole conversation history is on disk. - Obsidian-native. [[Wikilinks]] work. Dataview queries work. You can build a custom dashboard over your todos, a graph view of your relationships, a calendar over your journal entries.
- No lock-in. The files are plain text. If I want to stop using Horizon, the vault stays — notes, todos, journal, all of it.
- Mobile editing. Capabilities, the system prompt, and the tool allowlist are all vault files. Obsidian on iOS edits them. Changes propagate on the next event.
Signal-driven heartbeat #
The heartbeat fires every five minutes by default, but it doesn't always do anything. Before making an LLM call, the harness checks whether any capability with a schedule: field is due. If none are, the tick costs zero tokens.
When a capability is due, the orchestrator sees only that capability and an instruction to reply with HEARTBEAT_OK if nothing actionable is found. Skill-reflector runs weekly. Lint runs daily. The metacognitive monitor runs every few hours. Most heartbeats are silent.
What it won't do #
By design:
- It won't serve multiple users — one vault, one owner
- It won't host MCP or be an MCP client — tools are bash commands in a YAML file, nothing more
- It won't self-modify — skill-reflector proposes, I promote
- It won't hold sub-second real-time state — it's async by nature
These aren't compromises. They're the constraints that make everything else tractable.
Running it #
nix run github:purplenoodlesoop/horizon
Requires a .env with TELEGRAM_TOKEN, TELEGRAM_USERNAME, and LLM_TOKEN. Vault defaults to ./vault in the working directory. The bootstrap creates the _horizon/ subtree on first run.
Source at github.com/purplenoodlesoop/horizon.