A Tour of the Harness · v1 · M10

Extensions

Everything you can add without writing Python, the one place you can, and the door for other programs. Five stops, about half an hour.

Built M10, shipped in 1.0
Kept in sync by a test

M10 is what 1.0 actually freezes. The formats on this page — the manifest, the agent file, the skill folder, the hook entry, the tool kinds — are a promise that someone else's extension will still load in 2.0 (ADR-0055). The rule that keeps them small: extensions are files; Python is a plugin surface for providers, retrievers and sandboxes only (ADR-0018).

flowchart TD
    dir["an extension folder
edgar.toml + files"] --> man["manifest: name, version,
what it contributes"] man --> t["tools
command · http · mcp"] man --> s["skills"] man --> a["agents"] man --> h["hooks"] t --> reg["the ordinary registries,
with a source recorded"] s --> reg a --> reg h --> gate["observe, or veto —
never allow"] ep(["entry points
provider · retriever · sandbox"]) -.->|"the only Python surface"| reg

Stage 1. A folder of files about 12 minutes

1The manifest

extensions/manifest.py extensions/discovery.py

Look for: Manifest read _NAME Found discover _one disabled_from_config

An extension is just a directory with an edgar.toml in it — the rest is ordinary files. Manifest is forty lines because it's a description, not a program: a name, a version, and lists of what the folder contributes. _NAME is the character rule for that name, and it's strict on purpose — the name becomes part of a tool name, which the model sees and a permission rule might match against.

discover uses the same layering as skills and agents:

  • built-in extensions load first,
  • then the user's own,
  • then the project's — the later one wins when two share a name.
  • Every extension found remembers its own path in Found, which is how an error can say which of two files called git it means.

disabled_from_config is the half worth dwelling on: turning an extension off is a config edit a human makes. Nothing in the running system can turn one back on by itself — the same rule permissions follow, applied here to loading code instead of running a tool.

2Hooks that veto

extensions/hooks.py

Look for: EVENTS Hook from_config from_extension _matches _run veto listener _fire

A hook is one command line attached to a named event. Read EVENTS first: the list is short and closed, so there's no way to attach a hook to some internal detail that might move later. _matches is the filter — a hook usually cares about one tool, or one path pattern — and it runs before the command is even spawned, so the common case (nothing matches) costs nothing.

The important asymmetry lives in veto:

flowchart LR
    event["an event fires"] --> hooks["every matching hook runs"]
    hooks -->|"refuse"| deny["blocked — an error tool result,
with a reason, back to the model"] hooks -->|"observe"| log["logged, nothing changes"] hooks -.->|"approve a denial?"| never["never — not a thing a hook can do"]

A hook can refuse something, and it can observe something. It can never approve something the permission engine already denied (ADR-0021). A veto isn't a crash either — it comes back as an ordinary error tool result with a reason, exactly like any other tool failure, so the model can adapt instead of the whole run dying.

_run is where the care shows: a timeout, a cap on captured output, no shell — and a hook that crashes or times out is treated as a hook that said nothing, not one that said no. Whether that's the right default is a real trade-off; the code comment says so, and you can decide for yourself.

Take with you: listener and _fire keep observe-only hooks off the critical path. Only a vetoing event is actually waited for.

Stage 2. The Python surfaces about 10 minutes

3Provider plugins

providers/registry.py

Look for: BUILTIN KINDS split resolve _plugin

Eighty lines, three jobs:

  • split — parses a string like provider:model into its two parts.
  • resolve — maps a provider name to its adapter through BUILTIN, importing that module only at the moment it's actually needed. No edgar.providers.* import at startup — that's invariant 3 (ADR-0012).
  • _plugin — the third-party door: an entry point group, checked only after the built-ins. A broken plugin fails by naming the distribution that registered it, rather than raising some confusing error later inside the loop.

This is one of the six ports (ADR-0022). Worth reading alongside it: the test asserting core imports no adapter at all. The seam is enforced by a test, not just intended by convention.

4Skill activation

skills/discovery.py skills/activate.py

Look for: Skill lint matching activate bodies

A skill is a markdown file, or a folder with a SKILL.md plus whatever else it needs (ADR-0041). Only its name and description sit in the prompt. The body is read only when the model calls the skill tool — the same deferral trick as MCP's deferred schemas, and for the same reason: don't pay for what nobody asked for.

  • matching — the cheap pre-filter that decides which skills even look relevant.
  • activate — the actual load, once the model asks for one by name.
  • bodies — what gets handed back to the model.
  • lint — the one that saves someone an afternoon: it's what edgar doctor uses to say a skill's front matter is wrong, right in the file, before a session ever tries to use it.

This module is a port too — a skill source, per ADR-0022 — which is exactly why discovery and activation can live as two separate steps.

5edgar.run()

edgar/__init__.py

Look for: run __version__

The whole embedding API is one function, and its plainness is the point. It takes a prompt and returns a result. Everything else — config, provider, tools, permissions — comes from the exact same layered resolution a normal CLI run uses. A program embedding edgar can never accidentally get a different harness than the one its user actually configured.

  • Its imports live inside the function, because import edgar has to stay as cheap as import edgar.cli.main.
  • There's no callback parameter. Streaming and progress come off the event bus instead, the same decision made everywhere else in edgar (ADR-0011).
  • __version__ lives here too, bumped together with pyproject.toml at release time.

6The gate before a copy

skills/audit.py extensions/validate.py

Look for: DANGERS audit render diff validate _agreed

Extensions are copied in from a path, never installed from an index — which means the copy is the only moment anyone actually checks what's arriving (ADR-0042). audit keeps two separate questions apart:

  • Conformance. Does it load? Does the description say when to use it? Do its own paths stay inside its own folder?
  • Danger. A download piped into a shell, sudo, a recursive delete, text that tells a session not to ask or not to tell, hidden characters, unfamiliar hosts, places credentials might leak to, and every bundled script together with the programs it calls.

DANGERS is a table, one row per rule, so adding a new pattern means adding a row and a test — not another branch in an if-chain. Notice no model is asked anything here: the text under review might be hostile, and a verdict a model hands down is a verdict a prompt injection can argue its way around.

diff prints the fixes it would make and never applies them, so the audit itself never writes anything. In validate.py, _agreed is "humans widen, machines tighten" packed into one function:

  • a clean audit asks [Y/n] — the safe default is yes;
  • a danger finding asks [y/N] — the safe default is no;
  • with no terminal to ask, a danger finding is refused outright unless a human already passed --yes.

The copy itself lands in a staging folder first, then one rename moves it into place. A session sees the whole extension, or none of it — never half of one.

Sizes

Lines of code: blank lines and comments do not count, docstrings do. just loc prints the current totals.

FileLines of codeWhat it is
extensions/manifest.py~43What a folder declares it contributes
extensions/discovery.py~70Built-in, user, project; and what is switched off
extensions/hooks.py~116Named events, filters, and the veto
providers/registry.py~80Lazy resolution, and the entry-point door
skills/discovery.py~123The skill source port: find, lint, and the learned-skill shape
skills/audit.py~180Conformance, dangers and a diff it never applies
extensions/validate.py~102ext validate, and the gate ext add passes
skills/activate.py~36Bodies, loaded only when asked for
edgar/__init__.py~78One function, and the version

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