yakov.codes

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:

  1. Loads the standing system prompt from _horizon/system/standing.md
  2. Reads the capability manifest and selects capabilities relevant to the event
  3. Makes one LLM call with the assembled context
  4. Executes validated bash commands from a YAML allowlist
  5. 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:

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:

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:

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.