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