Workspace-first development orchestrator for AI coding agents
Project description
The typed multi-repo MCP server your AI coding agent needs.
Canopy is built for workspaces with multiple repos that share a feature lifecycle — backend + frontend, api + mobile, a monolith plus its services. That setting breaks coding agents in specific, fixable ways: shell state doesn't survive between tool calls, paths get constructed wrong, drift accumulates silently between repos, and PR review work pulls the agent across repo boundaries faster than its context can keep up.
Canopy gives the agent a typed contract for that setting — feature / repo / alias inputs, structured outputs, recoverable errors — so it can drive multi-repo feature work end-to-end without ever shelling cd /wrong/repo.
# Without canopy: brittle paths, parsed stderr, no shared state across repos.
bash("cd /Users/.../web/api && git status")
bash("cd /Users/.../web/ui && git status")
bash("gh pr list --author @me --json number,title")
# ... then per-thread "is this still actionable?" logic in the agent's head
# With canopy: one typed call, structured multi-repo response, recoverable error.
mcp__canopy__feature_state(feature="auth-flow")
# → { "state": "needs_work",
# "next_actions": ["address_review_comments"],
# "summary": {
# "ci_aggregate": "passing",
# "actionable_human_count": 2,
# "repos": {
# "api": { "dirty_file_count": 3, "ahead": 2, "behind": 0, "pr": {...} },
# "ui": { "dirty_file_count": 0, "ahead": 0, "behind": 0, "pr": {...} }
# }
# }
# }
The CLI is the surface humans use to drive the same primitives. Same JSON, two consumers.
Why multi-repo work breaks coding agents
Each mcp__canopy__* tool closes one failure mode that agents reliably hit when the workspace has more than one repo:
| Failure mode | Canopy's fix |
|---|---|
Shell state evaporates between tool calls. cd /repo-a && command doesn't persist; the next call lands somewhere else. Multi-repo makes this worse because there's more than one "right" place to land. |
Every canopy tool takes feature / repo / alias as parameters; path resolution lives inside canopy. The agent has no surface area to type the path. |
Cross-repo state is invisible. git status in one repo doesn't tell you what's happening in the other. The agent has to query each repo separately and stitch the picture. |
mcp__canopy__feature_state(feature) returns the full multi-repo picture in one call: per-repo dirty/ahead/behind, PRs, CI, computed state, prioritized next actions. |
Drift between tool calls. The agent git checkout'd X in one repo, the next call assumes the OTHER repo is also on X; things go sideways. |
Per-repo post-checkout hooks write .canopy/state/heads.json (fcntl-locked, atomic-renamed). mcp__canopy__drift reads cached state in <50ms. The agent sees misalignments that happened between calls, even when it didn't cause them. |
Session re-derivation. Each new chat re-walks gh pr list, git status per repo, comment threads, CI status — burning context on bookkeeping the previous chat already did. |
mcp__canopy__feature_resume(alias) is one call: alias → switch focus if needed → refresh GH+Linear → return structured brief of what changed since last visit. Cross-session state via .canopy/state/visits.json + per-feature memory at .canopy/memory/<feature>.md. |
| PR review churn across repos. A feature with two PRs (one per repo) accumulates threads on both; the agent re-classifies "is this still actionable?" every turn. | mcp__canopy__github_get_pr_comments(alias) returns threads pre-bucketed as actionable / likely_resolved via temporal filtering (comment timestamp vs commits-on-file-since). Resolved threads carry by_canopy: true attribution when canopy itself closed them. |
Closing GH threads needs raw GraphQL. REST has no thread IDs; agents fumble with gh api graphql query strings. |
mcp__canopy__resolve_thread(thread_id), mcp__canopy__reply_to_thread(thread_id, body, resolve_after=True), and mcp__canopy__commit(address=<id>, resolve_thread=True) handle the wire format and log resolutions locally for attribution. |
Juggling 2–3 features in parallel loses in-progress work to forgotten stashes or breaks when one repo gets git checkout'd alone. |
The slot model (Wave 3.0): each feature lives in canonical / warm / cold. mcp__canopy__switch(feature) rotates focus atomically across every repo in the feature's lane, evacuating the previous canonical into a warm slot with stash → checkout → pop. |
| Errors come back as stderr text. Agents have to parse English failure messages to decide recovery. | Structured BlockerError(code, what, expected, actual, fix_actions), each fix carrying safe: bool so the agent knows what's auto-runnable vs needs human confirmation. |
Install
Requires Python 3.10+.
pipx install git+https://github.com/ashmitb95/canopy.git
cd ~/your-multi-repo-workspace
canopy init
If you don't have pipx: brew install pipx && pipx ensurepath.
canopy init does four things:
- Discovers your git repos and writes
canopy.toml. - Installs the drift-detection post-checkout git hook in every repo.
- Wires the canopy MCP server into Claude Code by writing a
.mcp.jsonentry — this is what makes the agent surface live. - Installs the
using-canopyskill at~/.claude/skills/using-canopy/SKILL.mdso the agent knows when to reach for canopy tools.
Skip the agent bits with --no-agent if you're just using the CLI.
The 67-tool surface
Every CLI command has an mcp__canopy__* MCP equivalent returning the same JSON. The MCP server is the load-bearing surface for agents; the CLI is the side benefit for humans. Tools by topic:
Session-start + state
| Tool | What it does |
|---|---|
feature_resume(alias) |
The headline primitive. Resolves alias → switches canonical if needed → refreshes GitHub + Linear → returns the structured brief (since_last_visit, current_state, intent_hints). Call this first when a chat opens on a feature. |
feature_state(feature) |
9-state machine (drifted, needs_work, awaiting_bot_resolution, in_progress, ready_to_commit, ready_to_push, awaiting_review, approved, no_prs) + next_actions array. Drives the agent's decision tree. |
triage |
Cross-feature priority view. Returns features ordered by review-state urgency. |
slots(rich=True) |
Dashboard data — canonical + every warm slot with per-repo branch, dirty, ahead/behind, PR, CI, bot threads, Linear, computed feature_state. |
Focus management (the slot model)
| Tool | What it does |
|---|---|
switch(feature) |
Promote a feature into the canonical slot. Previous canonical evacuates into a warm slot (active rotation, default) or goes cold with feature-tagged stash (release_current=True). Atomic across every repo in the feature's lane. |
slot_load(feature, slot_id?) |
Warm a cold feature into a slot without changing canonical. Use for pre-warming or inspecting a feature you're not ready to focus on. |
slot_clear(slot_id) |
Vacate a slot to cold (feature-tagged stash if dirty). The slot remains, just empty. |
slot_swap(slot_a, slot_b) |
Exchange the occupants of two warm slots. |
migrate_slots() |
One-shot migration from pre-3.0 layouts. |
PR review work
| Tool | What it does |
|---|---|
github_get_pr_comments(alias) |
Returns actionable_threads + likely_resolved_threads per repo. Temporal filter has already classified what's worth the agent's attention. |
resolve_thread(thread_id, feature?) |
Close a GH review thread via GraphQL + log to .canopy/state/thread_resolutions.json for attribution. |
reply_to_thread(thread_id, body, feature?, resolve_after=False) |
Post a reply; optionally close after. |
commit(message, feature?, address=<id>, resolve_thread=False) |
Commit across the feature's repos. With address=<comment_id>, auto-suffixes the message + logs to bot_resolutions.json. With resolve_thread=True, closes the corresponding GH thread. |
bot_comments_status(feature) |
Per-PR bot-comment rollup: total / resolved / unresolved. |
draft_replies(feature) |
File-history-based addressed-comment detector + reply templates. |
Operate across repos without cd
| Tool | What it does |
|---|---|
preflight(feature?) |
Run each repo's preflight hooks (or [augments] preflight_cmd override). Records result for the state machine. |
push(feature?) |
Push across every repo in the feature's lane. set_upstream=True on first push. |
run(repo, command) |
Path-safe shell exec. Canopy resolves the cwd to the right repo dir; the agent never types a path. |
Read + alias resolution
| Tool | What it does |
|---|---|
linear_get_issue(alias), linear_my_issues |
Linear issue data via the issue-provider abstraction. |
github_get_pr(alias), github_get_branch(alias) |
PR + branch data. |
issue_get(alias) |
Provider-agnostic issue read (Linear or GitHub Issues). |
Every read tool accepts the same alias forms:
- Feature name:
auth-flow - Linear issue ID:
TEAM-101(resolves via lane'slinear_issue) - Specific PR:
<repo>#<n>likeapi#142 - PR URL:
https://github.com/owner/repo/pull/142 - Specific branch:
<repo>:<branch> - Slot id:
worktree-1,worktree-2, ... (resolves to the slot's current occupant)
Recovery
| Tool | What it does |
|---|---|
doctor |
21 diagnostic codes across 12 categories of state-file drift + install staleness. Each issue carries severity, expected / actual, and an auto_fixable flag. doctor(fix=True) runs the safe auto-fixes. First call when something feels off. |
version |
{cli_version, mcp_version, schema_version} handshake. Doctor reports cli_stale / mcp_stale when these drift. |
Cross-session memory
feature_memory(feature), historian_decide(feature, decisions), historian_pause(feature, reason), historian_defer_comment(feature, comment_id, reason), historian_compact(feature, keep_sessions) — persistent per-feature memory at .canopy/memory/<feature>.md. Auto-captured by commit --address and github_get_pr_comments. Read on switch to recover prior session context without re-deriving.
Full reference: docs/mcp.md.
The slot model
Every feature lives in one of three states:
- canonical — checked out in the main repo dirs. Exactly one canonical feature at a time across all repos. This is the only place to run code. Worktrees are passive branch storage; never
cdinto them to launch the app. - warm — sits in a numbered slot at
.canopy/worktrees/worktree-N/<repo>/. Slot identity (worktree-1,worktree-2, ...) is stable across feature swaps; feature occupancy is transient. Capped by[workspace] slots = Nin canopy.toml (default 2). - cold — branch only, no checkout. Cheap and unlimited.
switch(Y) is the single primitive that moves features between these states:
- Active rotation (default): previous canonical evacuates into a warm slot via
stash → checkout → pop. Instant to switch back. - Wind-down (
release_current=True): previous goes cold with a feature-tagged stash.
When the cap fires, switch returns BlockerError(code='worktree_cap_reached') with explicit fix_actions (evict a specific slot, wind down the current focus, raise the cap). No silent eviction.
Full design: docs/concepts.md §4.
Structured errors
Every error is a typed payload — agents don't parse stderr.
{
"status": "blocked",
"code": "drift_detected",
"what": "branches don't match feature lane 'auth-flow'",
"expected": {"branches": {"api": "auth-flow", "ui": "auth-flow"}},
"actual": {"branches": {"api": "auth-flow", "ui": "main"}},
"fix_actions": [
{"action": "switch", "args": {"feature": "auth-flow"}, "safe": true,
"preview": "promote auth-flow to canonical across all repos"}
]
}
The agent reads fix_actions[0], checks safe: true, calls mcp__canopy__switch(feature="auth-flow"). The CLI renders the same payload as colored multi-line output via cli/render.py. Single source of truth, two surfaces.
Agent integration
canopy init installs the using-canopy skill at ~/.claude/skills/using-canopy/SKILL.md (per-user) and writes .mcp.json so Claude Code spawns the canopy MCP server in this workspace. The skill teaches the agent when to reach for canopy:
- See a feature alias or issue ID as the first non-trivial token? Call
feature_resume(alias)before doing anything else. - About to
cd <repo> && command? Usemcp__canopy__run(repo, command)or the feature-aware verb. - About to
gh api graphqlfor thread mutations? Useresolve_thread/reply_to_thread/commit --address --resolve-thread. - See an unfamiliar error? Call
doctorfirst.
Opt-in extra skills via canopy setup-agent --skill <name>:
augment-canopy— teaches the agent thecanopy.toml [augments]schema so it can configurepreflight_cmd,review_bots,auto_resolve_threads_on_address, etc. on the user's behalf.
GitHub access works via the gh CLI fallback when no github MCP server is configured. Linear works via OAuth (browser flow once, no API key).
For humans
The same primitives are available as a CLI. Daily commands:
canopy resume <feature> # session start — print the brief
canopy switch <feature> # focus — promote to canonical
canopy status # workspace-wide rollup
canopy state <feature> # 9-state + next_actions
canopy triage # cross-feature priority
canopy preflight # run hooks across the feature's repos
canopy commit -m "..." # commit across repos at once
canopy push # push across repos at once
canopy slots --rich # dashboard data
canopy doctor # diagnose drift / staleness
The CLI and MCP server are thin wrappers over the same actions — canopy state X and mcp__canopy__feature_state(feature='X') return identical bytes. There's also a VSCode extension reading the same state the agent reads.
Full CLI reference: docs/commands.md.
Docs
- Concepts — the action framework, agent context contract, 9-state machine, slot model, resume brief
- Agents — skill install, integration recipes, the agent tool loop
- Commands — full CLI reference
- MCP — server tool list, client transports (stdio + HTTP/OAuth), gh fallback
- Workspace —
canopy.toml,features.json, state files - Architecture — module boundaries, runtime pathways, state files
- Providers — issue-provider abstraction (Linear, GitHub Issues)
Develop
git clone https://github.com/ashmitb95/canopy.git ~/projects/canopy
cd ~/projects/canopy
pip install -e ".[dev]"
pytest tests/ -v # 857 tests, ~225s, all use real temporary Git repos
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file canopy_cli-3.1.1.tar.gz.
File metadata
- Download URL: canopy_cli-3.1.1.tar.gz
- Upload date:
- Size: 1.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3a0a11384da09fd703b549ea2e999f91bd53815e2846578127e34b90dbeb4822
|
|
| MD5 |
fd88f6dfba37559dbd40a88ed7d8f781
|
|
| BLAKE2b-256 |
69d00a23f107d1ed041346897b66d68d5cc06aa1be44d9cb01696270ba4cccc2
|
Provenance
The following attestation bundles were made for canopy_cli-3.1.1.tar.gz:
Publisher:
release.yml on ashmitb95/canopy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
canopy_cli-3.1.1.tar.gz -
Subject digest:
3a0a11384da09fd703b549ea2e999f91bd53815e2846578127e34b90dbeb4822 - Sigstore transparency entry: 1677052231
- Sigstore integration time:
-
Permalink:
ashmitb95/canopy@b19c6e610420a0797c1cd5286b91e9ac49230a70 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/ashmitb95
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b19c6e610420a0797c1cd5286b91e9ac49230a70 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file canopy_cli-3.1.1-py3-none-any.whl.
File metadata
- Download URL: canopy_cli-3.1.1-py3-none-any.whl
- Upload date:
- Size: 258.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9c1824a232b5c842f6f564f3ebfe5d74ddccd442976931ecf8d0779eabd7dc53
|
|
| MD5 |
fac83a52bb76e9d5eb968fbf4d94e7e7
|
|
| BLAKE2b-256 |
8c58e94651006d640507516c4e7c65df25f4f46be89cb44574f3c4fac0e8a8c1
|
Provenance
The following attestation bundles were made for canopy_cli-3.1.1-py3-none-any.whl:
Publisher:
release.yml on ashmitb95/canopy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
canopy_cli-3.1.1-py3-none-any.whl -
Subject digest:
9c1824a232b5c842f6f564f3ebfe5d74ddccd442976931ecf8d0779eabd7dc53 - Sigstore transparency entry: 1677052245
- Sigstore integration time:
-
Permalink:
ashmitb95/canopy@b19c6e610420a0797c1cd5286b91e9ac49230a70 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/ashmitb95
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b19c6e610420a0797c1cd5286b91e9ac49230a70 -
Trigger Event:
workflow_dispatch
-
Statement type: