OpenCastle for Teams
One source for every assistant, shared across every repository you own: a baseline your repositories extend, a policy for the MCP servers your agents may start, a lock file that records what every assistant is given, and a review that says what each change does.
Why
A team runs several assistants side by side, each reading its own format. Without one source, nobody reviews what the agents are told, nobody notices when a repository drifts, and nobody can say which MCP servers — code that runs with every developer's credentials — the agents start. OpenCastle compiles one source into all of them, and adds what a team needs around it:
- Baselines — the organisation's instructions, skills, agents, MCP servers and policy, in an npm package every repository extends, pinned by its lockfile.
- Policy — which MCP servers may run and reach which hosts, exact versions, what no repository may drop, and how much loads before a task. It only tightens.
- The lock —
.opencastle/lock.json, one reviewable record of what every assistant is given, checked in CI. - Team commands —
explainfor onboarding,reviewfor pull requests,cito wire it up,baselineandpluginto publish a standard,promoteto share what one assistant learned,fleetto see where it has landed.
One Repository, One Team
Most teams start here, and need no baseline.
- Put the team's own rules in
.opencastle/. Short rules every task needs ininstructions/, deeper knowledge inskills/<name>/SKILL.md, procedures inprompts/.npx opencastle synccompiles them for every assistant anyone on the team uses. - Commit it all —
.opencastle/, the generated files and the lock. A teammate who clones the repository has the same setup without running anything. - Check it on every pull request.
npm i -D opencastle && npx opencastle ci --owners @your-teamwrites the workflow, and asks the owners to review any change to what the assistants are given. - Onboard with one command. A new teammate runs
npx opencastle explain: what their assistant is given, and which environment variables and sign-ins they still need.
When a second repository wants the same rules, move them into a baseline.
Layers
What the assistants in a repository receive is built from layers. Each layer is a directory laid out the same way, and later layers add to and override earlier ones.
extends, in orderskills/, mcp.json and dev.opencastle/.opencastle/A layer holds any of these:
| Path | What it is |
|---|---|
config.json | extends, exclude, mcpServers, policy and opencastle — see the config reference |
instructions/*.md | Rules every assistant reads before every task |
skills/<name>/SKILL.md | Knowledge an assistant loads when a task matches the skill's description |
agents/*.agent.md | Personas to delegate to |
prompts/*.md | Reusable prompts, compiled as /oc:<name> commands in Claude Code and Copilot |
workflows/*.md | Multi-step workflow templates |
In a repository the layer is .opencastle/. In a baseline it is the
directory the package names in its package.json, such as
"opencastle": { "baseline": "." }. Only config.json
and the five content directories are read as a layer; the rest of
.opencastle/ — stack notes, lessons, the manifest — is not part of it.
A baseline baseline init creates is also an Agent Plugin:
skills in skills/, portable servers in mcp.json, everything else in
dev.opencastle/, so assistants that read Agent Plugins can install it as it is. Any Agent
Plugin on npm can be extended the same way.
Items are named by kind and name: skills/deploy-runbook,
instructions/security, agents/reviewer. An item with the
same name as one below replaces it whole; exclude removes one from below,
and takes * — skills/* leaves out every skill below. A skill or
agent with no description compiles with a warning: no assistant can tell
when to load it.
Policy runs the other way. Content from a higher layer wins; rules from a lower layer hold. A repository can add to its baseline's policy and make it stricter, but nothing it writes loosens what the baseline set.
Worked Example
Acme publishes a baseline, @acme/opencastle-baseline. A repository called
shop extends it, adds a skill of its own and two MCP servers. The output below
comes from that setup; … marks lines left out.
1. The baseline package
@acme/opencastle-baseline/ an Agent Plugin, and a baseline
├── package.json "opencastle": { "baseline": "." }
├── plugin.json the Agent Plugins manifest
├── .claude-plugin/
│ └── plugin.json Claude Code's copy — opencastle plugin build writes it
├── mcp.json servers any assistant can start as they are (none here)
├── skills/
│ └── secure-coding/
│ └── SKILL.md
└── dev.opencastle/ what the standard does not cover yet
├── config.json
└── instructions/
├── api-style.md
└── security.md // dev.opencastle/config.json
{
// Acme's standard for every repository
"policy": {
"mcp": {
"allow": ["chrome-devtools", "next-devtools", "internal-docs", "acme-*", "Linear", "Sentry"],
"remoteHosts": ["*.acme.dev", "mcp.linear.app"],
"requirePinned": true
},
"require": ["skills/secure-coding", "instructions/security"],
"contextBudget": 12000
},
"mcpServers": {
"internal-docs": {
"url": "https://docs-mcp.acme.dev/mcp",
"headers": { "Authorization": "Bearer ${ACME_DOCS_TOKEN}" }
}
}
} Two always-loaded instructions and a skill, one remote MCP server for every repository, and a policy: six allowed server names, one of them a wildcard for Acme's own servers; two hosts; exact versions; two items no repository may drop; and a budget of 12,000 tokens loaded before each task.
2. The repository
shop installs the baseline as a devDependency — npm i -D @acme/opencastle-baseline —
so the version lives in package.json and the lockfile pins it. Then it extends it:
// .opencastle/config.json
{
"$schema": "https://www.opencastle.dev/schema/config.json",
"extends": ["@acme/opencastle-baseline"],
"exclude": ["skills/seo-patterns"],
"mcpServers": {
"acme-db": {
"command": "npx",
"args": ["-y", "@acme/db-mcp@2.2.0"],
"env": { "ACME_DB_URL": "${ACME_DB_URL}" }
},
"acme-flags": { "url": "https://flags.acme.dev/mcp" }
}
}
It also has a skill of its own, .opencastle/skills/deploy-runbook/SKILL.md, and
leaves out an OpenCastle skill it has no use for. npx opencastle sync compiles all of it:
$ npx opencastle sync
🏰 OpenCastle sync v1.0.0
…
✓ Rebuilt MCP config
✓ Wrote the team's MCP server(s) as defined: acme-db, acme-flags, internal-docs
- Removed 1 MCP server(s) the team's layers retired or its policy does not allow: Slack
✓ Compiled @acme/opencastle-baseline 1.5.0 and this project's own sources (4 items)
✓ Updated .opencastle/lock.json (opencastle review explains the change)
- Left out 1 MCP server(s):
Slack — not on the MCP allowlist of @acme/opencastle-baseline
! Removed 3 file(s) no longer produced by any source:
.claude/skills/seo-patterns/SKILL.md
.agents/skills/seo-patterns/SKILL.md
.github/skills/seo-patterns/SKILL.md
⚠ Environment variables still needed:
ACME_DOCS_TOKEN
└ used by the internal-docs MCP server (@acme/opencastle-baseline/dev.opencastle/config.json)
ACME_DB_URL
└ used by the acme-db MCP server (.opencastle/config.json)
… 3. What a new teammate sees: explain
Someone joining the team runs one command to learn what their assistant is given here and what they still have to do:
$ npx opencastle explain
🏰 What the AI assistants in this repository are given
Layers OpenCastle 1.0.0 → @acme/opencastle-baseline 1.5.0 → this project (later layers add to and override earlier ones)
Targets Claude Code, Cursor, VS Code, OpenCode
Always loaded — ~2779 tokens before every task: instructions ~613, the skill and agent index ~2167
• api-style ~14 tokens (@acme/opencastle-baseline)
• general ~397 tokens (OpenCastle)
• project-context ~182 tokens (this project)
• security ~21 tokens (@acme/opencastle-baseline, required)
budget: 12000 tokens
Skills — loaded when a task matches the description (23)
• deploy-runbook (this project)
How this shop deploys. Use when releasing, rolling back, or touching the deploy pipeline.
• secure-coding (@acme/opencastle-baseline, required)
Acme's secure coding rules. Use when handling auth, secrets, user input, or anything touching customer data.
…
• and 16 from OpenCastle: accessibility-standards, api-patterns, data-engineering, …
Agents — personas to delegate to (11)
• and 11 from OpenCastle: architect, developer, devops-expert, …
Commands — /oc:<name> in Claude Code and Copilot (7)
• and 7 from OpenCastle: bootstrap-customizations, brainstorm, bug-fix, …
Workflows — /oc:workflow-<name> in Claude Code (6)
• and 6 from OpenCastle: data-pipeline, database-migration, performance-optimization, …
Left out
• skills/seo-patterns (excluded by this project)
MCP servers — tools every assistant can start (6)
• Linear (linear integration)
remote: https://mcp.linear.app/mcp
docs: https://linear.app/docs/mcp
• acme-db (this project)
runs: npx -y @acme/db-mcp@2.2.0
• acme-flags (this project)
remote: https://flags.acme.dev/mcp
• chrome-devtools (chrome-devtools integration)
runs: npx -y chrome-devtools-mcp@1.10.1
docs: https://developer.chrome.com/docs/devtools
• internal-docs (@acme/opencastle-baseline)
remote: https://docs-mcp.acme.dev/mcp
• next-devtools (nextjs integration)
runs: npx -y next-devtools-mcp@0.4.0
docs: https://nextjs.org/docs
– Slack not written: not on the MCP allowlist of @acme/opencastle-baseline
The team's policy
• MCP servers must be on every one of these allowlists:
@acme/opencastle-baseline: Linear, Sentry, acme-*, chrome-devtools, internal-docs, next-devtools
• Remote servers may only reach hosts every one of these allows:
@acme/opencastle-baseline: *.acme.dev, mcp.linear.app
• Every local server must be pinned to an exact version (@acme/opencastle-baseline)
• Required everywhere: instructions/security, skills/secure-coding
Your setup
✗ ACME_DOCS_TOKEN — used by the internal-docs MCP server (@acme/opencastle-baseline/dev.opencastle/config.json); set it in .env or your shell
✗ ACME_DB_URL — used by the acme-db MCP server (.opencastle/config.json); set it in .env or your shell
→ Linear: your assistant opens a browser to sign in the first time it starts the server
… 4. What a reviewer sees: review
A pull request moves the baseline from 1.4.0 to 1.5.0 — which adds an instruction, changes
a skill and allows Sentry — and in the same change shop bumps its database server and
adds acme-flags. Most of the diff is regenerated files in four assistants' formats.
opencastle review writes this to the job summary instead:
### OpenCastle — what this change does to the AI assistants
Compared with `origin/main`. **3 change(s) marked ⚠️ deserve a careful look.**
**Layers**
- @acme/opencastle-baseline 1.4.0 → 1.5.0
**What every assistant reads**
- ⚠️ New always-loaded instruction **api-style** from @acme/opencastle-baseline (~14 tokens, loaded on every request)
- skill **secure-coding** changed — @acme/opencastle-baseline
**MCP servers**
- MCP server **acme-db**: 2.1.0 → 2.2.0
- ⚠️ New MCP server **acme-flags** from this project — remote, flags.acme.dev
**Policy**
- ⚠️ @acme/opencastle-baseline's MCP allowlist now also allows Sentry
**Context**
- Every assistant loads ~2779 tokens before the task (was ~2765, +1%) Config Reference
.opencastle/config.json in a repository; config.json at the root of a
baseline's layer. It is JSON with comments and trailing commas allowed. Point
$schema at https://www.opencastle.dev/schema/config.json
for completion in your editor. An unknown key is an error; when it is close to a real one, the
message suggests the key you probably meant.
// .opencastle/config.json — every field
{
"$schema": "https://www.opencastle.dev/schema/config.json",
"opencastle": "^1.0.0",
// A package, then a directory in this monorepo. The first is the lowest layer.
"extends": ["@acme/opencastle-baseline", "../packages/opencastle-web"],
"exclude": ["skills/seo-patterns", "agents/researcher"],
"mcpServers": {
"acme-db": {
"command": "npx",
"args": ["-y", "@acme/db-mcp@2.2.0"],
"env": { "ACME_DB_URL": "${ACME_DB_URL}" },
"description": "Read-only access to the staging database"
},
"acme-flags": {
"url": "https://flags.acme.dev/mcp",
"headers": { "Authorization": "Bearer ${ACME_FLAGS_TOKEN}" }
}
},
"policy": {
"mcp": {
"allow": ["acme-*", "Linear", "chrome-devtools"],
"remoteHosts": ["*.acme.dev", "mcp.linear.app"],
"requirePinned": true
},
"require": ["instructions/security"],
"contextBudget": 8000
}
} | Field | Type | What it does |
|---|---|---|
extends | string[] | Baselines to build on; the first entry is the lowest layer. Either a package name — installed as a devDependency and found in node_modules from this file's directory upwards, the way Node finds a package — or a path starting with ./ or ../, relative to this file. A version here (pkg@1.2.3) is refused: it belongs in package.json, where your lockfile pins it. Absolute paths are refused too. A baseline's own extends load beneath it; one reached twice loads once; a cycle is an error. |
exclude | string[] | Items from the layers below to leave out, as kind/name: skills/seo-patterns, agents/researcher. Content takes * for any run of characters in a name: skills/* leaves out every skill from below, so a team that wants only its own needs one line, not one per skill. An MCP server a lower layer defines or an integration brings is named exactly, as mcpServers/Slack. Naming content no lower layer provides, or a pattern that matches nothing, is a warning. Excluding an item a lower layer's policy requires is an error. |
mcpServers | object | Servers every assistant gets, keyed by name (letters, digits, ., _ and -). A layer above can redefine a server by name, and a team server named like an integration's replaces it. sync writes them exactly as defined, rewrites an entry someone edited in a target's MCP config, and removes one an earlier sync wrote that no layer defines any more. |
…command, args, env | string, string[], object | A local server: what to run, its arguments, and its environment. |
…url, headers | string, object | A remote server: its http(s) URL and the headers to send. A server has either command or url, never both. |
…type, description | string | type is stdio or http, inferred from command or url when left out. description is for people reading the config; it is not written to the assistants' files. |
policy.mcp.allow | string[] | Server names an assistant may start; * matches any run of characters. See Policy. |
policy.mcp.remoteHosts | string[] | Hosts remote servers may connect to. *.acme.dev matches any subdomain of acme.dev, not acme.dev itself; anything else must match exactly. |
policy.mcp.requirePinned | boolean | Every server launched through a package runner (npx, npm exec, pnpm dlx, yarn dlx, bunx, uvx, pipx run) names an exact version, or runs the project's own copy with npx --no. |
policy.require | string[] | Items, as kind/name, that must be present. One that existed at or below the requiring layer may not be excluded or replaced above it; one that did not can be provided by any layer above. |
policy.contextBudget | integer | Tokens every assistant may load before it reads the task: the always-loaded instructions plus the index of skills and agents, estimated at four characters per token. |
opencastle | string | The OpenCastle versions this layer works with, as a semver range: exact, ^, ~, 1.0.x, comparators such as >=1.0.0 <1.3.0, and ||. Every layer's range must hold, or nothing compiles. |
$schema | string | The published schema, for your editor. Ignored when compiling. |
Variables
Write a variable once, as ${NAME}, in env, headers,
args or url, and keep its value in .env or your shell;
explain lists the ones you have not set. No syntax works in every assistant, so each gets its
own spelling — here the worked example's internal-docs header:
Assistant File The header it receives Claude Code .mcp.jsonBearer ${ACME_DOCS_TOKEN} Cursor, Windsurf (Devin Desktop) .cursor/mcp.json, .devin/mcp_config.jsonBearer ${env:ACME_DOCS_TOKEN} OpenCode opencode.jsonBearer {env:ACME_DOCS_TOKEN} VS Code .vscode/mcp.jsonBearer ${input:ACME_DOCS_TOKEN} — a password input VS Code asks for once and keeps Codex CLI .codex/config.tomlNone written as text: Codex expands no variables, so the server gets bearer_token_env_var = "ACME_DOCS_TOKEN" and Codex sends the header itself Antigravity .agents/mcp_config.jsonWritten as Bearer ${ACME_DOCS_TOKEN}, which Antigravity sends as text — it expands no variables, so doctor says to set the header in your own ~/.gemini/config/mcp_config.json
For acme-db, whose env only passes ACME_DB_URL through,
VS Code gets "envFile": "${workspaceFolder}/.env" instead of a prompt. Editor
variables are not environment variables: VS Code and Cursor get ${workspaceFolder} and
${userHome} as written; every other assistant gets . and its own
spelling of HOME.
Policy
Policy is how the owners of a baseline make a standard hold in every repository that extends it. Any layer can add to it; no layer can loosen what a layer below set.
| Rule | How layers combine | When it is broken |
|---|---|---|
mcp.allow | A server must be on every layer's list. Allowing a server a lower layer refuses has no effect, and says so. | A team server refused by its own layer or one below: compile error. Refused by a layer above the one that defines it — a repository narrowing its baseline's list — or an integration's server: left out of every assistant, with the reason in the lock and in explain. Any other server in an MCP config, such as one added by hand: doctor and sync --check fail. |
mcp.remoteHosts | A remote server's host must be on every layer's list. | As for allow. |
mcp.requirePinned | Once a layer turns it on, it stays on; false above it has no effect, and says so. | A team server: compile error. A server in an MCP config: doctor and sync --check fail. Without the rule, an unpinned server is only a warning. |
require | Accumulates. | Excluding a required item, or replacing one that existed where it was required: compile error. Providing one nothing below had is how it is meant to be met. |
contextBudget | The smallest wins. | Not an error. doctor warns and names the largest contributors; review marks the change ⚠️. |
opencastle | Every layer's range must hold. | Compile error. |
Credentials need no policy. A team server with a credential written into it —
a value in env, headers, args or the URL shaped like a real
token, or a long random value under a name such as TOKEN or API_KEY — is a
compile error, and the same in any MCP config fails doctor and sync --check.
The message names where the credential is, never its value.
A compile error means sync writes nothing and says why;
sync --check and doctor fail with the same message; review
and explain refuse, because there is nothing coherent to describe.
When a source breaks the policy
Suppose shop adds a server called scratchpad, launched as npx -y scratchpad-mcp.
The baseline does not allow it and requires exact versions, so sync refuses and writes nothing;
sync --check and doctor fail with the same problems:
✗ The team's sources have 2 problem(s); nothing was compiled.
✗ .opencastle/config.json: defines MCP server "scratchpad", which @acme/opencastle-baseline does not allow
→ add it to policy.mcp.allow in @acme/opencastle-baseline, or remove it
✗ .opencastle/config.json: MCP server "scratchpad" runs scratchpad-mcp without an exact version — every machine runs whatever was published last
→ pin it as scratchpad-mcp@x.y.z (@acme/opencastle-baseline requires pinned servers) The Lock File
init and every sync write .opencastle/lock.json, in every project. Commit it. One change to a baseline
regenerates dozens of files across several assistants, and nobody reviews that; the lock is the
one file that says, in a shape a person can read, what every assistant is given.
- Layers — each with its version, and for a baseline a digest of its content, so a baseline that changed without a version bump still shows.
- Content — every instruction, skill, agent, prompt and workflow, with the layer it came from (or the integration, as
plugin:<id>), a hash of what was compiled, what it replaced, its description, and for instructions an estimate of their tokens. - MCP servers — each with where it came from, whether it is local or remote, the command line or URL, the names of the variables it reads, and for a team server a hash of its definition, so a changed header or environment value moves the lock. Servers left out — refused by the policy, or excluded with
mcpServers/<name>— are listed underblocked, with why. - Policy — the effective rules, and which layer set each.
- Context — the tokens every assistant loads before a task.
{
"$comment": "Written by opencastle sync — do not edit. Change .opencastle/ (or a baseline) and run sync; opencastle review explains a change.",
"lockfileVersion": 1,
"opencastle": "1.0.0",
"targets": ["claude-code", "cursor", "opencode", "vscode"],
"integrations": ["chrome-devtools", "linear", "nextjs", "slack", "vitest"],
"layers": [
{ "id": "opencastle", "kind": "core", "version": "1.0.0" },
{ "id": "@acme/opencastle-baseline", "kind": "baseline", "version": "1.5.0",
"integrity": "sha256-9YAXToj7DMrgWtqU37WydtzSMztkW+KZA3tfdDtY2l8=" },
{ "id": "project", "kind": "project" }
],
"content": {
…
"instructions/security": { "from": "@acme/opencastle-baseline", "sha": "f0c859346f1e", "tokens": 21 },
…
"skills/deploy-runbook": { "from": "project", "sha": "396e1d08dd6a",
"description": "How this shop deploys. Use when releasing, rolling back, or touching the deploy pipeline." },
…
},
"excluded": { "skills/seo-patterns": "project" },
"mcp": {
"Linear": { "from": "plugin:linear", "transport": "http",
"launch": "https://mcp.linear.app/mcp", "auth": "oauth" },
"acme-db": { "from": "project", "transport": "stdio",
"launch": "npx -y @acme/db-mcp@2.2.0", "env": ["ACME_DB_URL"], "sha": "a1b56de5d5ac" },
…
"internal-docs": { "from": "@acme/opencastle-baseline", "transport": "http",
"launch": "https://docs-mcp.acme.dev/mcp", "env": ["ACME_DOCS_TOKEN"], "sha": "af0f819150cc" },
…
},
"blocked": { "Slack": "not on the MCP allowlist of @acme/opencastle-baseline" },
"policy": {
"allow": { "@acme/opencastle-baseline": ["Linear", "Sentry", "acme-*", "chrome-devtools", "internal-docs", "next-devtools"] },
"remoteHosts": { "@acme/opencastle-baseline": ["*.acme.dev", "mcp.linear.app"] },
"requirePinned": "@acme/opencastle-baseline",
"require": ["instructions/security", "skills/secure-coding"],
"contextBudget": 12000
},
"context": { "tokens": 2779, "instructions": 613, "index": 2167 }
}
It is deterministic — no timestamps, no absolute paths, keys in a fixed order — so it changes only
when what the assistants get changes, and two branches conflict on it only when they disagree.
sync --check fails when it is missing or stale. Do not edit it: change
.opencastle/ or the baseline and run sync.
Every change to the instructions, skills, agents, prompts, workflows, MCP servers and policy moves the
lock, so owning the lock routes all of them to the right reviewers.
opencastle ci --owners @acme/platform appends this to .github/CODEOWNERS:
# What every AI assistant is given (opencastle ci). A change to its instructions,
# skills, agents, prompts, MCP servers or policy moves the lock, so these two
# lines route all of them to the owners. Lessons (.opencastle/lessons/) do not.
/.opencastle/lock.json @acme/platform
/.opencastle/config.json @acme/platform
Lessons are not in the lock. To have the same owners approve them, add a line for
/.opencastle/lessons/.
Team Memory
What agents learn on a project, a command that fails or a convention written down nowhere else, is worth more to the team than to the session that learned it. Not all of it is the team's, though, and OpenCastle keeps three scopes apart.
| Scope | Where it lives | Who it reaches | Moves up with |
|---|---|---|---|
| Personal | Your assistant's own memory on your machine, and your own skills in ~/.claude/skills or ~/.agents/skills | You | What it remembers about the project becomes a lesson when the agent stops; what it remembers about you stays. A skill you wrote: promote skill |
| Team | Lessons in .opencastle/lessons/, and .opencastle/skills/, committed | Every assistant of everyone in the repository, once the pull request is merged | A lesson that holds in every repository graduates into the baseline's skill; promote skill --to <baseline> |
| Organisation | A baseline: an Agent Plugin every repository extends | Every repository, and any assistant that installs the plugin | — |
Agents remember as they always do; the assistant shares it. Asking agents to write a
second record never worked: on the one project that tried it in earnest, agents wrote 25 lessons in 794
pull requests, while Claude Code saved 72 memories of its own, nearly all about the project. So agents
keep saving to their own memory, labelled as about the project or about the user, and the assistant runs
OpenCastle when they stop: Claude Code on SessionEnd, from the project's
.claude/settings.json, and VS Code on Stop, from
.github/hooks/opencastle.json. The hook runs promote memory --json: what is
about the project (corrections, project notes, pointers to where things live) becomes a lesson, and what
is about the user stays theirs. The lessons land in the working tree, so they go into the pull request with
the work, and the team reviews them there. Nobody runs a command or sorts anything.
Every assistant loads the lessons on its own. Claude Code reads
.claude/rules/opencastle-lessons.md, VS Code
.github/instructions/opencastle-lessons.instructions.md, Cursor and Windsurf an always-on rule:
the active lessons, titles and paths, rewritten whenever a lesson is written. Codex, OpenCode and
Antigravity have one root file, so their agents read the index by instruction, and record a lesson
themselves, as the self-improvement skill describes.
Lessons are one file each, written by opencastle lesson, so two branches that
each add one merge cleanly; .opencastle/LESSONS-LEARNED.md is the index agents read first,
rebuilt by sync and checked by sync --check. A lesson can cite the code it is
about with --cite. doctor then names any lesson whose cited file has changed or gone
since it was verified, and the fix — lesson verify when it still holds, lesson archive <id> --into <file>
when it has been merged into a skill — so a stale lesson does not keep steering agents. A lesson every
repository needs belongs in the baseline's skill: once the version carrying it is installed,
--into skills/<name> records it.
A lesson, once someone has set its category and cited the code it is about:
---
id: "2026-10-03-every-migration-ships-with-its-rollback"
title: "Every migration ships with its rollback"
category: "database"
severity: "high"
added: "2026-10-03"
citations:
- "db/migrations/"
---
Every migration in db/migrations/ ships with a down file.
**Why:** the March outage could not be rolled back. Skills leave one laptop with promote skill: it copies a skill you wrote for
your own assistant into the team's sources, after checking it against the Agent Skills spec and for
credentials. Codex's memories have no hook to share them: npx opencastle promote memory
turns them into lessons when you run it, as the hooks do for Claude Code's and VS Code's.
Limits. Hooks run only in a folder the assistant trusts, and VS Code's are a preview it
turns on by default (chat.useHooks). Cursor keeps its memories on its servers and Copilot
Memory lives on GitHub, so neither can be read. A lesson the hook writes at the end of a session is
uncommitted until the next commit.
Nothing is shared until it is committed. The pull request that commits a promoted skill or lesson is where
the team agrees with it — and, with a CODEOWNERS line for /.opencastle/lessons/, which
ci --owners does not add (see The Lock File), where its owners do.
Team Commands
Seven commands. What each is for is below; their options, exit codes and every check they make are in the CLI reference.
explain
npx opencastle explain [--all] [--json] What every assistant here is given, and what you still have to set up. Step 3 of the worked example is a run; the options are in the CLI reference.
review
npx opencastle review [--base <ref>] [--markdown] [--json]
Says in sentences what a change does to every assistant, and marks with ⚠️ what deserves a careful
look; step 4 of the worked example is a run. It explains rather than gates: whatever the change, it exits 0, and
sync --check is the gate. What gets ⚠️, and the few cases where it cannot compare:
CLI reference.
ci
npx opencastle ci [--owners <team>] [--force] [--dry-run]
Writes .github/workflows/opencastle.yml. For shop, --dry-run shows this
workflow:
# Written by `opencastle ci`. Fails a pull request when the AI assistant config
# every developer gets differs from its sources or breaks the team's policy,
# and summarises what the change does to the assistants.
name: AI assistant config
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
opencastle:
runs-on: ubuntu-latest
steps:
# Pin actions to a commit SHA if your organisation requires it.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Generated config matches its sources and policy
run: npx -y opencastle@1.0.0 sync --check
- name: What this pull request changes
if: github.event_name == 'pull_request'
run: npx -y opencastle@1.0.0 review --base origin/${{ github.base_ref }} shop has a package-lock.json, so the workflow installs with npm ci and the
baseline resolves. opencastle is not one of its devDependencies, so the workflow pins the release
that compiled the project; add it and the workflow runs that copy instead. Other package managers,
monorepos and where --owners writes: CLI reference.
baseline
npx opencastle baseline <init|check> [dir] [--name <pkg>] [--json] init scaffolds a baseline that is also an Agent Plugin, laid out like the package in step 1 of the worked example;
check validates it the way every repository extending it will, and fails — exit 1 — on any of
the problems listed in the CLI reference.
$ npx opencastle baseline init --name @acme/opencastle-baseline
✓ Created the baseline @acme/opencastle-baseline in opencastle-baseline/
It is an Agent Plugin too: skills/ and mcp.json are portable; the rest of the layer is in dev.opencastle/
Next: edit it, run npx opencastle baseline check opencastle-baseline, then publish it.
Repositories adopt it with npm i -D @acme/opencastle-baseline and "extends": ["@acme/opencastle-baseline"]
$ npx opencastle baseline check opencastle-baseline
🏰 Baseline @acme/opencastle-baseline 0.1.0
Contributes: 1 skills, 1 instructions
A repository extending it loads ~2372 tokens up front, with OpenCastle's own content
✓ Ready to publish. plugin
npx opencastle plugin <check|build|index> [dir] [--check] [--name <name>] [--owner <name>] [--json] check loads an Agent Plugin the way an assistant that follows the standard does and says what it
would refuse or skip; build writes the manifest and MCP file Claude Code reads instead of the
portable ones; index writes the marketplace files Claude Code, Copilot, Cursor and Codex read for a
repository of plugins. How to use them: Agent Plugins;
options: CLI reference.
promote
npx opencastle promote <skill|memory> [name|path] [--to <dir>] [--from <dir>] [--force] [--dry-run]
Copies a skill from your own skill directories into .opencastle/skills/ or a baseline, or turns
what Claude Code, VS Code and Codex remembered about the repository into lessons. See Team Memory
and the CLI reference.
fleet
npx opencastle fleet <dir...> [--json] Reads the committed lock of each directory you give it — nothing is installed or fetched — and shows which versions each repository runs and which MCP servers launch differently. Which directories it skips: CLI reference.
$ npx opencastle fleet shop admin
🏰 Fleet — 2 repositories
• shop opencastle 1.0.0 @acme/opencastle-baseline@1.5.0 4 target(s), 6 MCP server(s), ~2779 tokens up front
• admin opencastle 1.0.0 @acme/opencastle-baseline@1.4.0 4 target(s), 5 MCP server(s), ~2765 tokens up front
opencastle: 1.0.0 (2) — all on one version
@acme/opencastle-baseline: 1.4.0 (1), 1.5.0 (1) — 1 behind 1.5.0: admin
MCP servers running differently across repositories
~ acme-db
npx -y @acme/db-mcp@2.2.0 — shop
npx -y @acme/db-mcp@2.1.0 — admin doctor and sync in a team
doctor also checks the team's sources, the context budget, and the scripts and paths the team's
content names; sync will not compile with an older OpenCastle than the one that compiled the
project. See doctor and
sync in the CLI reference.
Rollout Recipe
- Create the baseline.
npx opencastle baseline init --name @acme/opencastle-baseline. Move the rules every repository shares intodev.opencastle/instructions/and keep them short — every token there loads before every task in every repository. Put detail inskills/, which loads only when a task needs it. Set the policy indev.opencastle/config.json. - Check and publish.
npx opencastle baseline checkvalidates it the way every repository will read it; the scaffolded workflow runs the same check on every pull request. Publish it to your registry like any other package. - Adopt it in each repository.
npm i -D @acme/opencastle-baseline opencastle, add"extends": ["@acme/opencastle-baseline"]to.opencastle/config.json, runnpx opencastle sync, and commit the generated files and the lock. Havingopencastlein devDependencies means everyone runs one version. - Wire up CI.
npx opencastle ci --owners @acme/platform, then commit the workflow and.github/CODEOWNERS. - Let your upgrade bot roll out new versions. When you publish a new baseline, Renovate or
Dependabot opens one pull request per repository. The bot changes only
package.jsonand your lockfile, sosync --checkfails on its pull request until someone runsnpx opencastle syncon the branch and pushes. That is the point: the review summary on the same pull request says what the new version changes in that repository before anyone merges it. - Review in pull requests. Read the ⚠️ lines closely: a new MCP server, a widened allowlist, a new always-loaded instruction. With CODEOWNERS in place, the owners approve every change the lock records.
- Watch the fleet.
npx opencastle fleet ~/src/*over your checkouts shows who is behind and which servers run at different versions.
An organisation, then a team
A baseline can extend another. The platform team publishes @acme/opencastle-baseline; the
payments team publishes @acme/payments-baseline, which extends it and adds its own skills
and servers; a payments repository extends only the second. Policy still only tightens: a server must be
on every layer's allowlist, so the payments baseline can narrow the organisation's list but never widen it.
Keep each layer's always-loaded instructions short — the repository loads all of them.
A monorepo
One .opencastle/ at the root, compiled once. Put what each app needs in a skill whose
description names the app — "Conventions for apps/checkout: …" — because every
assistant loads a skill only when the task needs it. An instruction with
applyTo: "apps/checkout/**" is scoped only in Copilot, Windsurf, and Cursor on its own;
Claude Code, Codex, OpenCode and Antigravity have one root file, so there it loads before every task,
and Cursor reads that file too when one of them is selected.
Limits
- OpenCastle fetches nothing. A baseline must be installed where Node would find it — npm, pnpm, Yarn and workspaces all work, and so does a git dependency your package manager installs. CI must install dependencies before it runs OpenCastle; the workflow
ciwrites does. - GitHub Actions only, for now.
ciwrites a GitHub Actions workflow, and the annotations and job summary are GitHub's. Elsewhere, runsync --checkandreview --markdownin your own pipeline.reviewwrites to the job summary, not a pull request comment. fleetreads checkouts. There is no hosted view; you point it at directories you have cloned.- The context figure is an estimate. It is one number — four characters per token over the merged instructions and the skill and agent index — not a measurement of what each assistant loads.
- A pin fixes a server's own version, not its dependencies, which
npxresolves on each machine. Launching the project's own copy withnpx --no, or a remote server, avoids that. - Windsurf and Antigravity are less certain. Windsurf is now Devin Desktop: its default agent, Devin Local, reads the
.devin/mcp_config.jsonOpenCastle writes, while the legacy Cascade agent reads only its global config. Antigravity expands no variables, so a server that needs a token in a header needs it set in each person's global config. Codex CLI gets its servers in.codex/config.toml, which it loads only in a project you have trusted —explaintells you whether you have. - Replacing is all or nothing. A layer that provides an item with the same name replaces the whole item; two layers' versions of one skill are never merged.
- Policy covers what is present, not what it says. It decides which servers run and which items must exist; it does not read the text of instructions.
- Dead references are checked narrowly. Scripts named as
npm run(orpnpm,yarn,bun run) are checked in all team content; backticked paths only in the repository's own content, and only under a top-level directory that exists.
What is still open, and why: the design notes.