tarek.kharsa

home / lessons / pi · lesson 1

ArchitecturePi · lesson 1 of 82 min read

Ship a small core, and make your own features replaceable

Its MCP support is a plugin, and a third-party plugin can replace it. That's the point.

Problem

Core teams keep adding features to the core, and users who disagree with one have to fork.

Idea

Build your own features on your public plugin API so users can replace them. A feature that can't be a plugin exposes a gap in the API.

Most tools grow by adding features to the core. This one grows by refusing to:

If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.

What makes that real, and not just a slogan, is that the first-party features obey it too.

Try it: replace a built-in feature

install a third-party MCP plugin and watch the built-in step aside

MCP support, code mode and tool search ship inside the product as built-in extensions marked replaceable. An extension that registers /mcp takes over, and the built-in stops reading its config and connecting servers.

export const builtInExtensions = [
  { name: "llama.cpp",   factory: llamaExtension,      builtin: true },
  // Replaceable: an extension that registers `codemode`, `tool_search`,
  // or `/mcp` takes over instead of running alongside the built-in one.
  { name: "codemode",    factory: codemodeExtension,   replaceable: true, builtin: true },
  { name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true },
  { name: "mcp",         factory: mcpExtension,        replaceable: true, builtin: true },
];

Why this is an architecture decision, not a feature list

  1. The API gets tested by its own authorsIf MCP can be built as an extension, the API already supports tools, commands, configuration, connections and renderers. The next protocol can be built the same way.
  2. Takeover is by registration, not by settingThe core doesn't need a "use the other MCP" flag. Whoever registers the command or tool name owns it, and removing the extension restores the built-in. Conflicts resolve the same way for every feature.
  3. Opinions become optionalSub-agents and plan mode aren't in the core at all. They're example extensions (subagent, plan-mode, permission-gate, sandbox, among 79), so disagreeing with a design means swapping an extension, not forking.

Hook points cost something too

Even hook points for extensions however should be well considered and discussed to avoid adding unmaintainable bloat and complex interactions.

A small core with a hundred ad-hoc hooks isn't small: every hook is API surface that has to keep working, and every pair of hooks is an interaction someone has to reason about. In other words, a hook needs a real caller before it's worth adding.

Steal this

Build your own features on your public plugin API, and let users replace them by registering the same name. If a first-party feature can't be written as a plugin, your API is missing something, and you just found it.

Use it in your code

A prompt for your coding agent

Run it from the root of your repo. It checks whether the idea fits before it changes anything.

Check whether this project's built-in features could run on its own extension or plugin API.

The idea: keep the core small, and build first-party features on the same public extension API that third parties use, so users can replace them. If a first-party feature can't be written as a plugin, the API is missing something.

1. Find the extension, plugin or hook API. If there isn't one, tell me and say whether one is worth adding.
2. List the built-in features that sit on top of the core. For each, say whether it uses the public API or reaches into internals.
3. Pick the most self-contained feature that reaches into internals, and describe the minimal API additions it would need to work as a plugin.
4. If the gap is small, refactor that one feature onto the public API with identical behavior, and add a test showing that a replacement plugin can take its place.

Don't add speculative hooks: every new hook needs a real caller. Keep the change small.

Read the source

Where this comes from

Pi (earendil-works/pi), the minimal, extensible agent harness. The files behind this lesson: