magus v0.4.3 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
16 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.

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 observation PreToolUse on the read tool
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 agent harness apply --id 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; apply then covers every wired provider:

import "ghcr.io/egladman/magus/spells/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 agent harness apply
magus agent harness 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 re-run apply and verify. Details: Adapting a Buzz harness and Recurring guard friction.

Or target Claude Code alone:

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

The spell installs entries for commands, file edits, Magus MCP tool calls, read observation, and sub-agent spawns. Each runs a shipped script that talks to magus shell:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "./magus buzz -s docs/guides/integrations/agents/magus-command.buzz", "timeout": 10 }]
      },
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [{ "type": "command", "command": "./magus buzz -s docs/guides/integrations/agents/magus-path.buzz", "timeout": 10 }]
      }
    ]
  }
}

This repository's own .claude/settings.json invokes those same files. The scripts are the glue; harness apply only merges the fragments that name them. See guard templates for the files and the variables that adapt them.

The Claude Code harness wires the Buzz form, which needs neither a POSIX shell nor jq. Its sh twin, magus-command.sh, is the same guard and renders the same replies; an executed case runs both against every recorded event and fails on one byte of difference. Wire the sh copy instead if you would rather not pin the guard to a magus buzz, and see below for what that pin costs.

./magus when the workspace carries its own binary, magus otherwise. Apply decides, because a hook command is one string with no shell in it to test a file with; re-run apply after your first build to move a checkout from one to the other. It is never an absolute path: this config is committed, and an absolute path would ship one machine's layout to every clone.

Every entry is a plain argv: <interpreter> buzz -s <file>, with at most a -- <flags> tail and never a VAR=value prefix. Claude Code splits a hook command itself rather than handing it to a shell, so a leading assignment is a word it would look for a program named after. The two knobs that used to ride there are gone from this config for that reason: the glue reads whether to forward the whole event off the event itself, and takes the magus shell flags an entry declares from the argv after --, which magus buzz forwards to the script. The sh twins keep their environment variables, because sh is what runs them and sh is what reads a variable; that is the one place the two forms differ by design.

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.

That failure is loud rather than silent, which is the trade this wiring makes. 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. So you see something like magus: magus.yaml:99: unknown key "sessions" in the transcript, the command you asked for runs, and it runs UNJUDGED: no deny rule fires and no advisory reaches the model for as long as the interpreter stays broken. magus agent harness verify --id claude-code reports the same thing before a session ever starts, by running each wired command against a synthetic event and checking the verdict comes back.

The guard never blocks on its own failure. Exit 2 is the only code that blocks, and the glue reaches it on no path: 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 magus agent harness apply and 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 agent harness apply --id claude-code installs 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 what was read

A third PreToolUse entry, matching Read, 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.

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

This repository dogfoods it, and it is the one job here that is deliberately not carried to the other hosts: it changes no verdict, so a reader who skips it loses detail in a trail rather than enforcement.

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 agent harness apply --id claude-code installs 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). That response field is unverified against a live session, and 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.

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 REHYDRATE_RULES 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.

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 (95680f58)
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.

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.