A personal agent control plane. Commands enter through a gateway (Telegram now, dashboard later), get published to a bus (Redis Streams), and are picked up by agents running as their own processes that publish results back. The gateway relays results to the originating channel.
Status: Phase 0 — skeleton. One trivial agent (
echo) proving the end-to-end loop: text → Telegram → bus → agent → reply. No persistence, no real agents, no dashboard yet — those are Phases 1+.
apps/gateway FastAPI: Telegram webhook, dispatcher, relay, dashboard API
apps/trace_writer persists every bus event to Postgres (Phase 1)
apps/scheduler cron-on-bus: fires scheduled commands (Phase 3)
apps/dashboard Next.js: live DAG, SSE feed, approvals, history (Phase 5)
packages/core the contract — events, bus, db/traces, tools, llm, skills
packages/agents worker agents (echo, email_triage, daily_briefing, supervisor)
packages/skills SKILL.md prompt templates (Phase 4)
infra docker-compose, migrations, mcp_registry, schedules, .env.example
scripts bootstrap / run / webhook helpers
packages/core is the base of the dependency graph: the gateway and agents both
depend on it, never on each other. Agents talk only via bus events.
Every event on the bus is a yohan_core.Event:
| field | meaning |
|---|---|
trace_id |
stitches one command's whole causal chain together |
agent_id |
who emitted it (gateway, echo-1a2b3c4d, …) |
event_type |
one of the EventType enum values |
timestamp |
UTC, set at creation |
payload |
open dict — per-event-type detail |
Prereqs: uv, Docker, a Telegram bot token from @BotFather, and
cloudflared for the tunnel.
# 1. install workspace + start redis/postgres
scripts/bootstrap.sh
# 2. put your token in infra/.env
# YOHAN_TELEGRAM_BOT_TOKEN=...
# YOHAN_TELEGRAM_WEBHOOK_SECRET=<any random string>
# 3. expose the gateway. Quick tunnel:
cloudflared tunnel --url http://localhost:8000
# (or a named tunnel mapped to agent.yourdomain.com — recommended, stable URL)
# put the resulting https URL in infra/.env as YOHAN_PUBLIC_URL
# 4. two processes, two terminals:
scripts/run_gateway.sh # FastAPI on :8000
scripts/run_echo.sh # echo agent consuming the bus
# 5. register the webhook, then message your bot
scripts/set_webhook.shText your bot "hello" → you get "echo: hello" back. That round-trip is the whole Phase 0 acceptance test.
To exercise the loop with no bot/tunnel, run Redis (scripts/bootstrap.sh), the
echo agent, then the smoke script — it publishes a command straight onto the bus
and prints the reply:
scripts/run_echo.sh # in one terminal
uv run python scripts/smoke_echo.py # in anotherSet the tunnel up before anything else — without it Telegram can't reach your laptop. A named tunnel gives you a stable subdomain:
cloudflared tunnel login
cloudflared tunnel create yohan
# map agent.yourdomain.com -> http://localhost:8000 in your tunnel config,
# then run: cloudflared tunnel run yohanEvery event on the bus is persisted to the Postgres traces table by a single
subscriber, apps/trace_writer — which joins the results stream as its own
consumer group (grp:tracewriter), independent of the gateway relay. Both see
every event; neither disturbs the other. Adding it required no change to the
gateway or agents.
scripts/run_trace_writer.sh # applies the schema on startup, then persists eventsInspect a run's full causal chain:
docker exec yohan-postgres psql -U yohan -d yohan -c \
"SELECT event_type, agent_id, payload->>'reply' FROM traces \
WHERE trace_id = '<trace_id>' ORDER BY id;"Inserts are idempotent on (stream, entry_id), so the bus's at-least-once
delivery yields exactly-once rows.
A Next.js 15 dashboard (apps/dashboard, Tailwind + React Flow) over the
gateway's SSE + REST API: a live event feed (SSE), a React Flow DAG of any
selected trace (the root spine fans out into one lane per parallel task), the
history list, and a pending-approvals panel with Approve/Reject. ● live
reflects the SSE connection. (The Phase 1 Streamlit MVP was retired here.)
scripts/run_gateway.sh # serves the API on :8000
scripts/run_dashboard.sh # Next.js on http://localhost:3000Every process configures JSON logging via configure_logging(service). Each line
is one JSON object — service, level, logger, msg, ts — enriched with
trace_id whenever one is bound. The trace id rides a ContextVar, so the
consume loop / webhook handler binds it once per command and every log line
beneath inherits it (third-party logs like httpx included). This is the log
half of the brief's rule that trace_id propagates through every event, log,
and DB row.
{"ts":"…","level":"INFO","service":"agent:echo","logger":"yohan_agents.base","msg":"task completed","trace_id":"trc_…","elapsed_seconds":0.001}Grep one command across every process with its trace_id.
The first real agent. apps/gateway routes email/inbox/triage/unread commands to
the email-triage agent, which runs a fixed workflow — search unread → read →
classify → draft → send. gmail.send_email is gated in infra/mcp_registry.yaml,
so a send parks on an approval event; the drafted reply is shown and only a grant
lets it through.
Approve/reject from either channel:
- Telegram — inline ✅/❌ buttons on the prompt; a tap publishes the decision.
- Streamlit — a "Pending approvals" panel at the top of the dashboard.
Both publish to the approvals stream; the parked agent resolves and continues.
scripts/run_email_triage.sh # the agent (needs Gmail MCP OAuth for live Gmail)Going live needs two things this repo can't do for you: authorize the Gmail MCP
server (npx @gongrzhe/server-gmail-autoauth-mcp auth) and run Ollama (or set
ANTHROPIC_API_KEY) for real classification. Without them the pipeline still
runs — classification falls back to heuristics.
- Supervisor (
yohan_agents.supervisor) plans a command into parallel tasks and fans them out viayohan_core.dispatch, aggregating results into one reply.morning/briefing/fanoutroute here. - Daily briefing (
yohan_agents.daily_briefing) gathers email/GitHub/calendar sections concurrently, each degrading gracefully when its source isn't connected. - Scheduler (
apps/scheduler, cron-on-bus) publishes scheduled commands onto the bus — same path as interactive commands.infra/schedules.yamldrives it (07:00 KST briefing by default; set yourchat_id).
scripts/run_supervisor.sh
scripts/run_daily_briefing.sh
scripts/run_scheduler.shDispatch shares the parent trace_id across the fan-out, with a task_id per
branch, so a multi-agent command stays one causal chain.
Agent prompts aren't hardcoded — they're skills loaded at runtime from
packages/skills/<name>/SKILL.md (YAML frontmatter: name, description,
model_role; body is a {placeholder} prompt template). Edit or version a prompt
without touching Python.
triage_classify,triage_draft— used by the email-triage agent.morning_briefing— used by the daily-briefing agent to compose sections.
yohan_core.load_skill(name) loads one; available_skills() discovers them.
Combined with the MCP registry (tools) from Phase 2, both tools and skills are
loaded dynamically — no agent ships a hardcoded list.
- Phase 6 — voice (Whisper transcription for Telegram notes) + devops / code-review agents.
- Deferred: LangGraph Postgres checkpointer (lands with the first agent whose path isn't predictable); a pinned long-lived MCP session (tool layer is per-call now).
See PROJECT_BRIEF.md for the full plan.