Architecture
How Edward is built, run and tested.
Overview
Next.js PWA (:3000) ──/api──▶ FastAPI (:8000) ──▶ PostgreSQL 16 + pgvector (:5432)
Today · Chat · Voice │
Edward tab · /screen · /hub │
├─ a turn (LangGraph): preprocess → retrieve_memory → respond (tool loop) → extract_memory
├─ one toolset of 14 tools; bash runs in a Seatbelt sandbox in ~/edward-home
├─ listeners: iMessage (chat.db), Mail, Gmail → the mind (wakes, noticing, pings)
├─ scheduler (every 30 s), consolidation (hourly), the screens' pulse
└─ the Claude API: ChatClaude, one route per purpose, a daily dollar capThe backend runs natively on macOS, never in a container: it reads Apple’s databases, talks to Messages over AppleScript, and sandboxes Edward’s shell with Seatbelt. The frontend is a Next.js production build served on :3000, which forwards /api to the backend. All state is in PostgreSQL: conversations and their LangGraph checkpoints, memories, documents, events, ledgers and settings.
A turn
preprocess → retrieve_memory → respond (the tool loop) → extract_memory- One tool loop for every surface (chat, voice, pings, wakes, the pulse, scheduled events), with the same fourteen tools. What a turn may do is its
TurnPolicy: whether you asked, whether it may reach people or push, where it may reply, its dollar cap and deadline. A turn you are not part of, with no policy, is fail-closed. - Honest failures: every tool failure reaches the model as
Tool error: <the real error>; the same failing call is refused the third time; after three failed rounds he is told to step back, and after six the turn stops. - Bounds: 60 rounds, $3 a turn you asked for and $1 otherwise, 300 s per model call, 600 s per streamed turn, tool results capped at 30K characters.
- One turn at a time in a thread, and Stop cancels the step in flight. The turn’s trace (reasoning, tools, memory hits) is stored with the answer, so the work rail renders the same from history.
The model
Every model call goes through one set of builders and one Claude adapter on the anthropic SDK, with a model, effort and prompt-cache lifetime per purpose (chat, mind, pulse, background, voice, judge). Before each request the adapter asks the daily spend cap, and after it prices the call into a ledger. Every call is labelled with what it was for (mind:nightly, ping:friend, turn:chat…), which is how the app shows where the money went. An unofficial Codex fallback exists behind EDWARD_LLM_PROVIDER=codex. Details in Configuration.
Running in the background
| Loop | What it does |
|---|---|
| Scheduler | Polls every 30 s; each due event runs as its own task, in your time zone, never the host’s. Reminders, follow-up wakes, the nightly and free-time ticks, a five-minute sweep, the pulse. |
| Listeners | iMessage every 10 s, Mail and Gmail on their own intervals; they store events and nothing else |
| Ping runner | Answers each @Edward in its own turn, two at a time, never two in one chat |
| The mind | Wakes, the noticer and the speak gate (The mind) |
| The pulse | Every 15 minutes, a model turn only when a surface’s inputs changed |
| Consolidation | Hourly memory upkeep (Memory) |
| The sweep | Every five minutes, with no model: the late-dose alert, noticing upkeep, snoozes |
| Graph worker | Optional, its own LaunchAgent: fills the knowledge graph |
Startup order
main.py’s lifespan starts things in an order that matters: the tool registry must come after skills and every MCP source.
- Logging, the database and the LangGraph checkpointer (tables, migrations, drops)
- The time zone cache, the embedding model, the Codex rate gate (Codex only)
- The storage directory and skills
- Built-in MCP clients (after an orphan sweep), custom MCP servers, a second skills sweep
- The tool registry
- Edward’s computer: his home, the Seatbelt profile, a sweep of leftover shells
- The scheduler
- The listeners and the mind path, the presence probe
- The ping runner
- Browser hands (a no-op unless they are on)
- Consolidation (after 60 s)
- The orchestrator (recovers crashed worker tasks; dormant)
- Greeting pre-warm
Shutdown runs the matching hooks in reverse, his computer first.
The chat stream
POST /api/chat streams Server-Sent Events, data: {json}, plus a heartbeat comment every 10 s when idle. Every event has a type and a conversation_id; the frontend folds them into one live bubble. A crash mid-stream becomes a spoken-style apology and done; a client that leaves gets nothing more, and the step in flight is cancelled.
| Event | Meaning |
|---|---|
user_message | A voice memo’s transcript and audio, before the answer |
progress | Steps such as memory_search, tool_execution, generating |
memory_match | One per retrieved memory (debug streaming only) |
thinking | The model is working between tool calls |
reasoning | Summarized reasoning, as it streams |
tool_args_delta | Partial JSON of a tool call being written |
tool_start / tool_end | A tool call begins / finishes (result ≤ 500 chars) |
code, execution_output, execution_result | A command, its output, its result |
plan_created, plan_updated, plan_step_update, plan_completed | His visible plan |
cc_session_start, cc_text, cc_tool_use, cc_tool_result, cc_session_end | A Codex session (dormant) |
content | Text of the answer |
done | Stream complete: the checkpoint is written, the thread is free |
The tree
meet-edward/
├── backend/ FastAPI: main.py, routers/, services/, tests/, scripts/
│ └── services/
│ ├── graph/ the turn: the tool loop, streaming, the prompt
│ ├── toolset/ the 14 tools and TurnPolicy
│ ├── computer/ his home, shell, editor and the Seatbelt rails
│ ├── heartbeat/ the listeners and the mind path
│ ├── pings/ the @Edward runner
│ ├── media/ photos, PDFs, voice messages, video
│ ├── voice/ live voice
│ └── … memory, mind, noticing, reach, scheduler, hub, screen
├── frontend/ the Next.js PWA (app/, components/, lib/, __tests__/)
├── chrome-extension/ his hands in your Chrome (optional)
├── companion/ the menu-bar companion
├── scriptable/ the iPhone widget
├── scripts/ LaunchAgents, the watchdog, the test cluster
├── docs/ ADAPTING, UPGRADING, architecture/
├── site/ meet-edward.com
├── setup.sh
└── restart.shRunning it
- LaunchAgents keep the backend and frontend alive, and run a health check every five minutes (Postgres, backend, frontend, the tunnel, the graph worker and the scheduler, bottom up, never restarting the app onto a dead dependency), a deeper one at 08:00, a verified nightly backup and a daily memory report.
- The frontend is a production build.
frontend/deploy-build.shbuilds the next version beside the running one and swaps it in on restart;frontend/rollback-build.shswaps back. Open tabs reload onto a new build only when you are idle. - The live checkout is production: edits there are live at the next restart, so build and experiment in a git worktree.
Testing
- Backend tests run only on their own Postgres cluster on :5433, through
scripts/with-test-db-lock.sh, never with the live database exported. They can never reach a real model, Chrome, Messages, your calendar or the embedding model. - Frontend tests are
node --test, with guardrail tests that pin real contracts (every tool has a work-rail label, one clock format, every settings panel in a room). - A UX fixture harness runs the real app against a mock API with invented content, for screenshots and design work.
- Evaluation gates run the real model over recorded situations, with every outward act a dry run, before the mind, pings or noticing change.
- A doc lint fails when the docs name a path, route or event that doesn’t exist.
Read further
- CLAUDE.md: the operating guide, every rule that must hold, every route and setting. Written for Claude sessions working in the repo, and just as useful for people.
- docs/architecture/: one page per subsystem, with the reasons and incidents behind each rule.
- api.md: every route and socket.
- CONTRIBUTING.md: setup for contributors, the checks a change must pass, and the privacy rules.