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 cap

The 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

LoopWhat it does
SchedulerPolls 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.
ListenersiMessage every 10 s, Mail and Gmail on their own intervals; they store events and nothing else
Ping runnerAnswers each @Edward in its own turn, two at a time, never two in one chat
The mindWakes, the noticer and the speak gate (The mind)
The pulseEvery 15 minutes, a model turn only when a surface’s inputs changed
ConsolidationHourly memory upkeep (Memory)
The sweepEvery five minutes, with no model: the late-dose alert, noticing upkeep, snoozes
Graph workerOptional, 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.

  1. Logging, the database and the LangGraph checkpointer (tables, migrations, drops)
  2. The time zone cache, the embedding model, the Codex rate gate (Codex only)
  3. The storage directory and skills
  4. Built-in MCP clients (after an orphan sweep), custom MCP servers, a second skills sweep
  5. The tool registry
  6. Edward’s computer: his home, the Seatbelt profile, a sweep of leftover shells
  7. The scheduler
  8. The listeners and the mind path, the presence probe
  9. The ping runner
  10. Browser hands (a no-op unless they are on)
  11. Consolidation (after 60 s)
  12. The orchestrator (recovers crashed worker tasks; dormant)
  13. 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.

EventMeaning
user_messageA voice memo’s transcript and audio, before the answer
progressSteps such as memory_search, tool_execution, generating
memory_matchOne per retrieved memory (debug streaming only)
thinkingThe model is working between tool calls
reasoningSummarized reasoning, as it streams
tool_args_deltaPartial JSON of a tool call being written
tool_start / tool_endA tool call begins / finishes (result ≤ 500 chars)
code, execution_output, execution_resultA command, its output, its result
plan_created, plan_updated, plan_step_update, plan_completedHis visible plan
cc_session_start, cc_text, cc_tool_use, cc_tool_result, cc_session_endA Codex session (dormant)
contentText of the answer
doneStream 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.sh

Running 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.sh builds the next version beside the running one and swaps it in on restart; frontend/rollback-build.sh swaps 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.