An Agent Core that can call tools is not complex. At minimum, it maintains messages, sends requests to the model, executes tool calls, and feeds the results back into the next request.
The rapid expansion of an Agent Framework usually happens outside the Loop.
Coding Agents quickly accumulate filesystem tools, Shell, MCP, Skills, permission controls, model switching, Sessions, Compaction, commands, UI, telemetry, and SubAgents. Every new capability brings an implementation choice: keep modifying the Agent Core, or provide a stable extension surface that external modules can plug into.
Pi chose the latter. It retains a relatively centralized Agent Runtime while using Extensions to move a wide range of product capabilities outside the Core.
This article focuses not on which plugin APIs Pi has, but on a more fundamental framework-design question: which capabilities should live in the Agent Core, and which should be extended through the Harness.
From the Tool Loop to an Expanding Core
The first part established a relatively stable boundary for the Agent Loop:
Context
↓
Model
↓
Tool
↓
Input Delivery
↓
Stop Condition
This structure can explain how a single agent run progresses, but it cannot accommodate all the capabilities of a complete product.
Take permission control. The most direct implementation is to add a check before each tool runs:
Then auditing is added:
Next comes a hook:
Then remote execution:
If every feature is added to the main execution function in this way, the Agent Loop gradually becomes the integration hub for the entire system. Any feature can modify it, and any modification can affect other features.
Here the Harness takes on its first responsibility:
The Core defines stable execution semantics; the Harness organizes external capabilities around those semantic boundaries.
The value of an extension system therefore lies not in “allowing third parties to write plugins,” but in controlling the Core’s rate of growth.
Pi’s Extension Design: A Stable Core + an Extension Point
Pi positions itself as a minimal terminal coding harness. By default, its Coding Agent provides only a small set of basic tools; more behavior is added through Extensions, Skills, Prompt Templates, and Packages.[1][2]
An Extension can register a Tool:
It can also listen for lifecycle events:
It can also register Commands, Shortcuts, and Providers, or inject messages into a running Session.[1][3]
On the surface, these APIs look very different, but they can be grouped into two directions.
The first is Contribution:
Extension
↓
向 Runtime 注册一项能力
Tool
Command
Provider
Renderer
...
The second is Hook / Interception:
Runtime lifecycle
↓
Extension observes / modifies / blocks
The former expands “what the system has”; the latter expands “what happens when the system reaches a particular boundary.”
Therefore, the core structure of a Pi Extension can be abstracted as:
This model makes it easier to establish a unified development model than designing ToolPlugin, CommandPlugin, and HookPlugin separately. An Extension has closure-scoped state, so Tools, Hooks, and Commands within the same Extension can share state directly.
At one point, Pi kept Hooks and custom Tools separate before unifying them under Extensions. This evolution reflects the same principle: extension units should be organized around a feature module, not around a registration type.[4]
ExtensionAPI Is a Boundary, Not the Container Itself
A common mistake in plugin architecture is to expose the entire Application or internal objects so that plugins can “do anything.”
That is highly flexible in the short term, but over time it creates a de facto internal API: plugins can access any implementation detail, and the Core loses room to refactor.
Pi uses ExtensionAPI as the plugin entry point. Extensions use it to register capabilities, send messages, and read or modify state under the API’s control.[1][3]
The relationship can be understood as:
Extension
│
▼
ExtensionAPI
│
▼
Runtime / Session / UI / Registry
The API matters because it defines which system boundaries a plugin is allowed to influence.
For example:
registerTool()
allows an Extension to add a capability the model can invoke.
on('tool_call')
allows an Extension to participate in controlling execution at the tool-call boundary.
sendMessage()
allows an Extension to feed new input into the Agent Runtime.
These capabilities are powerful, but calls still pass through entry points defined by the Harness. The Core’s internal state structures can continue to evolve.
For an extension API, the stability of its capability boundaries matters more than the number of methods it exposes.
Why a Hook Is More Than Just an Observer
pi.on(...) is easy to mistake for an Event Bus, but some of Pi’s Hooks can modify or even block runtime behavior.
For example, a Tool Call Hook can inspect, modify, or reject a call, while a Context Hook can adjust the messages the model is about to see.[1]
These Hooks are closer to Middleware / Interceptors:
Runtime operation
↓
Extension A
↓
Extension B
↓
Core behavior
An Observer’s basic semantics are to observe facts that have already occurred:
event happened
↓
listener notified
An Interceptor, by contrast, sits within the behavior chain:
operation requested
↓
interceptor
↓
modify / block / continue
This distinction directly affects the complexity of the plugin system.
A pure Observer only has to account for notifications and exception isolation. An Interceptor also needs to define ordering, short-circuiting, error propagation, asynchronous behavior, and composition semantics when multiple extensions modify the result at the same time.
Pi’s current tool_call explicitly specifies that modifications made to the input by an earlier Handler are visible to later Handlers, and that a Handler can block the call by returning a value. The event is not a simple broadcast; it is part of an execution protocol.[1]
This is another boundary that Harness design must control: not every event should grant the right to modify behavior.
If an extension point only needs observation, it should remain read-only; provide Interception only at execution boundaries that genuinely require policy intervention.
Dynamic Registration Covers Runtime Extensions; Lifecycle Is a Separate Concern
Pi Extensions can register Tools while the system is running. Once registered, a new Tool can join the current Session’s tool set.
From the user’s perspective, this already provides an important capability of a dynamic plugin system:
Runtime
│
├── Tool A
├── Tool B
│
└── Extension loads
↓
Tool C
But dynamically “adding” capabilities is only half the problem.
The harder problem arises when capabilities leave the system.
Suppose an Extension does the following:
register Tool A
register Command B
subscribe tool_call
start timer
provide service
When the plugin is unloaded, the system must fully roll back these changes.
As dependencies accumulate, the problem grows more complex:
Plugin A
└── provides Service X
Plugin B
├── depends on Service X
└── provides Service Y
Plugin C
└── depends on Service Y
If A is unloaded, should B keep running? Does the Y provided by B remain valid? How should C handle the situation? When A is loaded again, should B and C be restarted?
These questions extend beyond the Extension Registry itself into dynamic composition and lifecycle management.
Pi’s design centers Extensions around a relatively stable Runtime. This design is well suited to expressing:
stable runtime
+
dynamic contributions
+
lifecycle hooks
DeepSeek Harness adopts a more radical architecture: capabilities such as the Agent Loop, Session, Tool Registry, and LLM Adapter are themselves provided by plugins.[5] The plugin system must then support dynamic changes to the Runtime’s structure itself.
This is precisely where Cordis fits into DeepSeek Harness.
A Practical Test for the Core Boundary
You can judge whether a capability belongs in the Agent Core with three questions.
First, does it participate in the basic semantics of every Agent Step?
For example, Context construction, Model Requests, the flow of Tool Results back into the Loop, and stop conditions form the Loop’s basic execution structure. Remove these capabilities entirely, and the Agent Loop itself ceases to exist.
Second, does it have many interchangeable implementations?
Persistence, Sandbox, Model Adapter, and Telemetry all admit many implementation choices. Locking one implementation into the Core makes it increasingly costly to replace.
Finally, does it need an independent lifecycle?
If a capability requires dynamic loading and unloading, isolation and reloading, or dependencies on other capabilities, it is already a component in its own right and is better suited to the Harness.
A useful rule of thumb emerges:
Agent Core
负责不可再分的执行语义
Harness
负责能力的组合、替换与生命周期
This boundary does not require the Core to be extremely small. Over-splitting raises the cost of understanding just as much. The key is to avoid forcing every product capability to integrate by modifying the same Loop.
Pi demonstrates a relatively conservative, easy-to-understand approach: keep the Runtime stable and extend Contributions and Hooks through Extensions.
As DeepSeek Harness expands its scope, it needs to solve a different class of problems: how to keep composition relationships correct when components can appear, disappear, and depend on one another at runtime.
The next installment will discuss the paper behind Cordis, “A Programming Paradigm for Spatiotemporal Composability.” The paper attempts to establish a more general model for this problem.
References
[1] Pi Extensions: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md
[2] Pi Coding Agent: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/README.md
[3] Pi Extension types: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/extensions/types.ts
[4] Pi Extension unified design discussion: https://github.com/earendil-works/pi/issues/454
[5] DeepSeek Harness Architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md