magus v0.4.3 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
19 min read

Claude Code

Claude Code reads Agent Skills from .claude/skills/ and runs a PreToolUse hook before every tool call. That covers both guard surfaces, and both verdicts reach the model, so nothing in the contract is lost here. It is also the setup this repository dogfoods and the only one executed end to end against a real event.

{"figure":"agent-surface","stamp":"sha256:8ec7a102718d3b23e9080cfbe5273d521bfe6f1fb3a14e9f55c0c8f0a9ddff3a"}What an agent host gets, and the gate every call crossesmagus agent install writes the skills and the AGENTS.md block, and magus describe harness prints the hook entries a person merges into the host's config. From then on the host runs the guard's PreToolUse hook before every shell command, file edit and magus MCP call, and only an allowed call runs. Skills, guard verdicts and MCP replies all name commands through internal/hint, so a renamed command cannot leave one of them pointing at nothing.AGENTSWhat an agent host gets, and the gate every call crossesINSTALLED BEFORE A SESSIONON EVERY CALLAGENT INSTALLCOMMAND NAMESHOOKS TO MERGEEVERY CALLIF ALLOWEDDENIAL HINTSNEXT FIELDSSkills catalogembedded skills, AGENTS.mdHarness spellshook entries per hostCLIENTAgent hostClaude Code, Codex, CursorGATEGuarddeny, advise, askMCP tools./magus mcp or /mcpHintscommands, next fieldsLEGENDTHE GATEOUTSIDE MAGUS
what where
skills .claude/skills/
guard wiring .claude/settings.json, PreToolUse
command surface deny and advise both reach the model
file surface deny and advise both reach the model
MCP call surface deny and advise both reach the model
read surface PreToolUse on the read tool: recorded, and judged as cat
MCP MCP
attention events Notification, Stop, SubagentStop
checkpoint Stop
rehydration SessionStart (compact, resume)
lease PreToolUse on the sub-agent tool
declared model PreToolUse on the sub-agent tool, when the caller named one

Skills

magus agent install .claude/skills

Commit what it writes so every teammate's agent gets the same instructions. Claude Code discovers skills when a session starts, so restart the session before it can invoke anything new. Skills covers the install surface, the two forms, and the drift check.

MCP

Configure MCP for Claude Code yourself. magus describe harness claude-code prints the claude mcp add sketch only. Resolve secret ref MAGUS_MCP_TOKEN (env provider by default) for the bearer header; MCP has the full token setup. Tools are discovered at launch, so restart a client after changing its MCP configuration. An agent that finds MCP unavailable uses the CLI fallback; it does not manually start Magus solely to obtain tools.

Guard hook

Prefer wiring the Claude Code harness from the root magusfile when you bounce between hosts; magus describe harness then covers every wired provider:

import "magus";
import "ghcr.io/egladman/magus/spells/harness/claude-code" as claude;
magus\harness.provider(claude);

The alias is needed only because claude-code is not a Buzz identifier. Declare the spell in magus.yaml with the tag cd's spell-publish step pushed, and run your lock target with :update to pin its digest in magus.lock, so the harness versions apart from your magus binary; see Remote spells.

magus describe harness
magus agent harness verify

magus describe harness prints each entry .claude/settings.json lacks and the one command that merges them. magus never writes the file: read the command, run it yourself, then verify.

To adapt that Buzz harness without modifying Magus source: copy the spell into the workspace, change only the import path (for example import "harness/claude-code" as claude), edit the workspace Buzz, then describe, merge, and verify again. Details: Adapting a Buzz harness and Recurring guard friction.

Or target Claude Code alone:

magus describe harness claude-code
magus agent harness verify --id claude-code

The spell installs entries for commands, file edits, Magus MCP tool calls, reads (recorded and judged), and sub-agent spawns. Each is one magus buzz command that runs a shipped script, which talks to magus shell: file edits run magus-path.buzz, reads also run magus-observe.buzz, and every other entry runs magus-command.buzz. The Bash entry, as magus describe harness claude-code prints it, in the place it lands in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{
          "type": "command",
          "timeout": 10,
          "command": "magus buzz -C \"$CLAUDE_PROJECT_DIR\" -s docs/guides/integrations/agents/magus-command.buzz -- --agent-name claude-code"
        }]
      }
    ]
  }
}

This repository's own .claude/settings.json holds exactly what the spell prints, merged with the command magus describe harness prints beside it. The scripts are the glue; the harness only prints the fragments that name them. See guard templates for the files and the variables that adapt them.

Claude Code runs a hook command through sh -c and exports CLAUDE_PROJECT_DIR, the root the session started in (hooks reference). So the string is the same on every machine, never an absolute path. -C makes the root the working directory before anything else, so the root-relative script path resolves wherever the session is; the glue reads the session's own directory off the event. The glue is Buzz and needs no jq, and no entry carries any shell of its own.

Flags an entry declares ride after --, which magus buzz forwards to the script, never in a leading VAR=value: the glue reads whether to forward the whole event off the event itself.

Which magus runs

Three things in a session resolve the word magus, and each has its own owner:

Who runs magus Resolved by Owner
Hook commands (the guard's interpreter) PATH, then the ./magus of the tree -C names when that tree builds one every hook entry, and magus's own re-exec
The agent's own Bash tool commands PATH, with the session root put first the SessionStart entry (matcher startup|resume|clear), whose magus-session.buzz appends export PATH="<root>:$PATH" to $CLAUDE_ENV_FILE
Commands magus itself starts (targets, spells, scripts) PATH, with the running binary's directory put first magus, on every child it spawns

A hook's magus is whatever the PATH Claude Code was started with finds. Before it does anything else, that magus walks up from the -C directory to the nearest magusfile and re-execs into the ./magus there when one exists, so a workspace that builds its own binary is judged by it and one that builds none uses the installed one. The SessionStart entry cannot serve hooks: the hooks reference says CLAUDE_ENV_FILE persists variables "for subsequent Bash commands", and names no other consumer.

magus doctor's guard-binary check runs the interpreter a hook would run and fails when it is a different build from the doctor's own, or cannot print its version, naming both binaries.

When the hook itself cannot run

Wiring the guard to magus buzz makes the INTERPRETER a magus, where the sh copy's interpreter was /bin/sh: always present, and with no version to be wrong. So a magus that is missing, too old to run the script, or unable to load this workspace does not merely answer badly; it never runs the script at all.

Claude Code treats a hook that exits non-zero with any code other than 2 as a non-blocking error: the first line of its stderr appears in the transcript as a <hook name> hook error notice, and the tool call goes ahead UNJUDGED. A bare magus with none on PATH exits 127 that way, and a whole session once ran with every guard rule open.

So with no magus on PATH every hook runs open: each exits 127, Claude Code shows the notice, and the call goes ahead unjudged. Start Claude Code from a shell whose PATH finds one. This repository's mise.toml puts the checkout root first on PATH, so an activated shell finds the checkout's own ./magus once GOEXPERIMENT=jsonv2 go run -trimpath ./cmd/magus run go-build --no-cache . has built it.

A magus that is present but broken (too old, or unable to load the workspace) fails open the same way, with its own error as the notice. magus doctor and magus agent harness verify --id claude-code report that before a session starts; verify runs each wired command against a synthetic event and checks the verdict comes back. The glue never blocks on its own failure either: a verdict it cannot obtain is reported and the call proceeds.

A push at a commit no passing gate covers gets the verdict ask, and the command template renders it as permissionDecision: "ask": Claude Code shows you the reason, which names the commit and the gate state, and approving publishes it. A push the gate covers runs without a prompt. A session bound to a job lease is denied instead, because workers do not publish.

Maintaining the workspace harness

This host is a Buzz harness spell. Adapt without Magus source edits by forking the spell and changing only the import path; then merge what magus describe harness prints and run magus agent harness verify. See Adapting a Buzz harness and Recurring guard friction.

MCP tool calls

Claude Code's PreToolUse also fires for a tool served over MCP, matching mcp__<server>__<tool>. The shipped spell includes a Magus-MCP matcher, so magus describe harness claude-code prints this entry alongside the command and file surfaces:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__magus__.*",
        "hooks": [
          { "type": "command", "command": "./magus buzz -s docs/guides/integrations/agents/magus-command.buzz", "timeout": 10 }
        ]
      }
    ]
  }
}

Same script, same command string, same reply shape. An MCP call carries no tool_input.command, only a tool name and a params object, and that absence is what the glue reads: with nothing at HOST_EVENT_PATH to select, it forwards the event whole instead of extracting one field, so the entry says nothing the event does not already say. magus session hook already parses that whole envelope; today it recognizes the tool name and params only well enough to say there is nothing here it can judge, so this wiring passes every MCP call rather than denying or advising on one, which is the honest state to ship rather than silence. The next rule this surface grows reaches the model the moment it ships, with no new host wiring, because the transport is already here.

Recording and judging what was read

Two PreToolUse entries match Read. The first runs magus-observe.buzz. It judges nothing and prints nothing: it records the path on the activity trail so a later magus session show can say what a session looked at, not only what it changed.

The second runs magus-command.buzz, which restates the read as the shell line it stands for: cat <file>, or sed -n <first>,<last>p <file> over the call's offset and limit, a limit over 300 counting as whole. The read rules then judge it as they judge that cat: a whole read of a Go, Buzz or Markdown file over 120 lines is refused with the file's map, and a bounded read inside one indexed declaration is advised toward magus refs.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read",
        "hooks": [
          {
            "type": "command",
            "command": "./magus buzz -s docs/guides/integrations/agents/magus-observe.buzz",
            "timeout": 10
          }
        ]
      },
      {
        "matcher": "Read",
        "hooks": [
          {
            "type": "command",
            "command": "./magus buzz -s docs/guides/integrations/agents/magus-command.buzz",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

The observer is the one job here that is deliberately not carried to every host: it changes no verdict, so a reader who skips it loses detail in a trail rather than enforcement. The judging entry is enforcement, and Codex carries it too.

Lease capture

When Claude Code hands work to a sub-agent it does so through a tool call, and that call fires PreToolUse like any other, carrying the whole prompt the orchestrator is handing over in tool_input.prompt, the callee's declared subagent_type, and, when the caller named one, tool_input.model. The shipped descriptor includes a matcher for it, so magus describe harness claude-code prints this entry alongside the surfaces above:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Agent|Task|SendMessage",
        "hooks": [
          { "type": "command", "command": "./magus buzz -s docs/guides/integrations/agents/magus-command.buzz -- --observes-skill-loads", "timeout": 10 }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Agent|Task",
        "hooks": [
          { "type": "command", "command": "./magus buzz -s docs/guides/integrations/agents/magus-command.buzz", "timeout": 10 }
        ]
      }
    ],
    "SubagentStop": [
      {
        "hooks": [
          { "type": "command", "command": "./magus buzz -s docs/guides/integrations/agents/magus-command.buzz", "timeout": 10 }
        ]
      }
    ]
  }
}

SendMessage is the continuation of a subagent that already exists: a tool_input carrying to and message. It reaches the same verdict path as a spawn, so a workspace magus\guard.spawn rule sees both. The PostToolUse entry judges nothing: the finished call's tool_response.agentId is the id the host gave the child, and recording it against the spawn's description is what lets that child's own spawns name their parent (agent_id on its later hook events). A live background spawn carries that response field; a release that drops it leaves every parent empty rather than guessed. The same record keeps the model the spawn named, and, when the spawn's description reads <parent>/<role> <job> for a live job, the job its later calls are graded under, in its own worktree too.

The SubagentStop entry judges nothing either. Its payload names the finished subagent's agent_id and agent_transcript_path; magus reads the last usage record in that transcript's final 512 KiB and files input plus cache-read plus cache-write tokens as the agent's context size, which a later SendMessage to it hands a magus\guard.spawn rule as target.contextTokens.

Same script as the MCP surface and for the same reason: a spawn's payload is a prompt, a subagent_type, and an optional model, not one string, so there is no tool_input.command to select and the event goes whole. The one thing written on this entry is the flag after --, which magus buzz forwards to the script as its own argv: --observes-skill-loads says that THIS config also matches the host's Skill tool, so a rule that requires a skill before a spawn has loads to read. A config without that matcher omits the flag and those rules stand down rather than denying every spawn forever. The script parses that tail against the flags it supports; an argument it does not know is named on stderr, which Claude Code shows as a hook error, and the call is judged without it rather than blocked. magus session hook reads tool_input.prompt for the context, tool_input.subagent_type (then description, then tool_name) for the callee's label, tool_input.model for the model the caller claimed, and session_id for the parent's session. The result is one agent_spawn event per lease, with the handed context and the declared model stored as a payload blob you fetch by ref: magus session show <id> renders each spawn's model claim, wording an absent one as "none declared" rather than leaving it blank.

The matcher covers both names Claude Code has used for the spawn tool across releases (Task historically, Agent currently); a release that emits neither records nothing here; nothing else in the contract depends on the spelling.

It records; it does not judge. A lease prompt is prose, so the command rules never run against it and the verdict is always a pass: a prompt that mentions a denied command describes it rather than runs it, and that holds whether or not the caller named a model: magus asks the question, it does not grade the answer. A later decision may add an advisory or a deny keyed on the declared model; recording it here is what would make that decision possible, not itself one.

To join those events to a job, write the marker line documented in Any other host at the top of the prompt you hand the sub-agent.

Notifications

magus session notify turns a host event into a desktop notification. It does not send an event to the server or Console. Wire Notification (it fires on a permission prompt and when the agent goes idle waiting for input), and Stop or SubagentStop for completion.

{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "d=$PWD; while [ -n \"$d\" ] && [ ! -f \"$d/magusfile.buzz\" ]; do d=${d%/*}; done; __MAGUS_BIN=$([ -x \"$d/magus\" ] && printf %s \"$d/magus\" || command -v magus 2>/dev/null); [ -n \"$__MAGUS_BIN\" ] && jq -c '{schema_version: 1, outcome: .hook_event_name, source: {kind: \"agent\"}, message: .message}' | \"$__MAGUS_BIN\" session notify --desktop >/dev/null 2>&1; exit 0"
          }
        ]
      }
    ]
  }
}

It exits 0 and swallows its own output on purpose: a notifier that can fail is a hook that can break the session it was meant to watch. It opens with the same magusfile walk as the lease hook above, for the same reason. Attention hooks covers the envelope and the outcome vocabulary.

Recording where the work stands

Wire Stop to magus-checkpoint.buzz and each time a turn ends magus records the revision, branch and dirtiness of the tree, plus this session's id and transcript path. magus session lists it.

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./magus buzz -s docs/guides/integrations/agents/magus-checkpoint.buzz",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

This is the wiring this repository dogfoods, in .claude/settings.json beside the three guard hooks. It is not a guard: it judges nothing, prints nothing, and exits 0 whatever happens. magus session checkpoint --note "..." writes the same record by hand, which is the form to reach for when you are the one stopping.

Handing a compacted session its state back

Claude Code fires SessionStart when a session begins, when one is resumed, and after it compacts a long conversation into a summary; whatever a SessionStart hook prints is added to the model's context. Wire it to magus-rehydrate.buzz and a session that just lost its history is handed this checkout instead: branch and revision, commits not yet on the base ref, the dirty tree split into sources, generated outputs and unclaimed paths, the live leases with the command that binds each one, the last recorded run's failures with the ref that holds their output, the guard wiring, and where the rules live. If recurring guard evidence crossed its review threshold, it also receives one line pointing to magus doctor's recurring-guard-denials check; it states facts, never an automatic instruction or memory edit.

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact|resume",
        "hooks": [
          {
            "type": "command",
            "command": "./magus buzz -s docs/guides/integrations/agents/magus-rehydrate.buzz",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

compact is the case that needs it; resume gets it for free and answers the same question, since a resumed session did not watch the tree move while it was away. Add startup if you want it at the top of every session, at the cost of the block on sessions that would have been fine without it.

Every line is read off the disk when the hook runs, so nothing in it can be a retelling of a retelling. It restates no rule: the last line names the files your rules live in, CLAUDE.md by default and -- --rules <file> when yours is somewhere else. Run magus session --brief yourself to see what a session will be handed.

Coverage and limits

No transport gap in the guard contract: all three surfaces are wired, deny arrives as a permissionDecision, and advise arrives as additionalContext, which puts the explanation in front of the model rather than the person. The MCP surface is transport-complete but rule-empty today: the wiring passes every call because nothing yet judges an MCP tool name, not because the channel cannot carry a verdict.

Shell commands run with stdin at end-of-file. An agent's command inherits a stdin nobody writes to, so a stray reader (grep with no file operand, read, a prompt, ssh, a pager) waits forever, and Claude Code backgrounds a timed-out command rather than killing it. On a pass or an advise, the reply hands the command back as updatedInput, prefixed exec </dev/null;, with every other tool_input field kept, since updatedInput replaces the whole input. It sets no permissionDecision, so the rewritten call still meets your permission rules. A heredoc, a pipe or a < still feed their command. A denied or asked call is never rewritten, a command that already starts with the prefix is left alone, the session is told once, and the activity trail records stdin_closed on the verdict.

A worktree-isolated subagent meets one side effect: Claude Code's isolation check judges the rewritten line, and refuses the prefix on a line it already finds borderline (runtime-computed values beside a redirect) as "exec inside a construct too complex to verify". Split such a line, as that refusal says. A { <command> ... } </dev/null group avoids the word but was refused far more often, even around stat or git status.

That is not the same as using everything this host offers. SessionStart with matcher startup, UserPromptSubmit, PostToolUse, PostToolUseFailure, PermissionRequest and PreCompact are all available and all unused, on the test every wiring here has to pass: a hook must change a verdict or restore state the model cannot otherwise get. An advisory that fires every turn to restate guidance the skills already carry fails it.

SubagentStart is the one worth naming, because it looks like it should replace the lease wiring above and does not. It fires when a sub-agent is spawned and matches on agent type, but its documented input does not carry the prompt the orchestrator handed over, and the prompt is the whole record: it is what magus stores as the spawn's payload, and it is where a lease: marker rides. So the PreToolUse matcher Task wiring stays, on the tool call that does carry tool_input.prompt.

Verify

magus doctor

doctor's guard binary check names the binary a hook would actually run and fails when it is older than your working tree; guard wiring probes it with a known-denied command and then looks for a host config that invokes a current template; agent skills grades the installed copies against the running binary and --fix reinstalls whatever it reports stale.

Commit .claude/settings.json once you are happy with it. Until a checkout has that file, its guard rules are correct and entirely unenforced, with nothing in the session saying so, which is the gap the guard wiring check exists to report.

agentsclaudeclaude codeskillsguardhooksnotifications
Last updated (895d94e4)
Earlier changes on this page (7)

Full history ↗ · Blame source ↗

Glossary

Workspace

The magus root directory that owns a set of projects and shared config; the unit magus operates over. See workspace.

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. See workspace.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). See operations.

Spell

A language/runtime adapter (e.g. go, md) that maps generic targets onto a toolchain's real commands. See spells.

Ward

A coded diagnostic that inspects a resolved op and nudges or blocks an anti-pattern before it runs. See wards.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Cache

The content-addressed store magus consults before running a target, so unchanged work is skipped. See cache.

Server

The background process a person starts with magus server start. It serves MCP, the console, background jobs and the warm knowledge graph, and adopts nested magus calls into one pool. See server.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs: magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Session

An agent host's conversation, by the id the host delivers to its hooks. magus never mints one: a record with no session is unattributed, and the OS user it carries says whose account ran it.

Job

The unit of delegated work, and one row of the job store: what an orchestrating agent handed out, with its goal, the checkpoint it was cut against, the paths it may write or must not touch, and the one check it runs. A job's holder is either a session, for work an orchestrator handed out, or the server, for its own maintenance. The store records; the agent guard is what reads those facts back when grading a write. See doctrine.

A job is not a run. magus run build web is a run, and no job exists for it. A job causes runs: its check executes as one, and a server job records the invocation of its last one. Jobs are listed with magus ls jobs and in the console's Jobs view; runs are listed in the Runs view.

Run

One target executing under one magus invocation, such as magus run test web or magus affected ci. A run keeps its captured output behind an output reference. Every magus run is a run whether or not any job asked for it; see Job for how the two relate.

Lease

The grant a holder takes on a job: the write and read paths that job declared, enforced in the checkout that took it with magus job exec. A job is the piece of work; a lease is permission over it.

Advisor

One read-only check from the advice suite: it reads the changeset through magus and writes one titled section of findings. The same advisors run as a pull request comment in CI and inside magus diff --impact locally.

Conventions

Placeholders

Angle brackets mark a value you replace with your own; never type the brackets:

magus run <target>
magus completion <shell>    # e.g. bash, zsh, fish

<target>, <path>, <shell>, <name> and the like are stand-ins, not literal text.