Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Echo Agent

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.

Why it looks the way it does

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.

Architecture

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.

The safety model

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-run which computes, prints, and writes nothing.

Setup

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 in

Apply the schema to your Supabase project:

.venv/bin/python migrations/apply.py

Run the test suite. 115 tests run with no credentials and no network:

.venv/bin/python -m pytest -q

The 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-run

To 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.plist

Default 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.

Known weaknesses

Being honest about these rather than letting you discover them:

  • echo_db.py uses 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=bypassPermissions removes 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.example is default for a reason.
  • Gmail auth is borrowed, expecting an OAuth token JSON at ~/.claude/scripts/config/google-token.json rather than owning its own flow.
  • The calendar reader shells out to claude -p with 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/Vancouver in a few places.

License

MIT. See LICENSE.

About

Self-hosted personal chief-of-staff agent: Telegram bot on the Claude Agent SDK plus scheduled jobs for email triage, commitment mining, and daily briefs. Deterministic lanes run before any model call.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages