A personal chief-of-staff agent that runs on your own machine. It watches a mailbox, mines meeting notes for commitments, reads your calendar, and messages you on Telegram three times a day with a brief about what actually needs your attention. You answer in plain English and it closes the loop.
Two halves: an always-on Telegram bot backed by the Claude Agent SDK, and a set of scheduled jobs that do the watching.
This is real running code extracted from a working system, not a framework. It is opinionated, it assumes macOS + launchd, and it expects a Supabase project and a Gmail OAuth token. Read the setup section before assuming it will just run.
The interesting part is not the LLM calls. It is where the LLM is not.
Email triage runs deterministic lanes before any model sees a message. A VIP sender is kept because a row in a table says so, never because a model decided. Muted senders are dropped before a model is invoked at all. Only genuinely unknown senders reach a classifier, and that classifier runs with every tool disabled.
Commitment mining does the same thing. Meeting transcripts are untrusted input, so extraction and the quality gate both run through a no-tool runner (claude -p with --strict-mcp-config --mcp-config '{}' --tools ""). Untrusted text can never reach a step that could execute something.
That is the whole design philosophy: the model provides judgment, deterministic code holds the authority.
| Component | What it does |
|---|---|
coach_bot.py |
Always-on Telegram bot. Drives Claude via the Agent SDK with your MCP servers injected. One long-lived session for memory, resumes across restarts. Read-only tools auto-run, mutating ones send an Approve/Deny card. |
email_sweep.py |
Mailbox sweep through lanes: deal-token drop, muted drop, VIP keep, then dossier + no-tool verdict for unknowns. Starts every run with a deterministic reply detector so answered threads stop resurfacing. |
echo_brief.py |
The "brain" behind the daily briefs. gather() builds a deterministic state bundle, render() makes one no-tool model call, parse() validates strict JSON and whitelists every generated question against ids that actually exist. Returns None on any failure so the caller falls back. |
digest.py |
Morning / midday / end-of-day Telegram briefs. Runs through echo_brief, falls back to a pure string formatter if the model call fails. |
miner.py |
Reads meeting notes, extracts commitments, runs them through a quality gate, writes survivors to the DB. |
task_chaser.py |
Finds slipping commitments and rolls them forward. Records that a nudge is owed, never sends it itself. |
echo_close.py |
Deterministic closer. Lists open rows, closes by id, reads back exactly what changed. The only path the conversational agent may use to close anything. |
sunday_ritual.py |
Weekly kickoff. Ranks the week ahead and proposes time blocks. Proposes only, never writes to your calendar. |
email_vault.py |
Funnels inbound mail into an Obsidian vault, one note per thread. No LLM anywhere in it. |
echo_db.py |
All Postgres access, via the Supabase Management API. |
dossier.py |
Injection-safe research on an unknown sender. Fixed-string searches only, the model never composes a query. |
These rules are enforced in code, not just in prompts. They are the reason this is safe to leave running unattended:
- Messaging you is autonomous. Acting on the world is not. No email send, no calendar mutation, no external post happens without an explicit tap. Drafts are surfaced as text. Calendar blocks are proposals.
- Untrusted text never reaches a tool-enabled step. Every component that ingests email bodies or meeting transcripts runs with MCP servers and built-in tools disabled.
- Generated questions are whitelisted. A brief can only ask about row ids present in the state bundle, so hostile email text cannot forge a prompt that closes a real commitment.
- Closes are soft and read back. Status changes plus an audit stamp, never deletes, and the closer quotes what it changed so a mismatch is visible instead of silent.
- Everything is idempotent and has
--dry-runwhich computes, prints, and writes nothing.
Requires Python 3.11+, macOS for the launchd jobs (the bot itself is cross-platform), and a Supabase project.
git clone <your-fork> echo-agent && cd echo-agent
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp .env.example .env # then fill it inApply the schema to your Supabase project:
.venv/bin/python migrations/apply.pyRun the test suite. 115 tests run with no credentials and no network:
.venv/bin/python -m pytest -qThe 5 skipped tests are live round-trips against a real database. They run only if you set SUPABASE_PROJECT_REF, and they clean up after themselves.
Try a job without letting it write anything:
.venv/bin/python digest.py morning --dry-runTo schedule the jobs, edit the com.solnest.*.plist files, replace /ABSOLUTE/PATH/TO/echo-agent and /ABSOLUTE/PATH/TO/HOME with real paths, then load them:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.solnest.echo.plistDefault schedule: sweep at 07:30 / 12:30 / 17:00, briefs at 07:00 / 12:00 / 17:00, chaser hourly during the day, mail-to-vault at :20, weekly ritual Sunday 22:00.
Being honest about these rather than letting you discover them:
echo_db.pyuses an account-wide Supabase Personal Access Token, read from~/.claude.json. It is stored in plaintext and it bypasses row-level security. It works, but the right answer is a scoped role and a pooler connection. Migrating it only changes the internals of_query().PERMISSION_MODE=bypassPermissionsremoves the tool gate entirely. The calendar-confirm rule then lives only in the system prompt, which is a behavioral guarantee rather than an enforced one. The default in.env.exampleisdefaultfor a reason.- Gmail auth is borrowed, expecting an OAuth token JSON at
~/.claude/scripts/config/google-token.jsonrather than owning its own flow. - The calendar reader shells out to
claude -pwith the google-workspace MCP instead of talking to the Google API directly. It works and it fails soft, but it is a strange dependency. - Some internal field names (
ryan_replies,last_from_ryan) carry the original author's name. They are DB columns and part of a model output contract, so they were left alone rather than risk breaking the schema. - Timezone is hardcoded to
America/Vancouverin a few places.
MIT. See LICENSE.