OpenCastle

Docs / OpenCastle for Teams

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 — explain for onboarding, review for pull requests, ci to wire it up, baseline and plugin to publish a standard, promote to share what one assistant learned, fleet to see where it has landed.

One Repository, One Team

Most teams start here, and need no baseline.

  1. Put the team's own rules in .opencastle/. Short rules every task needs in instructions/, deeper knowledge in skills/<name>/SKILL.md, procedures in prompts/. npx opencastle sync compiles them for every assistant anyone on the team uses.
  2. Commit it all — .opencastle/, the generated files and the lock. A teammate who clones the repository has the same setup without running anything.
  3. Check it on every pull request. npm i -D opencastle && npx opencastle ci --owners @your-team writes the workflow, and asks the owners to review any change to what the assistants are given.
  4. 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.

Layers, bottom to top
OpenCastle — its own agents, skills, instructions, prompts and workflows, and the integrations your stack uses
then each baseline in extends, in order
@acme/opencastle-baseline — an Agent Plugin: its skills/, mcp.json and dev.opencastle/
then the repository itself
This repository — .opencastle/
compiled into every target
Claude Code
Copilot
Cursor
Windsurf
OpenCode
Codex CLI
Antigravity

A layer holds any of these:

PathWhat it is
config.jsonextends, exclude, mcpServers, policy and opencastle — see the config reference
instructions/*.mdRules every assistant reads before every task
skills/<name>/SKILL.mdKnowledge an assistant loads when a task matches the skill's description
agents/*.agent.mdPersonas to delegate to
prompts/*.mdReusable prompts, compiled as /oc:<name> commands in Claude Code and Copilot
workflows/*.mdMulti-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.

Walkthrough

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%)
Reference

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
  }
}
FieldTypeWhat it does
extendsstring[] 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.
excludestring[] 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.
mcpServersobject 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, envstring, string[], object A local server: what to run, its arguments, and its environment.
…url, headersstring, object A remote server: its http(s) URL and the headers to send. A server has either command or url, never both.
…type, descriptionstring 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.allowstring[] Server names an assistant may start; * matches any run of characters. See Policy.
policy.mcp.remoteHostsstring[] 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.requirePinnedboolean 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.requirestring[] 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.contextBudgetinteger 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.
opencastlestring 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.
$schemastring 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:

AssistantFileThe 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}
OpenCodeopencode.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.

RuleHow layers combineWhen 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 under blocked, 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.

ScopeWhere it livesWho it reachesMoves up with
PersonalYour assistant's own memory on your machine, and your own skills in ~/.claude/skills or ~/.agents/skillsYouWhat it remembers about the project becomes a lesson when the agent stops; what it remembers about you stays. A skill you wrote: promote skill
TeamLessons in .opencastle/lessons/, and .opencastle/skills/, committedEvery assistant of everyone in the repository, once the pull request is mergedA lesson that holds in every repository graduates into the baseline's skill; promote skill --to <baseline>
OrganisationA baseline: an Agent Plugin every repository extendsEvery 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.

Adoption

Rollout Recipe

  1. Create the baseline. npx opencastle baseline init --name @acme/opencastle-baseline. Move the rules every repository shares into dev.opencastle/instructions/ and keep them short — every token there loads before every task in every repository. Put detail in skills/, which loads only when a task needs it. Set the policy in dev.opencastle/config.json.
  2. Check and publish. npx opencastle baseline check validates 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.
  3. Adopt it in each repository. npm i -D @acme/opencastle-baseline opencastle, add "extends": ["@acme/opencastle-baseline"] to .opencastle/config.json, run npx opencastle sync, and commit the generated files and the lock. Having opencastle in devDependencies means everyone runs one version.
  4. Wire up CI. npx opencastle ci --owners @acme/platform, then commit the workflow and .github/CODEOWNERS.
  5. 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.json and your lockfile, so sync --check fails on its pull request until someone runs npx opencastle sync on 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.
  6. 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.
  7. 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 ci writes does.
  • GitHub Actions only, for now. ci writes a GitHub Actions workflow, and the annotations and job summary are GitHub's. Elsewhere, run sync --check and review --markdown in your own pipeline. review writes to the job summary, not a pull request comment.
  • fleet reads 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 npx resolves on each machine. Launching the project's own copy with npx --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.json OpenCastle 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 — explain tells 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 (or pnpm, 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.