edgar · a guided read of the code

A Tour of the Harness

Which files to open, in which order, and what to look for in each. The agent harness you can read in an afternoon. Any model. No hidden calls. Nothing is done until it's verified.

Built M0 to M11, and M12
Planned M13 to M17, the rest of v3 and v4
Kept in sync by a test

Every stop links to the file on GitHub and names the functions worth finding. Read the stops in order — each one only needs the stops that came before it, not the alphabet. Keep the code open beside this page. The numbered steps in a diagram match the numbered comments in the code it describes.

The tour has three parts, one per tier, so you can read a tier at a time. Core and v1 are built: read them now. Part III covers the removable tiers — v3, learning, and v4, unattended — which are still planned. A planned stop names the files it will become, the milestone that brings it, and the seam in Core it will attach to. Once a milestone lands, its stop gets real links like the rest. Between v1 and v3 sits v2, the daily driver (M18 to M22, ADR-0057): it extends the files already in Parts I and II, and gets its own tour page per feature next to this one — so it has no part of its own here.

  1. 1. The spine45 minutes
  2. 2. The hands45 minutes
  3. 3. The mouth45 minutes
  4. 4. The memory30 minutes
  5. 5. The surfaceskim, 15 minutes
  6. 6. The know-how10 minutes
  7. 7. The rest of itreference

The promise is that Core can be read in an afternoon, so time yourself. If it takes longer, that is worth knowing: open an issue and say where you got stuck.

PART I.

Core

Built, M0 to M6

The smallest honest harness: one loop, eight tools, five providers, permissions, verification, sessions, skills. At most 5,000 lines of code, total.

The shape of the thing

Sizes below are in lines of code: blank lines and comments don't count toward the budget, but docstrings do. just loc prints the current totals.

PackageLines of codeWhat it is
core/~790The turn loop and the types everything else speaks in
tools/~1,450What the model can do, and the pipeline every call goes through
permissions/~300The decision, and the asking
providers/~1,320Five vendors, two adapters, a table of differences; declarative routing rules (v1)
skills/~339Finding skills, reading only their frontmatter, deterministic activation, and the audit before a copy (v1, v2)
context/~380Building the prompt, and compacting it
storage/~290Sessions on disk, append-only
config/~400Loading, layering and checking config
cli/~2,543The REPL, one-shot runs, rendering, setup, and the commands that inspect a project (v1, v2)
memory/~400Facts, their undo log, and lexical search (v1)
auth/~300Signing in: PKCE, the loopback redirect, the OS keyring (v1)
agents/~320A subagent's definition, finding them, spawning one (v1), and its own git worktree (v2)
sandbox/~110The Sandbox port and its backends: nothing, bubblewrap, seatbelt (v2)
learning/~686Telemetry, autolearn from typed text, error facts, history.md, skill synthesis (v3)
controller/~892Deterministic triggers, eight whitelisted actions, policy that only tightens (v3)
extensions/~330An extension folder's manifest, discovery, observe-or-veto hooks, and the gate ext add passes (v1, v2)
broker/~365Intents, tickets and caveats, the pure veto, the signed receipt, edgar receipt (v4)
schedule/~624Due dates as a pure function, overlap-safe ticks, the host installer, edgar tick, schedule_self (v4)

Source: just loc, which counts with tests/support/budget.py. Rounded; the tour's test fails if a figure drifts more than 100 lines of code from the code.

The loop itself is under 200 lines of code; the command line is the biggest package in the whole tree. The hard part is small and the surface around it is large. That's typical of harnesses in general, and worth noticing here.

flowchart LR
    ways["Ways in
cli/repl.py
cli/oneshot.py"] --> loop["core/loop.py
run_turn"] loop --> ctx["context/
build · compact"] loop --> prov["providers/
openai_compat · anthropic · fake"] loop --> exe["tools/execute.py
the pipeline"] exe --> pol["permissions/
decide, pure"] exe --> tools["tools/builtin/
tools/custom.py"] loop --> ver["core/verify.py
the gate"] loop --> disk[("storage/
session JSONL")] loop --> bus["core/events.py
EventBus"] bus -.->|"the only output path"| ways

Stage 1. The spine about 45 minutes

These four files are the whole system in miniature. Everything else is detail hanging off them.

1The loop

core/loop.py

Look for: run_turn _Turn _start _ask _run_tools _check Runtime

The file opens with the whole turn as pseudocode, and run_turn follows it step by step, numbered 1 to 10 in the comments. The diagram uses the same numbers. Each step with any detail is a helper one level down; no helper calls another, and nothing recurses.

flowchart TD
    start(["1 · record the prompt"]) --> safe["3 · safe point:
pause, /steer"] safe --> caps{"4 · over a
cost cap?"} caps -->|yes| budget(["budget_exceeded"]) caps -->|no| ask["5 · ask the model
_ask"] ask --> calls{"6 · tool
calls?"} calls -->|yes| run["run them in order
_run_tools"] --> safe calls -->|no| steer{"7 · steer
pending?"} steer -->|yes| safe steer -->|no| acted{"8 · changed
something?"} acted -->|"no, or no check"| done(["9 · completed"]) acted -->|yes| check["run the check
_check"] check -->|passed| done check -->|"failed, tries left"| safe check -->|"failed, none left"| failed(["verification_failed"])

A turn ends one of four ways:

  • budget_exceeded — the turn hit a cost cap.
  • completed — the model stopped talking and any check passed.
  • verification_failed — the check failed and no tries were left.
  • A cancel, which raises. First seal() gives every open tool call a result, so the transcript stays valid even mid-call.

Take with you: the safe point (step 3) is the only place a pause or a /steer takes effect. The previous round always ended on a complete call-and-result pair, which is what keeps the transcript valid (stop 16).

2The vocabulary

core/message.py

Look for: Message TextBlock ToolUseBlock ToolResultBlock ErrorRecord ThinkingBlock

These are the shapes the model sees: a small, fixed set of types for a whole conversation. The comment at the top of the file shows one written out.

  • Everything is frozen. Nothing here is ever edited in place. Compaction (stop 16) builds a new, shorter transcript instead.
  • A failed tool call is data, not an exception. It's a ToolResultBlock with is_error set, so the model sees it and can react.

3The output

core/events.py

Look for: EventBus Event TurnStarted ToolProposed PermissionResolved TurnFinished

These are the shapes the outside world sees. The loop and the tools never print to the screen directly — they emit events instead, and whoever is listening decides what to do with them. Each event's comment says who emits it, and the comment at the top of the file lists a typical turn's events in order.

flowchart LR
    src["loop · tools · providers"] -->|"emit(event)"| bus["EventBus"]
    bus --> r["cli/render.py
stdout: the answer"] bus --> s["cli/statusbar.py
stderr: status"] bus --> j["--events
one JSON line each"] bus --> t["tests
record and assert"]

4Done means verified

core/verify.py

Look for: verify Check

This is the smallest important file in the repository. It's edgar's answer to "how do you know the model actually did it?"

  • The model stops calling tools after changing something.
  • The declared check runs (a test suite, a lint, whatever the project named).
  • If it fails, the output goes back to the model, and it gets another try — while attempts remain.

Decide for yourself whether that is as strong as the README's claim that "nothing is done until it's verified."

Stage 2. The hands about 45 minutes

How the loop touches the world: where "the model asked" turns into "the thing happened".

5The tool contract

tools/base.py

Look for: Tool ToolSchema ToolContext ToolResult builtin_schema

A tool is small: a schema plus an async run function. The schema carries a category — read, write, shell or network — and that category is what the permission engine reasons about, not the tool's name. Every built-in tool builds its schema through builtin_schema, which closes the object to only the properties it names: no extra field a model invents gets through.

6The pipeline

tools/execute.py

Look for: execute _validate _failed

Every call from every source takes this one path, so this is the single place where a check on every tool call can sit.

flowchart TD
    callin["a tool call"] --> proposed[/"ToolProposed"/]
    proposed --> known{"tool exists?"}
    known -->|yes| shape{"arguments a
JSON object?"} shape -->|yes| valid{"match the
schema?"} valid -->|yes| guard[["guard.check
decide, ask if needed"]] guard -->|allow| runit["run, with a timeout"] runit --> spillit["spill big output to a blob"] spillit --> block["ToolResultBlock"] known -->|"no: not_found"| err["error result"] shape -->|"no: validation"| err valid -->|"no: validation"| err guard -->|"deny: permission_denied"| err err --> block block --> back(["back to the model"])

Nothing here raises an exception into the loop. A timeout or a crashing tool also becomes an error result — the model sees it and can recover from it, the same way it recovers from a bad argument.

7The decision

permissions/policy.py

Look for: decide _decide _mode Policy Allow Deny Ask

One pure function decides whether a tool call is Allow, Deny or Ask. Pure means no I/O: give it the same tool, subject and policy twice and it gives the same answer twice. The checks run in order and the first match wins, top to bottom in the diagram below.

flowchart TD
    in["tool, subject, policy"] --> c1{"catastrophic?"}
    c1 -->|yes| d1["Deny"]
    c1 -->|no| ll{"network call to a
link-local address?"} ll -->|yes| d3["Deny, hard"] ll -->|no| yolo{"yolo mode?"} yolo -->|yes| a0["Allow"] yolo -->|no| cred{"credential
path?"} cred -->|yes| d2["Deny"] cred -->|no| ctrl{"writes a
control file?"} ctrl -->|yes| k1["Ask"] ctrl -->|no| out{"outside cwd,
not granted?"} out -->|yes| k2["Ask"] out -->|no| rule{"config rule?"} rule -->|"deny, allow, ask"| r1["that answer"] rule -->|none| grant{"granted
before?"} grant -->|yes| a2["Allow"] grant -->|no| mode["the mode's default
tightened by taint"] mode --> dang{"allowed, but
dangerous?"} dang -->|yes| k4["Ask"] dang -->|no| final["the mode's answer"]
  • No one to ask. A one-shot edgar -p run has no human to answer an Ask, so decide turns it into a Deny with needed_prompt set, and the run exits with code 5.
  • Taint is sticky. Once a tool has returned content pulled from the network, auto mode asks before every shell or network call for the rest of the session — that page could have said anything.
  • The link-local check is a hard floor. It sits above even yolo mode. A URL that literally names 169.254.169.254 or another link-local address — the range every major cloud uses for its instance-metadata endpoint, which is how a stolen credential leaks — is denied before anything else runs. It only catches a literal address, not a hostname that happens to resolve to one (ADR-0049).

8The asking

permissions/guard.py permissions/matcher.py

Look for: Guard subject CATASTROPHIC credential inside link_local

decide (stop 7) is pure, so it can't touch a file, print a question, or remember an answer. Guard is where that I/O actually happens:

  • Works out what a call actually touches — which file, which path, which host.
  • Builds the Policy that decide reasons over.
  • Asks you, when the answer is Ask, and remembers an "always" answer for next time.

The matcher backs it with three lookups: the list of commands that never run, detection of a path that looks like a credential, and link_local, which reads a URL's host as a literal IP address and checks it against 169.254.0.0/16 and fe80::/10.

9The tools that can hurt you

tools/builtin/fs.py tools/builtin/shell.py

Look for: Read Write Edit Grep Shell Fetch

Read fs.py first. Fetch is the one to watch: its output is marked untrusted, and that's exactly what taints a session (stop 7).

10Your own tools

tools/custom.py

Look for: CommandTool HttpTool load

This turns a CLI or an API into a tool using nothing but config — no code.

  • A command tool is an argv template (a list of arguments), never a shell string a model could inject into.
  • An HTTP tool has its host fixed in config and reads secrets only from the environment, never from a value the model supplies.

Stage 3. The mouth about 45 minutes

Talking to models, which is where most harnesses hide their complexity.

11The provider port

providers/base.py

Look for: Provider ProviderResponse Usage Capabilities

This file is the contract every model vendor has to meet. Everything after this stop is an adapter written behind it — the loop never talks to OpenAI or Anthropic directly, only to this shape.

12Four vendors, one adapter

providers/openai_compat.py providers/quirks.py

Look for: OpenAICompatible Quirks QUIRKS connect Minted

OpenAI, Azure, OpenRouter, Ollama and any OpenAI-compatible server share one adapter. How they differ from each other is a table of data, not a branch of code written for each one. Check whether the table earns that claim: even what a provider can do — its context window, whether it reasons, whether it runs tools in parallel — is read off its row by HttpAdapter, never guessed from its name.

  • connect finds the credential before any request: a key from the environment, or else a token printed by a command the user named, such as gcloud auth print-access-token. That's how Azure without keys, Vertex AI and Bedrock sign in, with no cloud SDK installed.
  • Minted runs that command again once its token turns ten minutes old, so a long session never sits on a stale token.

Notice that the config loader refuses to run this command when it comes from a project's own config file, only from the user's. Ask yourself why — a cloned repository should not get to run an arbitrary command on your machine just because you opened it.

13The one that needed its own adapter

providers/anthropic.py

Look for: Anthropic

Every other vendor fits the shared OpenAI-compatible adapter. Anthropic doesn't, and asking exactly why is the point of this stop — its message format, and features like prompt caching, don't map onto that shape. Follow how a thinking block, its signature, and a cache breakpoint travel through this file and you'll see where the two protocols actually diverge.

14When the model gets it wrong

providers/repair.py providers/routing.py

Look for: text_call arguments select_model

Small local models often don't format a tool call the way the protocol expects — they wrap it in a code fence, or just write plain JSON in their reply. repair.py recovers a call like that deterministically. It never invents a call the model didn't actually make; it just reads one written in the wrong shape.

Routing decides which model handles a given role. In Core it's a pure function that binds roles to models — it never picks a model you didn't name yourself. v1 (stop 28) adds declarative [[route]] rules that run ahead of that binding: same first-match-wins shape as permissions.decide() (stop 7), still a pure function, still free to call.

Skip these on a first pass: pricing.py, registry.py, fake.py, http.py.

Stage 4. The memory about 30 minutes

15The prompt

context/builder.py prompts/system.md

Look for: build system_text pinned skill_index

Everything the model is told before the conversation starts lives in one short file, and build is the function that puts it in front of the transcript. One rule shapes it: nothing above the cache breakpoint may change within a session — no date, no timestamp, nothing volatile — because a provider's prompt cache only pays off when the prefix is byte-for-byte the same every turn.

16Compaction

context/compact.py core/units.py

Look for: compact elide fold assert_pairing units

This is the hardest correctness problem in the repository: shrinking a long transcript to fit the context window without ever separating a tool call from its result. Every provider rejects a transcript where that pairing is broken.

  • units.py defines the invariant: a call and its result travel together, as one unit.
  • compact.py only ever removes, stubs, or summarises a whole unit — never trims inside one.
  • The stages below run cheapest first, and stop as soon as the transcript fits.
flowchart TD
    view["the view"] --> big{"over
compact_at?"} big -->|no| same(["unchanged"]) big -->|yes| s1["S1 elide:
old results become stubs"] s1 --> ok1{"under
compact_to?"} ok1 -->|yes| done(["done"]) ok1 -->|no| s2["S2 fold:
old turns become one summary"] s2 --> done

17Sessions on disk

storage/transcript.py core/session.py core/cancel.py

Look for: Log entries replay Session seal chain

A session is recorded as JSONL: one line per record, appended, never rewritten and never edited in place. Undo, compaction and a cancel each just append a new record, and replay rebuilds the exact current view by reading the file from the top. Check that the append-only claim really holds everywhere, including for a cancel and for /undo — a place where it's tempting to reach back and change an old line instead.

Each line goes out in one write, newline included. So a crash can only tear the last line, and entries drops whatever comes after the final newline and keeps the rest. A bad line anywhere else still raises: that's damage, not a crash.

A fork (v1) keeps the same rule: it's a new file whose second line names its parent session and a turn number. chain reads the parent's lines up to that turn, then the fork's own lines, with a loop rather than recursion. save does the opposite: it flattens a whole chain into one file that can travel on its own, reading spilled output back in and passing every string through memory/redact.py first, so a saved session can't leak a secret.

Stage 5. The surface skim, 15 minutes

18The surface

cli/repl.py cli/slash.py cli/oneshot.py cli/render.py cli/setup.py cli/statusbar.py config/load.py

Look for: Shell dispatch run_prompt Renderer Status

This is where most of the line count goes. Skim it, don't study it — it's plumbing, not the hard part. Two things worth noticing on the way through:

  • Status rebuilds itself from nothing but the event stream, same as the renderer does. Every event carries depth and agent_id, so a subagent running alongside the main turn (stop 28) gets its own row underneath instead of its output tangling with the main one (SUB-9).
  • runtime() is where a model with no tool support gets refused up front, before the first prompt is even sent — not later, on its first failed tool call (ROUTE-6, stop 28).

Stage 6. The know-how about 10 minutes

19Skills

skills/discovery.py tools/builtin/skill.py

Look for: discover split Found SkillTool

A skill is just a folder with a SKILL.md file in it, in the same format Claude uses.

  • At startup only the frontmatter is read, so the prompt carries one short line per skill (skill_index in context/builder.py) — not the whole file.
  • The body only loads when the model actually calls the skill tool for it.
  • discover has an order: the project's own skills beat the user's, and anything a human wrote beats anything the harness learned on its own.

A skill is instructions, never code that runs on its own. If it bundles a script, that script goes through the shell tool and its permission check, exactly like any other command a model asks to run.

Stage 7. The rest of it reference, dip in as needed

Six stops that finish the map of Core. Nothing here is on the critical path of a turn, which is why it comes last — but every file in src/edgar is now named by some stop on this tour, and a test fails if one stops being.

20Errors that stop, and a question that does not

core/errors.py core/aside.py

Look for: EdgarError ContextOverflow VerificationFailed Cancelled ask request

errors.py is the whole taxonomy of what can go actually wrong, in under thirty lines, and each class carries the exit code it produces [CLI-10]. It encodes the same rule as stop 6: a tool failure never raises an exception, it goes back to the model as an error result it can work around. These classes are only for the things the harness truly cannot proceed through — a budget fully spent, a context that won't fit even with only pinned content, a verification that ran out of tries.

aside.py is /btw [CLI-24, ADR-0028]: a side question you can ask mid-turn that never touches the real conversation. The interesting part is request — it takes the request the turn would have sent and cuts it back to the last complete unit, so the aside never ends on a tool call with no answer yet (the same pairing invariant from stop 16). It reuses the cached prefix, sends no tools of its own, and its replies stream to a private channel so the turn already in progress keeps the screen.

21The schema is the config

config/schema.py permissions/control.py

Look for: Config SECTIONS TABLES LATER ProviderSection PermissionsSection control_files is_control _TREES

  • Every key edgar accepts is a dataclass field in this file: the type hint is the validation rule, and the default is the default. So "what does this config key take?" has exactly one place to look for the answer [CFG-3].
  • Validation itself is hand-written in load.py rather than handed off to a validation library — the same dependency-budget reasoning as everywhere else (ADR-0019). It's also what lets an error message name the exact file, key and expected type.
  • LATER is worth finding: a key that belongs to a tier not built yet is recognised and quietly ignored, so a config file written for v3 doesn't error out on a v1 install.

control.py is thirty-five lines that decide which files steer edgar itself — the prompts, agents, tools, extensions and skills trees, plus config.toml and its siblings. Writing or editing one of these is Ask in every mode except yolo [PERM-12]. The reason: a model that can rewrite its own instructions can widen its own policy, and only a human is allowed to do that (ADR-0021).

22The prompt file, the counting, and the spill

context/prompts.py context/tokens.py tools/spill.py

Look for: Prompt load_prompt choose_profile COMPACT_BELOW SHIPPED approx_tokens CHARS_PER_TOKEN Spilled spill

The system prompt is a plain file, not a string buried in the source [CTX-16, ADR-0023]. A project can ship its own, and because that file is a control file, the model can't change it without asking first. choose_profile is the one piece of real behaviour here: below COMPACT_BELOW, a smaller model gets a shorter version of the prompt [PRV-17].

tokens.py is twenty lines and honest about what it's doing: it guesses four characters per token, used only for the compaction thresholds, while exact counts still come from the provider itself. Read CHARS_PER_TOKEN and decide whether a rough guess like that can hurt you — the answer is in which direction compaction's estimate errs.

spill is stage S0 of the context pipeline [TOOL-4, CTX-13]: when a tool's output is too big, it keeps the head and the tail in the prompt and writes the whole thing to a blob file instead. The model can still read that blob with an offset, so nothing is actually lost — it's just kept out of the prompt by default. Thirty lines, and the reason the session file itself never needs to be compressed.

23Which tool wins a name, and what outlives the session

tools/registry.py storage/db.py

Look for: ToolRegistry core_registry registry_for cost _defers exposed Store SCHEMA grant trusted spend

registry.py answers two questions:

  • If two tools want the same name, which one wins? Sources are added lowest priority first — built-ins, the skill tool, MCP, user, project — so a later, more specific source can replace an earlier one. That replacement is always reported, never silent [TOOL-9].
  • Which tool schemas can a request actually afford? cost adds up their size, and past tools.schema_budget the MCP ones get deferred behind tool_search instead of being sent up front [TOOL-15]. That's the mechanism the MCP page builds on.

db.py is the only SQLite database in Core: it holds grants, trust decisions, each day's spend, and each MCP server's cached tool list [PERM-6, PERM-13, BUD-2, TOOL-8]. There are two database files, and which fact lives in which is worth reading closely:

  • .edgar/edgar.db — grants, scoped to this one project.
  • ~/.edgar/edgar.db — trust and spend, shared across every project you use.

Never a config file for either — config is what a human edits, a database is what the machine records on its own. Both run in WAL mode with short transactions and a busy timeout, because the folder might sit under a sync client like iCloud Drive, and the file is only created once there's actually something to store in it.

24What the adapters share

providers/http.py providers/fake.py providers/pricing.py

Look for: HttpAdapter RETRYABLE Retry TIMEOUT events tool_calls error_message FakeProvider _rules cost_of BUILTIN CHECKED

http.py is the biggest file in providers/. It holds everything the two real adapters would otherwise each get slightly wrong on their own: the HTTP request and its retries, server-sent events, mapping a provider's errors to edgar's own, token counting, and assembling a tool call out of a stream. events and tool_calls are the place to look when a provider's stream is chopped up differently from everyone else's. It's imported only by the adapters, so httpx never loads at all on a run that never reaches a provider [PRV-4, NFR-1].

fake.py is a model that runs nowhere: three fixed rules, offline and deterministic. It's why edgar can do something useful before you've configured a single API key, and why almost every test in the suite can drive the real loop without needing a recorded cassette (ADR-0009).

pricing.py is thirty-five lines with one opinion baked in: unknown pricing is None, shown to you as "unknown" — never a wrong zero [BUD-1, BUD-5]. A cost cap that silently counts nothing toward its total is worse than one that admits it can't count. CHECKED dates the shipped price table, and a [pricing] entry in your own config always wins over it.

25The command line

cli/main.py cli/admin.py cli/models.py cli/trust.py edgar/__main__.py

Look for: main build_parser ADMIN USAGE command describe pick ROLES trusted digest executable

Read main.py with the startup budget invariant in mind: nothing heavy may be imported at module level, because every single run of edgar passes through this file first. tests/e2e/test_startup.py fails the build if httpx, rich, prompt_toolkit, jsonschema, yaml, keyring or any edgar.providers module gets pulled in just by importing this one (NFR-1, ADR-0012). Those heavier imports live inside main() instead, where they only run once you're actually doing something. __main__.py is three lines, just so python -m edgar and the installed console script are the same program.

admin.py collects the subcommands that look at a project instead of running a turn — each is one branch of command(): read something off disk, print it, return an exit code. None of them talks to a model. Only three actually change anything: trust, revoke and sessions rm, and each changes only the one thing it names.

models.py backs edgar models list and the model picker [CLI-30, ADR-0034]. describe prints where a prompt would actually go — every provider, its endpoint, whether its key variable is set — without contacting anything [PRV-15]. pick is the sensible-defaults rule made concrete: press Enter to save the choice for every project, p for just this one, n for neither.

trust.py is why cloning a repository and running edgar in it can't silently run someone else's code [PERM-13, CLI-19]. The parts of project config that can execute something — command and HTTP tools, a verify.command, an [mcp.NAME] server, hooks and extensions — only run once you've explicitly trusted the project. Trust is keyed to the project's path plus a digest of that config, so editing what it would run asks you again.

PART II.

v1

M7 to M11, all shipped in 1.0

Extensible, and it remembers: memory, MCP, subagents, extensions and hooks. At most 8,000 lines of code for Core plus v1, combined. Nothing in v1 changes the shape of a turn — each new piece just plugs into a seam you've already read about in Core.

flowchart LR
    subgraph read["Seams you read in Core"]
        builder["context/builder.py
pinned sections"] registry["tools/registry.py
tool sources"] exe["tools/execute.py
the pipeline"] loop["core/loop.py
run_turn"] prov["providers/
the port"] end mem["memory/
facts, frozen at session start"] --> builder mcp["tools/mcp/
MCP servers"] --> registry hooks["extensions/hooks.py
pre_tool vetoes"] --> exe task["tools/builtin/task.py
a subagent"] -->|"re-enters with a fresh session"| loop fb["providers/fallback.py
sideways on an outage"] --> prov

26Memory and session search

memory/store.py memory/recall.py tools/builtin/memory_tools.py

Look for: add confirm _Op pinned Fts5Retriever Remember

M7 has a tour of its own: Memory, four stops on the boundary, recall, the markdown round trip, and forks and saves. What to carry from here:

  • An active fact comes only from text a human actually typed.
  • The model's own remember call only ever makes a pending fact — never in the prompt, never in the search index, until a human confirms it.
  • A fact's text never changes. Every confirm or forget is a new status change under the same operation number, which is why edgar memory undo can always put it back exactly as it was.
  • The pinned set (what actually reaches the prompt) is chosen once, at session start, above the cache breakpoint — so a fact you add mid-session shows up starting next session, not this one.

27MCP

tools/mcp/client.py tools/mcp/schema.py tools/builtin/tool_search.py

Look for: Server discover McpTool to_schema ToolSearch

M8 has a tour of its own: MCP and signing in, four stops on the lazy start, deferred schemas and the loopback dance. What to carry from here:

  • An MCP server is just one more tool source, nothing special. Nothing outside tools/mcp/ even knows the protocol exists.
  • A session starts no server at all until one of its tools is actually called.
  • A turn itself never opens a browser. edgar mcp login NAME does, because a human ran that command on purpose.

One detail belongs to Core rather than to M8: whatever a server claims about its own tools (readOnlyHint and friends) is only ever shown to you by edgar mcp list — decide() never reads it or trusts it. An MCP tool is always treated as untrusted_output, and its category is shell for a local program or network for a URL, regardless of what it claims about itself.

28Subagents, routing rules and fallback

tools/builtin/task.py agents/spawn.py tools/execute.py

Look for: TaskTool spawn narrow_mode SpawnLimits execute_many

M9 has a tour of its own: Subagents, four stops on what an agent file is, how spawn() narrows, how consecutive task calls fan out, and why routing, escalation and fallback stay three separate things. What to carry from here: a subagent is not a second engine (ADR-0006) — it's the same one, called again.

  • spawn() builds a second Runtime and calls the very same run_turn from stop 1, just with a fresh Session, a narrowed policy, and a slice of the parent's remaining budget.
  • Its permission prompts share the parent's guard, so they share the same asking lock and show up carrying the agent's own name.

edgar agents list|validate is cut from 1.0 (ADR-0053) — that's a deliberate trim, not work still owed. Roadmap: M9

29Extensions, hooks, plugins, embedding

extensions/discovery.py extensions/hooks.py skills/activate.py __init__.py

Look for: discover Hook veto matching verify_command run

M10 has a tour of its own: Extensions, five stops on the manifest, hooks that veto, provider plugins, skill activation and edgar.run(). What to carry from here:

  • An extension is just a folder of files.
  • A hook can only observe or veto a call — it can never widen what's allowed (EXT-4..7).
  • Python is a plugin surface only for providers, retrievers and sandboxes, nowhere else (ADR-0018).

These are the formats 1.0 freezes for good. edgar ext list is the whole CLI surface 1.0 ships for them; ext validate and ext add, with the skill audit that gates a copy, arrived later in v2 — see stop 6 on that page.

One piece belongs here rather than on that page, because it closes Core's verify gate (stop 4): deterministic skill activation [SKL-17]. It means the model never has to remember to call the skill tool itself for a keyword the user just typed, or a file path the last round touched — matching() finds the relevant skills on its own, and bodies() loads them straight into the turn's attached context. Those same matches also feed the precedence chain behind VER-1: verify_command() falls back to a loaded skill's own verify: command whenever nothing ranked higher (--verify, then an agent's own frontmatter) has already claimed the turn. Roadmap: M10

30Init and doctor

cli/init.py cli/doctor.py templates/config.toml

Look for: run _scaffold _gitignore command _connectivity

edgar init and /init [CFG-4] scaffold a new project. They write:

  • an AGENTS.md file,
  • a .gitignore fragment for .edgar/'s generated state,
  • and a working, commented config.toml.

All three come from plain files under templates/, not Python code, so they cost nothing against the line-of-code budget — same as prompts/*.md. Every write checks whether the file already exists first and leaves it alone if so (ADR-0034's rule, extended here). init.run() is shared code: it's called both from the CLI and from cli/repl.py's "nothing configured yet" startup path, so a fresh checkout gets real scaffolding, not just a model picker.

edgar doctor [CFG-5] checks each provider's credentials (reusing cli/models.py's describe()) and warns when a project sits inside iCloud Drive, OneDrive, Dropbox or Google Drive — a plain substring match on the resolved path. That match can't catch macOS's silent Desktop & Documents sync, and the gap is named in a comment rather than hidden.

v2 rounded doctor out with four more checks (_trust, _db, _mcp, _ext): whether this project is trusted, a PRAGMA integrity_check on both databases, whether a stdio MCP server's command is even on PATH, and whether each extension's required commands are too. A default run opens no network socket at all — a remote MCP server isn't probed, because the only probe actually worth anything is the handshake edgar mcp test NAME already performs, and the one provider check that does go out over the network lives behind --network, with the output line saying so whenever it's skipped. Roadmap: M11

PART III.

v3 and v4

v3 learning, M12 to M15 v4 unattended, M17 then M16

v3 learns: learning, the controller, skill synthesis and escalation — at most 12,000 lines of code in all. v4 runs unattended: the capability broker and scheduling — at most 13,000. Both tiers are fully removable. Nothing in Core, v1 or v2 imports them, and CI proves it by deleting both packages and running the whole test suite anyway.

flowchart LR
    subgraph v1["Core, v1 and v2: never import v3 or v4"]
        loop["core/loop.py
post-turn gate"] exe["tools/execute.py
pre_tool vetoes"] bus["core/events.py
EventBus"] end ctrl["controller/
triggers, proposals"] -.->|"loaded by name"| loop syn["learning/synthesis.py"] -.-> loop broker["broker/authorize.py
tickets and caveats"] -.->|"loaded by name"| exe exp["learning/experience.py
telemetry"] -.->|subscribes| bus sched["schedule/tick.py"] -->|"runs edgar -p on a clock"| v1

31Learning foundations

learning/learner.py learning/error_facts.py learning/experience.py

Look for: Learner extract ErrorFacts TEMPLATES Recorder Stats

M12 has a tour of its own: Learning, four stops on the boundary, the telemetry, the error templates and history.md. What to carry from here: Learner only ever hears about one kind of event, PromptTyped.

  • PromptTyped is built in two places, always from the line you typed and nothing else — so a fetched web page, a piped file, or a tool result has no road at all to becoming a learned fact.
  • The only other source is an ErrorRecord's four fields, turned into a fact through a template rather than quoted verbatim, and only once the same failure has happened three times.
  • Every other candidate — the model's own remember call, edgar history distill — lands as a pending fact and waits for a human to confirm it.

32The controller

controller/triggers.py controller/proposals.py controller/tighten.py

Look for: tripped ACTIONS parse narrow is_narrowing

M13 has a tour of its own: The controller, three stops on the triggers, the whitelist and the tightening. What to carry from here: self-management that can't hurt you comes down to three refusals.

  • Whether to call a model at all is decided by five plain comparisons, not by asking a model.
  • What it's allowed to ask for is eight frozen types, and there is no ninth — in particular, no learn.
  • Policy only ever moves one direction, through a pure function whose last step double-checks its own answer.

Everything that persists is dry-run by default, logged, and revertible.

33Skill synthesis

learning/synthesis.py learning/observations.py learning/distill.py

Look for: triggers Outline write_learned observe command

M14 has a tour of its own: Skill synthesis, five stops on when edgar writes its own instructions and what it is not allowed to read while doing it. What to carry from here: an agent writing its own standing instructions is safe only under refusals.

  • Four plain checks decide whether to try, and three of them need the project's own test command to have passed.
  • The writer is given an outline that has no field for a tool's output — the type is the filter, so it cannot fall out of date.
  • It writes into one machine-owned folder, never over a name a human owns, and by default only as a proposal you read first.

Choose auto and edgar says so at the start of every session.

34Escalation

providers/escalation.py

Look for: EscalationState Triggers _due chain_from_config

A weak model that fails gets help from a stronger one, visibly: upward only, capped, and announced so you can see it happened. Compare it with routing (stop 14) and fallback (stop 28) — the point of this whole page is keeping the three apart, never letting one quietly become another.

  • Escalation reacts to a pattern, not one failure: tool-call errors, consecutive rounds where every call fails, or the model losing the tool-call format (read straight off Usage.repairs), each with its own threshold.
  • core/loop.py never imports this file — it declares an _Escalator protocol locally and calls it structurally, so the removable tier stays removable (NFR-12). cli/setup.py is the only place that reaches in, through import_module.
  • The chain only ever moves up: once a model is reached it is never tried again in that session, and crossing model families drops reasoning for the rest of the turn (stop 6's ThinkingBlock origin rule).

edgar route suggest (ROUTE-12) and the Responses API adapter (OQ-8) stay open questions past M15 — see the ADR for why. Roadmap: M15

35The capability broker

broker/caveats.py broker/ticket.py broker/authorize.py

Look for: Caveat parse_scope Ticket attenuate verify_chain authorize

M17 has a tour of its own: Capability broker, four stops on the ticket a typed request opens, the pure veto one step before the permission engine, the signed receipt, and edgar receipt. What to carry from here: a subagent spawned under a ticket can only narrow its caveats through attenuate(), never loosen them (ADR-0039), the same "humans widen, machines tighten" rule (invariant 2) applied to what a session is scoped to rather than to its permission mode.

The controller's tighten_policy (CTRL-8), ADR-0039's fourth caveat source, is not built yet — see the roadmap for why. Roadmap: M17

36Scheduling

schedule/due.py schedule/tick.py schedule/state.py schedule/parser.py schedule/run.py schedule/install.py schedule/cli.py schedule/store.py schedule/tool.py

Look for: due parse_when tick catch_up FileState load append runner_for install command SelfSchedules ScheduleSelfTool

M16 has a tour of its own: Scheduling, five stops on unattended runs that cannot run away — due dates as a pure function, the overlap-safe tick, append-only schedules.toml, the host installer behind edgar tick, and schedule_self, the model's own way to schedule a future run, guarded so that run never holds more authority than the one that created it. What to carry from here: a scheduled run's scope and allowlist become the same --scope caveats a human turn would pass (invariant 2).