Agents
magus knows more about a workspace than any agent can rediscover by reading files: the project DAG, every target's inputs and declared outputs, which files are generated, what a diff affects, where churn and coupling concentrate.
The agent surface exposes that through three artifacts, and one rule governs all of them. Every tool answers a question from declared sources; none of them decides, plans, or injects itself into the agent's context. Answering is the tool's job, deciding is the model's.
| artifact | answers | freshness |
|---|---|---|
MAGUS.md |
WHAT is in this workspace (targets, counts, anchors) | regenerated with the workspace (magus describe graph -o markdown) |
| Skills | HOW to use the magus tool surface | ships with the binary; versioned, drift-checked |
| MCP surface | live answers (query, run, explain, logs) | host-managed; clients discover it at task start |
The split is deliberate: skills never mention workspace specifics, so they go stale only when the tool surface changes, and that staleness is detectable.
MAGUS.md is per-project as well as per-workspace. A nested project can commit
its own, generated by its generate target and scoped to that project's
targets, so an agent working inside one project gets a routing index sized to
it.
Hosts, not LLM providers
An LLM provider supplies a model; a host (or harness) decides which skills, MCP tools, hooks, and instruction files that model receives. Claude Code, Codex, Cursor, and OpenCode therefore differ at the host layer, even when they use models from the same provider. magus normalizes its guidance and guard verdict, not those host capabilities.
Set up your host
A full setup is two steps: install the guidance where the host reads it, then wire the guard hook. Both are on your host's page.
| host | skills | guard |
|---|---|---|
| Claude Code | .claude/skills/ |
shipped PreToolUse scripts via harness spell |
| Codex | .agents/skills/ + AGENTS.md |
shipped PreToolUse scripts via harness spell |
| Cursor | AGENTS.md only | one self-contained script |
| OpenCode | .opencode/skills/ |
TypeScript plugin (harness spell is skills-only) |
| Any other host | wherever it reads them | a collaborator-owned harness descriptor or small adapter |
What you wire is yours. Prefer a harness spell selected from the root magusfile
(import "ghcr.io/egladman/magus/spells/<id>"; then magus\harness.provider(<id>),
with the spell declared in magus.yaml and pinned in magus.lock;
Remote spells explains the pin).
Wire several providers when you bounce between hosts; each call appends.
To adapt a Buzz harness without modifying Magus source, copy the shipped
spell into the workspace and declare the copy as a path: override of the
registry path in magus.yaml. The import and magus\harness.provider(...)
stay as they are. Edit the workspace Buzz, then
magus agent harness apply / verify. That ownership switch is documented in
the workspace-rules skill
under "Adapting a Buzz harness" and in Recurring guard friction. Additive deny/advise
that is not host-shaped stays in magus\guard.shell({...}).
magus agent harness apply (no --id) applies every magusfile-wired provider;
--id <id> targets one spell. verify reports whether the fragments are still
present and that the config invokes magus. A host Magus has never heard of can
ship its own harness spell without a Magus release, by the same ownership switch
as adapting a shipped one; a portable adapter remains an option where a native
event contract is not available. Doctrine
records that trade and what it costs you.
This repository as a worked example
The magus repository wires every shipped provider on itself and commits what
that writes, so the wiring can be read whole rather than assembled from the
pages above. Its maintainers do not use every host; the files for the others are
kept as examples. magus agent harness verify reports whether each is still what
its spell writes, and the root tests grade the committed hook configs against
each host's vendored schema.
| file | what it shows |
|---|---|
magusfile.buzz |
the four magus\harness.provider(...) calls that select hosts |
.claude/settings.json |
Claude Code guard hooks, as the spell writes them |
.codex/hooks.json |
Codex guard hooks |
.cursor/hooks.json |
Cursor hooks, all routed through one script |
AGENTS.md |
the managed skills block between its magus:skills markers |
Installed skills are not committed, because magus agent install regenerates
them from the binary. The two under .claude/skills/ that are committed are
written by hand for working on magus itself.
The shared reference pages sit behind those: Skills for the
install surface, The guard for what is denied and why,
Guard hook templates for the files Claude Code
and Codex run, Session load adapters for reading a
host's own session log back into magus,
Attention hooks for magus session notify,
and Jobs and leases for the surface an agent uses when it
fans work out across several.
When a session loses its history
A host that compacts a long session replaces what happened with a summary of it, and the model works from that summary. The branch it is on, what it has already changed, which rules it agreed to, and the failure it was in the middle of all survive only as a retelling, and nothing in the transcript says how much each retelling lost.
magus session --brief answers that with state instead of prose: branch and
revision, commits not yet on the base ref, the dirty tree split by the same
classifier magus describe file uses, the live leases with the command that
binds each one, the last recorded run's failures with the ref that holds their
output, whether any hook config here invokes magus, and where this workspace's
rules live. When recurring guard evidence needs a human decision, it adds one
bounded line pointing to magus doctor (its recurring-guard-denials check).
Every line is read off the disk on the call, so none of it can
degrade; it restates no rule, because a rule copied into a context block is a
second copy to go stale.
magus prints it and your host places it. Wire
magus-rehydrate.sh to whatever
event your host fires after compaction: SessionStart with matcher compact on
Claude Code and on Codex, where
REHYDRATE_FORMAT=json wraps the text in the reply Codex parses.
OpenCode has no hook to point at a file, so its plugin pushes
the same brief into the compaction prompt itself. Cursor is
the one host that cannot: preCompact returns a message for the person and
nothing for the model, so there run the command yourself and read the same thing.
The review belongs here rather than in a Stop hook: a stop event can record
evidence, but cannot reliably inject an actionable message into the model's next
context.
Parity across hosts
Every host gets the same RULES - they come from one binary, and none of them is per-host. What differs is how much of a verdict a host's hook surface can carry. Claude Code is the reference contract: parity means every supported adapter exercises the same guard decision cells and preserves the verdict semantics. It does not mean vendors expose the same tools, payloads, or lifecycle events.
Manual host end-to-end harness
The ordinary checks are deterministic: vendored schemas validate configuration and reply shapes, and the fixture-driven transport cases execute every guard decision cell against the shipped adapters. That proves the guard's decision and adapter behavior for controlled events; it does not prove that a locally installed vendor client discovers configuration or dispatches a real hook.
That separate, opt-in end-to-end check currently proves one narrow boundary: host discovery and delivery of a denied command. It is not a general host certification or a replacement for the deterministic cases:
MAGUS_HOST_E2E=1 magus run host-integration docs/guides/integrations/agents -- \
host-e2e/claude-code.json host-e2e/codex.json host-e2e/opencode.json
It is deliberately not part of ci or magus affected ci. The runner makes a
temporary Magus workspace and applies each supplied descriptor's isolated host
configuration, then asks the host to use Bash for the denied command. The guard must
intercept that call and render a deny; a broken hook can affect only the
temporary workspace. A missing selected host is reported as a structured skip,
while an installed host that never dispatches its hook is a failure. A run in
which every selected host skips is inconclusive and exits nonzero
rather than looking like a pass. Failures retain their workspace with the trace,
generated configuration, and capped host stdout/stderr logs for inspection.
Successful workspaces are deleted by default; set MAGUS_HOST_E2E_KEEP=1 to
retain them deliberately.
It can use local authentication and quota, so the environment variable is a second explicit confirmation. The descriptor is the extension seam: a user can add a host without changing the runner by declaring its binary, isolated setup, launch command, and evidence contract. A descriptor must represent a real host dispatch path; the runner never substitutes a fixture and calls that an end-to-end pass.
| command rules | declared-output rule | deny | advise | manual E2E: command discovery and block dispatch | MCP call rules | |
|---|---|---|---|---|---|---|
| Claude Code | yes | yes | yes | yes | yes | wired, rule-empty; live pending |
| Codex | yes | yes | yes | yes | yes | wired, fixture-verified; live pending |
| Cursor | yes | yes | yes | yes | unsupported | not wired: event exists, payload does not |
| OpenCode | yes | yes | yes | yes | yes | not wired: sees call, tool name unconfirmed |
"Fixture-verified" means the adapter executed against this binary with a
controlled host event on stdin. "Manual-E2E-proven" means a locally installed
host discovered an isolated configuration, dispatched the git stash command
hook, and delivered its deny. Neither label proves filesystem-path delivery,
MCP delivery, or lifecycle delivery unless that cell says so; those remain
pending. "Wired, rule-empty" means the transport carries a verdict end to end
but the guard has no MCP-specific rule yet, so every call passes - not because
the channel is silent, but because nothing has been asked of it.
Both decisions now reach the model everywhere, which they did not until recently: one host was sent no advisory at all, one delivered it to the person on stderr, and one logged it. What still differs is WHEN. A host whose gating event carries a message only with a denial has to send the explanation on its post-tool event instead, which means the call has already run and that magus was asked about it twice, leaving two rows in the activity trail where the other hosts leave one.
What each host carries
One row per job magus does through a host event. A cell names the event that carries it, or the reason nothing does. This is the intended host capability mapping, not a claim that the manual command/deny E2E run has proven delivery of each row. Path, MCP, and lifecycle delivery remain pending unless the evidence matrix above explicitly marks them proven.
| job | Claude Code | Codex | Cursor | OpenCode |
|---|---|---|---|---|
| command guard, deny | PreToolUse Bash |
PreToolUse Bash |
beforeShellExecution |
tool.execute.before |
| command guard, advise | additionalContext |
additionalContext |
postToolUse.additional_context |
tool.execute.after |
| write guard, deny | PreToolUse on the edit tools |
PreToolUse on the edit tools |
preToolUse |
tool.execute.before |
| write guard, advise | additionalContext |
additionalContext |
postToolUse.additional_context |
tool.execute.after |
| MCP call guard | PreToolUse mcp__magus__.* (rule-empty) |
PreToolUse mcp__.* (fixture-verified; live dispatch pending) |
not wired: payload unconfirmed | not wired: tool name unconfirmed |
| post-compaction rehydration | SessionStart compact |
SessionStart compact (JSON envelope) |
not expressible: preCompact returns user_message only |
not wired: only a PRE hook exists |
| checkpoint | Stop |
Stop |
sessionEnd |
the session.idle bus event |
| lease provenance | PreToolUse on the sub-agent tool |
no event carries the handed prompt | subagentStart (unverified live) |
not wired: no confirmed tool id |
| read observation | PreToolUse Read |
not wired | not wired | not wired |
| tool-failure hint | not wired | not wired | not expressible: no response fields | not wired |
| session-load adapter | ships one | ships one | none written | ships one |
Two kinds of blank belong in that table and they are not the same. Not expressible is a host contract magus cannot reach through, and it is named rather than approximated. Not wired is a choice: a hook earns its place by changing a verdict or restoring state the model cannot otherwise get, and read observation changes neither: it records a path for the activity trail, which is worth one host's wiring and not four.
Everything else is additive. A host missing a file-write hook still gets every command rule, and adding one later changes no magus code, because the rules and the verdict already exist and only the wrapper is host-shaped.
Project references
A project reference is a workspace-relative path, written bare: pkg/api. That
is the one spelling to read, to write, and to quote back. What a project arg
prints is what a project arg takes, so a path out of any magus command pastes
straight back into the next one.
| form | means |
|---|---|
pkg/api |
that project, measured from the workspace root, from anywhere |
./pkg/api, .. |
measured from the current directory |
. |
the project the current directory is in |
project:pkg/api |
the same project as a GRAPH NODE, for explain, query, path |
Three rules cover the whole surface:
- A bare path is absolute enough. It is measured from the workspace root, so
it means the same project from any directory. Only the dot forms depend on
where you are standing, and when you are standing outside the workspace
entirely,
--root <path>measures them from the workspace it names - so a command written once keeps working when it is run from somewhere else. - Never rewrite a path magus printed. Every surface prints the bare
workspace-relative form -
-o name,-o json, logs, error messages, Mermaid node labels - so it is already in the form the next command wants. Commands that take fuzzy search tokens rather than paths, such asmagus where, take the same bare text. - Quoting a project back to a user: prefer whatever magus printed. The
workspace root is the case that bites, because it is the one project whose
path is a bare
.; human output renders it as the repository's directory name, so a.never leaks into a sentence where it reads as punctuation.
The project: prefix in the last row is not a second path syntax. It is the
kind-prefixed node grammar the graph commands use where kinds mix, alongside
target:, spell:, and doc:; magus explain project:pkg/api disambiguates a
name that could be either. Commands that only ever take a project - run,
affected, ls, describe project - take the bare path.
A workspace://pkg/api reference still parses and resolves to the same
project, with a deprecation warning. It bought no reading the bare path did
not already have, and it is not what magus teaches or prints any more. Drop
the scheme wherever you find one written down.
Incremental review
Answer "I reviewed earlier - what changed since, and what do I need to look at now" without re-reading the whole workspace:
- At review time, record where you stopped:
magus vcs checkpoint -o nameprints the revision, or<revision>+<digest>when the tree was dirty - the digest says which dirty tree was reviewed, since the revision alone reads the same for every dirty tree built on it. - Later, pipe the delta through the annotated view instead of reading a raw
diff:
git diff <revision> | magus diff -reports each changed file's reach, public-surface exposure, and referents - the surrounding code worth a second look, not just the literal hunks.magus diffrefuses a git ref given positionally, on purpose - a swallowed ref once printed the reader's own edits as the answer - so the pipe form above is the only sanctioned spelling, and the refusal message says so. - Reviewing through a diff session carries this further: per-hunk viewed marks key off content digest, not position, so a hunk that has not changed stays marked reviewed and one that has resurfaces on its own.
Handing work to other agents
The same checkpoint identifies a piece of work handed to another agent, and it is one leg of a wider surface: a declared set of jobs, a console Jobs view that draws them, and a spawn recorded but never judged. magus records what an orchestrating agent says it intends and enforces none of it: ownership is settled by diffing against the checkpoint each job was handed. Jobs and leases covers that loop.
MCP
An MCP-connected host gets the same verbs as the CLI plus run and log tools;
magus describe mcp-tools lists all of them with parameters. See
MCP for transport and token setup, and
Knowledge graph for the graph the query tools
read.
Skills prefer the MCP tools and fall back to the CLI, so they work in both
connected and disconnected sessions. MCP availability is a host/integration
concern: configure it once for the host that owns it. An agent checks
magus status --probe=mcp when it needs to know, then uses the CLI fallback if
the surface is unavailable; it must not manually start Magus merely to obtain
MCP.
One tool carries state across sessions: magus_memory, a user-owned memory of
per-repository records, each pointing at something magus can reopen,
kept in the user state directory outside the repo and shared across branches and
worktrees. It is pull-based - nothing is injected into an agent's context - and
also available through magus memory ls|get|put|delete|verify. Use verify to
surface stale, malformed, or broken linked entries. Captured build output is
addressed by output references.
The CLI works without it
The CLI still reads the workspace, runs targets, uses the cache, and answers graph queries with no server running. What it lacks is MCP tool discovery, the warm graph and background indexes, structured output retrieval, and MCP-only capabilities such as the memory store.
An agent must not turn that into a blocker. At task start, or after an MCP
error, run magus status --probe=mcp; if it is unavailable, use the CLI
fallback. Restoring or changing host MCP wiring is a user-owned integration
action, not ordinary agent work.
Why a server, not a wrapper
If an agent can already run magus query in a shell, what does an MCP server
add? If the server only ran the CLI and handed back its stdout, the answer would
be nothing, plus a round trip. The difference is the context the agent has to
work with.
An agent working through a shell falls back on the habits it learned everywhere else: grep and cat over the files. Those return text matches. They do not return the project DAG, the declared outputs, the affected set, or the blast radius of a symbol, because none of that is written in the files - it lives in the graph the server keeps warm. So the agent reasons one layer below the structure it is trying to understand, and fills the gap by guessing: this file looks generated, these two packages probably change together. Those guesses are frequently wrong, and the agent has no way to check them.
The tools answer from what the workspace declares. Ask magus_describe_file
about a path and it does not read the filename and infer; it checks the
project's own globs and reports role: output with the note "generated: never
hand-edit, regenerate." In one case an agent spent close to an hour working out
whether a committed gen/ file was safe to edit - running generate repeatedly,
planting sentinel writes, diffing timestamps - when one call to that tool would
have answered it in a line.
So the server adds three things a shell-out leaves on the floor. Discovery: the
tools arrive in the model's context with their descriptions and parameters, so
the agent knows they exist without reading --help first. Shape: results come
back structured and sized for a model, rather than a human-formatted table
wrapped in color codes and pagination it has to scrape and pay for by the token.
Ground truth: the server reports what the workspace declares, which the agent
can rely on, rather than what a text pattern happened to match, which it cannot.
None of this comes from the protocol. A server that only shelled out would be a wrapper whether or not it spoke MCP. MCP is how the graph reaches the model; the value is in the graph and the curation.