When many developers read the plugin examples of DeepSeek Harness for the first time, two questions tend to arise most easily: why can a plugin confidently assume that ctx.tools must exist just by writing a single line, export const inject = ['tools']? And why does the documentation contain no manual cleanup code such as removeListener or clearInterval when a plugin is unloaded? These two questions appear to belong to two separate topics, "dependency declaration" and "resource reclamation," but in fact they answer the same thing: Harness has remodeled plugins from "a module that produces side effects everywhere" into "a node attached to a scope tree, with its dependencies explicitly declared." This article unfolds along three main lines: dependency injection (inject), Effects (ctx.effect), and the service model (Service). It first explains clearly what the three built-in services expose, what timing guarantees the inject array provides before apply, and how to use the Service base class to turn yourself into a service provider. Then, following the mechanisms of ctx.effect() and Fiber scope accounting, it peels back layer by layer the suspense of "why no cleanup code is needed," and provides TypeScript examples that can be pasted and run directly, along with an engineering pitfall-avoidance checklist. This article is the first half of the entire tutorial. It first explains the mechanisms thoroughly, grounding the timing of registration and unload, the sources of service lookup and type hints, and the boundaries of the automatic tracking list in concrete fields and behaviors.
ctx.tools / ctx.llm / ctx.agents: What Capabilities Do the Three Built-in Services Expose?
To understand dependency injection, you first need to understand the thing being injected—the "service." In the context of Harness, a service is a named capability that a plugin exposes to other plugins. It is not a function library that gets imported, but a stable property attached to the ctx object, such as ctx.tools, ctx.llm, and ctx.agents. Any plugin, as long as it has access to ctx, can look up capabilities along this key, without needing to know who implements the capability behind it, which file it lives in, or whether it might be replaced by a mock version by another plugin. This is precisely the core distinction between dependency injection and "direct import": the caller depends on the contract, not the implementation.
At startup, Harness attaches three foundational services to ctx, and they form the minimal ecosystem foundation for the vast majority of plugins. The table below compares the service names of these three built-in services, what they are, and their typical usage, so you can quickly determine which one to inject when writing a plugin:
| Built-in Service | What It Is | Typical Usage |
|---|---|---|
| ctx.tools | Tool Runtime (ToolRuntime) | Register tools, invoke tools, and attach model-callable capabilities to the runtime |
| ctx.llm | Large Language Model Service (LLM) | Register model adapters and issue model requests; it is the unified entry point for model calls |
| ctx.agents | Agent Service (Agent) | Manage sub-agents and handle orchestration and lifecycle across multiple agents |
Two engineering implications can be drawn from this table. First, ctx.tools is oriented toward the layer of "capability registration and invocation"; it cares about the running of tools, not the specific business logic of the tools. Your plugin hands tool objects to it, and it routes requests when the model needs to invoke them. Second, ctx.llm is the unified convergence point for model access. Registering an adapter means you can swap out the underlying provider without touching upper-layer business code, and issuing requests means you do not need to instantiate an HTTP client yourself. Third, ctx.agents abstracts the management of sub-agents out of the plugin. The plugin's responsibility degrades to "declaring sub-agents and orchestrating them," while the lifecycle is handled by the service itself.
There is a design detail here that is easily overlooked but crucial: plugins look up services by key rather than importing concrete implementations. That is, in your plugin you write ctx.tools.register(...), not import { ToolRuntime } from '...' and then construct an instance yourself. This difference, which seems like merely "writing one less import," actually determines three things:
- Replaceability: As long as another plugin provides a service with the same key, or even provides a contract-compliant test double, the consumer code does not need to change a single line. During testing, you can completely replace ctx.tools with a fake implementation and observe whether the plugin calls it according to the contract.
- Composability: Multiple plugins can each register their own capabilities into the same service. Tools, model adapters, and sub-agents can all be aggregated under the same namespace, without plugins needing to import each other and form a mesh of dependencies.
- Unloadability: Because service registration enters the framework's field of view through ctx, the framework knows what should be returned when unloading. Conversely, if you bypass ctx and maintain a global registry yourself, the framework cannot see it during unload, and that resource will leak.
There is also a common engineering pitfall: conflating "services" with "tools." A service is a named capability, and it is attached to ctx; a tool is a concrete entry that you register within the ctx.tools service. The two have a container-and-content relationship. A plugin can consume the ctx.tools service, consume the ctx.llm service, and also provide a new service of its own; these three things do not conflict with one another. When writing inject, the question to ask yourself is "which ctx.
inject Array: How the Framework Ensures All Dependencies Are Ready Before apply Runs
Now that you understand services, let's look at the syntax for declaring dependencies. For a plugin to express "I depend on the tools service," it only needs to export an array named inject at the module's top level:
// File path: scratch-plugin/src/my-tool-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-tool-plugin'
// Declare dependencies: requires the tools service
export const inject = ['tools']
export function apply(ctx: Context) {
// By the time execution reaches here, ctx.tools is guaranteed to be ready
ctx.tools.register(/* ... */)
}Short as this code is, it carries a very strong temporal contract: as long as 'tools' is listed in inject, then when apply is invoked, ctx.tools is guaranteed to exist and be ready. Here, "ready" does not mean "the property exists but might be undefined"—it means "the service has finished mounting and its public methods can be called directly." During the plugin loading process, the framework first reads the inject array and checks one by one whether these services are already available on ctx; only when every entry in inject satisfies the condition does it actually enter your apply. If some service is not yet ready, the framework suspends the plugin and triggers loading once the service appears. This is why you can confidently write ctx.tools.register(...) inside apply without needing defensive checks like if (ctx.tools).
Expanding this timing into steps, the call chain looks roughly like this:
- The framework reads the name and inject exported by the plugin module.
- The framework resolves each key in the inject array, looking up on ctx whether the corresponding service is already ready.
- If all are ready, it calls apply(ctx); if any service is not ready, the plugin enters a waiting state and apply is not executed.
- Calls to ctx.tools inside apply occur after the service has already been mounted, so access is safe.
There are three engineering points here that deserve to be called out separately. First, inject is a module-level export, not a local variable inside apply. This means it must be readable by the framework at the moment the plugin is resolved, rather than having the plugin "decide" at apply runtime whom it depends on. This constraint is intentional: dependencies are statically analyzable, so the framework can perform a topological sort before loading, avoiding circular loading where "plugin A depends on plugin B, and plugin B depends on plugin A," and also avoiding race conditions where "a service is used before it has been initialized." Second, inject declares service keys, not concrete classes or filenames. When you write 'tools', it corresponds to ctx.tools; if a custom service is mounted on ctx.sessions, then what you inject should be 'sessions'. The keys correspond one-to-one with the property names on ctx, which is the most intuitive and least error-prone mental model. Third, apply is the execution timing, not the dependency declaration timing. Many newcomers put dependency checks inside apply, for example checking first and then registering, which ends up being verbose and obscures the real dependency relationships; the correct approach is to move all dependencies forward into the inject array, leaving apply to care only about business logic.
Going one step further, a natural follow-up question arises: if I inject 'tools', but I also want this plugin to keep working after the tools service is replaced, what should I do? The answer is that you should have been depending only on the contract all along. Because ctx.tools is a service, its public interface is stable, and replacing the implementation will not change the method signatures you call. This is also why writing only string keys in the inject array is sufficient—the stability of the contract is already guaranteed by the service's interface, and you do not need to declare a version or source in inject.
From Consumer to Provider: Mounting Custom ctx.<key> with the Service Base Class
Everything discussed so far has been from the "consumer" perspective: my plugin needs capabilities provided by others, so I write inject. But from the perspective of the plugin ecosystem, a healthy system must have plugins that act as "providers." Harness provides the Service base class for this purpose, allowing any plugin to register one of its capabilities as a named service, mount it onto a key of ctx, and make it available for other plugins to consume.
First, let's clarify the definition of a service: a service is a named capability mounted on ctx; any plugin can provide a service for other plugins to use. It occupies a stable ctx.
The typical workflow for using the Service base class as a provider can be summarized in three steps:
- Define a service class that extends the Service base class, and implement the methods you want to expose on the class.
- In the plugin's apply, mount this service instance onto the agreed-upon ctx.
, completing the act of "providing the service." - Add that key to other plugins' inject arrays, and the framework will guarantee that your service is ready by the time their apply executes.
The key here lies in the correspondence between the mount point and the key. When you mount a service onto ctx.sessions, consumers inject 'sessions'; when the service is unmounted, this key is also removed from ctx, and the plugin dependencies of consumers are no longer satisfied. This linkage of "service appears → consumers are awakened; service disappears → consumers are suspended or unmounted" is the most worthwhile part of a dependency injection system to appreciate. It makes collaboration between plugins depend not on the incidental order of "who loads first," but on the explicit contract of "who declared the dependency."
In engineering practice, providers should pay special attention to two things. First, public methods should be small and stable. Once a service interface is used by a large number of consumers, the cost of modification rises exponentially, so it is better to provide several fine-grained methods than a single "universal method" with a huge number of parameters. Second, services should not do heavy work in the constructor. The timing of when a service is mounted is affected by framework scheduling, and heavy initialization will slow down startup; a more reliable approach is to put heavy work into apply, or initialize lazily on the consumer's first call.
Where Type Hints Come From: Service Interfaces and Auto-Generated Service Pages
The easiest thing for advanced readers to find fault with in a dependency injection system is type safety: if consumers only fetch services from ctx via string keys, how does TypeScript know that ctx.tools has a register method? The answer is that the framework injects service interfaces into the Context type through the Service base class and type declarations. In other words, when you correctly declare a service, ctx.
For built-in services, the situation is even more worth emphasizing: the service names, public methods, and source locations of built-in services are automatically generated from the repository into each service's subsystem page. This sentence carries three layers of information:
- Service names: for example tools, llm, agents. These keys are not a hand-maintained static list, but are generated from the repository, ensuring consistency with the actual state of the code.
- Public methods: which methods each service exposes externally is likewise given by the generated blocks, rather than being listed by documentation authors from memory.
- Source locations: which file a service is implemented in can be looked up in the generated pages, making it convenient to jump directly to the implementation to confirm behavioral details.
From this comes a very important development discipline: when developing plugins, you should treat these generated blocks and the services' TypeScript interfaces as the source of truth, and not rely on any hand-written static service list. The problem with hand-written lists is that they drift: a service adds a method and the list isn't updated; a service changes its parameters and the list is still the old one. Auto-generated blocks and TS interfaces, by contrast, are naturally in sync with the source code. When you encounter something uncertain, the correct approach is to look at the method signatures of the TypeScript interface, not to search for a table in some secondhand tutorial.
Stringing the type chain together end to end, it goes roughly like this: the Service base class defines the public methods available on a service instance; a plugin, on the provider side, attaches the service to ctx.
ctx.effect(): the cleanup entry point for non-registered resources like network connections
Having covered services and dependencies, the article moves on to its second main thread: resource cleanup. A real plugin doesn't just print a single log line—it registers listeners, registers tools, starts timers, and even opens network connections. That raises the question: when the plugin is unloaded, who cleans up these resources? Harness's answer is—registration goes through ctx, and cleanup goes through ctx too.
For some resources, the framework can track them automatically, because they are registered through ctx's public methods. But for other resources—a network connection, a file handle, an instance of a third-party SDK—the framework doesn't know your creation logic, so it can't automatically infer how to destroy them. This is where ctx.effect() comes in. Its usage is straightforward: ctx.effect takes a callback, and inside that callback you create the resource and return a disposer function, which runs when the plugin is unloaded.
// File path: scratch-plugin/src/heartbeat.ts
import type { Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
ctx.effect(() => {
// Create a timer: print heartbeat every 5 seconds
const timer = setInterval(() => {
console.log('heartbeat')
}, 5000)
// The returned cleanup function runs when the plugin is unloaded
// Equivalent to: no need to manually clearInterval in your unload logic
return () => clearInterval(timer)
})
}There are two roles in this code that need to be distinguished. The callback is responsible for "creating the resource"; it executes immediately when effect is called and returns the disposer. The disposer is the return value of that callback, and it describes "how to destroy the resource created this time"; the framework calls it when the plugin is unloaded. Focus on the semantics of the disposer: it isn't "just any cleanup function," but a destruction action strictly paired with this creation. If you created a timer, the disposer is responsible for clearInterval; if you opened a connection, the disposer is responsible for closing the connection and releasing the associated handles. This "creation and destruction appear as a pair" style turns resource management from "remember to write cleanup somewhere" into "declare how to clean up right where you create it," greatly reducing the chance of omissions.
Why does effect fill the gap left by automatic tracking? Because automatic tracking presupposes that "the registration action is seen by ctx," whereas effect is that explicit declaration entry point: you proactively tell the framework "this is the resource I created, and this is how it's destroyed." Any resource that can't be covered by the automatic tracking list should go through effect. There's a very practical rule of thumb in engineering: if you new an object, open a connection, or start a loop that isn't on ctx inside apply, then it most likely needs effect as a fallback. Conversely, if you're only calling ctx's registration methods, you don't need to wrap another layer of effect, to avoid duplicate declarations.
Fiber Scope Accounting: Why You Don't Need removeListener on Unload
Now let's answer the question from the beginning: why can you skip writing cleanup code? The core mechanism is the Fiber scope. The documentation explains it this way: the framework can clean up automatically because all registrations made through ctx are recorded in the plugin's Fiber scope; on unload, the framework revokes them in reverse order of registration. That sentence carries a lot of information, so let's break it down piece by piece.
First, "all registrations made through ctx". Note the qualifier "through ctx". Event listeners registered via ctx.on, tools registered via ctx.tools.register, adapters registered via ctx.llm.registerAdapter, and resources registered via ctx.effect all fall into this category. They share one common trait: the call happens on ctx, so the framework can record that registration at the very moment the call occurs. Conversely, if you bypass ctx and call addListener on some global object directly, the framework can't see that record, and naturally has no way to clean it up.
Second, "recorded in the plugin's Fiber scope". A Fiber can be understood as the execution context of a plugin instance, carrying its own ledger that records every revocable action the plugin has performed over its lifetime. Each entry contains both "what to call when revoking" and ordering information. Binding records to a Fiber rather than globally yields a very practical property: the unload granularity is plugin-level. When a plugin is unloaded, the framework only needs to process the records in that Fiber, without accidentally affecting resources registered by other plugins. Even if two plugins register tools with the same name, unloading one of them only revokes the registration belonging to it.
Third, "revoke in reverse order of registration". This is a very classic resource management principle, akin to a stack's last-in-first-out. Later-registered resources may depend on earlier-registered ones, so revocation must go the other way: tear down the later registrations first, then the earlier ones, otherwise you end up with a dangling state where "the dependency is already gone but the consumer is still around". As a concrete example, if your plugin first registers a tool and then creates a timer via effect to periodically call that tool, the correct order on unload is: stop the timer first, then revoke the tool registration. Reverse-order revocation naturally satisfies this, whereas with forward order, the timer might access an already-revoked tool on some future tick, producing hard-to-diagnose errors.
Putting these three points together, you can understand where the confidence to "not need manual removeListener or clearInterval" comes from: the registration action is recorded by ctx → the record enters the plugin's Fiber scope → on unload, revocation happens in reverse order. You write ctx.on(...), and the framework handles cleanup on unload; you write ctx.effect and return a disposer, and the framework calls your disposer on unload. Throughout this entire chain, the only thing you need to do proactively is hand resource creation over to ctx or effect, rather than opening your own unmanaged shadow registry.
Automatic Tracking Checklist: Unload Behavior of ctx.on / ctx.tools.register / ctx.llm.registerAdapter / ctx.effect
Finally, let's turn "which operations are automatically tracked and cleaned up" into an item-by-item checklist. The table below is one of the parts of this article most worth memorizing, because it directly determines whether you need to write extra code when a plugin is unloaded:
| Registration Operation | Behavior on Unload |
|---|---|
| ctx.on(event, handler) | Event listener automatically removed |
| ctx.tools.register(tool) | Tool registration automatically revoked |
| ctx.llm.registerAdapter(names, adapter) | LLM adapter registration automatically revoked |
| ctx.effect(() => cleanup) | Executes the returned disposer cleanup function |
Let's interpret the engineering implications of this table item by item. First row, ctx.on(event, handler): event listeners are one of the most easily forgotten resources. In the traditional approach, you must write on and removeListener in pairs, otherwise reloading the plugin repeatedly will cause the same event to be responded to multiple times. In Harness, you only write ctx.on, and the listener is automatically removed on unload, fundamentally eliminating the classic bug of "listener leaks causing the handler to be called multiple times." Second row, ctx.tools.register(tool): tool registration is automatically revoked, meaning that after the plugin is unloaded, the model will no longer route requests to this now-nonexistent tool, avoiding "ghost calls" where "the tool is dead but the route still exists." Third row, ctx.llm.registerAdapter(names, adapter): model adapter registration is automatically revoked, which is especially suitable for plugins that provide experimental adapters—mount, test-run, unmount, without worrying about the adapter lingering in the global registry and polluting subsequent requests. Fourth row, ctx.effect(() => cleanup): the framework does not infer the destruction logic for you, but because you returned a disposer in the callback, the framework will call it on unload. This is equivalent to writing cleanup manually, except the location moves from the "unload hook" forward to the "creation site."
This table also draws a boundary: the precondition for automatic cleanup is "registering through ctx" or "declaring through effect." Resources outside the checklist—bare setInterval, bare setTimeout, self-established network connections, third-party SDK instances, global caches—all require you to proactively use ctx.effect as a fallback, or simply switch to doing things through the equivalent capabilities provided by ctx. This is also the engineering discipline this article most wants to convey: don't ask "what should be cleaned up on unload," but ask "did I hand it over to ctx or effect when I created it." The former is after-the-fact remediation; the latter is upfront governance. In systems where plugins are repeatedly loaded and unloaded, upfront governance is the maintainable solution.
Continuing down this checklist, there is a deeper question: if two plugins both depend on the same service, what happens when one of them is unloaded? If the plugin providing the service is unloaded before the consumer, will the consumer get an already-invalid ctx.
In the previous section, we thoroughly unpacked the accounting mechanism of inject dependency declarations and Fiber scope, and also explained the registration semantics of ctx.effect. In this section, we zoom in on real plugins: when listeners, tools, and timers begin to appear in a plugin, what exactly happens at the moment of unload, and who pays the bill.
Heartbeat Timer in Practice: How setInterval and clearInterval Are Taken Over by the Disposer
Recall the minimal plugin mentioned at the end of the previous section—it only prints a single log line, finishes as soon as it loads, and has no side effects to clean up on unload. But as soon as a plugin gains any real functionality, the situation changes immediately. Consider the most classic scenario: we need a background heartbeat that prints heartbeat to the console every 5 seconds, to confirm the plugin is still alive and the event loop isn't blocked. The intuitive approach is to call setInterval directly inside apply:
// Anti-pattern: don't write it this way
export function apply(ctx) {
const timer = setInterval(() => console.log('heartbeat'), 5000)
// timer is captured by the closure, but no code clears it on unload
}
This code runs, but it has a hidden resource leak: once the setInterval handle is captured by the closure, if the plugin is unloaded, the framework has no reference pointing to this timer, so naturally there's no way to clean it up. The timer keeps firing every 5 seconds, and the console.log in the callback keeps printing. Worse, if the callback references other objects internal to the plugin, those objects can't be reclaimed by GC because of the closure reference chain, resulting in permanent memory residency. In a development environment with frequent hot reloads, repeatedly loading and unloading the same plugin accumulates dozens of zombie timers, the console gets flooded with heartbeat, and troubleshooting becomes extremely disruptive.
The correct approach is to hand the resource creation action to ctx.effect, letting the framework keep the books for us. Look at the following TypeScript that can be pasted and run directly:
// File path: scratch-plugin/src/heartbeat.ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'heartbeat-plugin'
export function apply(ctx: Context) {
ctx.effect(() => {
// Create the timer: print heartbeat every 5 seconds
const timer = setInterval(() => {
console.log('heartbeat')
}, 5000)
// The returned cleanup function runs when the plugin is unloaded
// Equivalent to: no need to manually clearInterval in your unload logic
return () => clearInterval(timer)
})
}
The only difference between this code and the anti-pattern is that it explicitly expresses the pairing between creation and destruction: the callback is responsible for creation, and the return value is responsible for destruction. When the plugin loads, the framework executes the ctx.effect callback, gets the return value (a function), and stores it in the current plugin's Fiber scope; when the plugin unloads, the framework traverses this scope and calls each disposer in reverse order of registration, so clearInterval(timer) gets executed, the timer handle is released, the callback no longer fires, the closure reference chain is broken, and the memory can be reclaimed normally.
There's a pitfall that's easy to fall into here: don't perform asynchronous creation actions inside the ctx.effect callback. This is because the framework needs to obtain the disposer when the callback returns synchronously. If you await halfway through the callback to get a connection object, the return value might be undefined, and the disposer is lost. The correct approach is to create the handle synchronously first, and put asynchronous initialization outside the effect or in a separate async flow paired with a manually registered cleanup entry point.
Another engineering detail worth explaining: ctx.effect can be called multiple times, and can also be mixed with ctx.on and ctx.tools.register. The Fiber scope is torn down in reverse order on unload, which means the last-registered effect is cleaned up first. This order matters when there are dependencies between resources—for example, a database connection established first and a subscriber registered later; reverse-order cleanup ensures the subscriber unsubscribes before the connection closes, avoiding errors during cleanup.
The semantics of a disposer: it describes how to destroy the resource created this time
When many people first encounter ctx.effect, they confuse it with an ordinary event callback, thinking, "I passed in a function, isn't that just a callback?" The concepts need to be pried apart here: an ordinary callback describes "what to do when something happens," whereas a disposer describes "how to destroy the resource created this time." The two belong to entirely different semantic categories.
The three key properties of a disposer are worth committing to memory one by one:
- It must be the return value of the ctx.effect callback. It is not an arbitrary function, not a function defined elsewhere and passed in, but rather "the destruction logic corresponding to the resource created during this particular execution of effect." Even if you import a cleaner from a utility function, you must still return it from within the callback for the framework to recognize it.
- It is only called by the framework when the plugin is unloaded; while the plugin is running normally, the framework will not invoke it on its own. In other words, a disposer is a pure unload hook, so you can safely put things in it that should only happen on shutdown, such as stopping services, disconnecting, or flushing to disk.
- It is naturally in the same closure as the creation action, so it can capture local variables obtained at creation time (such as the timer in the example above). You do not need to attach the handle to a global singleton or to some field on ctx; the closure is a natural channel for association, which is also why it is recommended to write creation and destruction as a pair.
If you do not return this function, or return a non-function value (such as returning undefined or returning a string), the framework cannot establish a cleanup entry point for this effect. The rule of thumb is: if ctx.effect creates any "handle that needs to be closed," it must return a paired disposer, whether it is a setInterval, a WebSocket, a file descriptor, a child process handle, or a subscription list you maintain yourself.
There is also an advanced usage worth pointing out: the disposer itself can be an async function, that is, return async () => { await conn.close() }. The framework will execute it, but pay attention to the timing of the unload process—if elsewhere in the project there are synchronous timing assumptions about unload completion (for example, in tests, asserting immediately after awaiting unload that the resource has been released), an async disposer may introduce a race condition, and you need to explicitly await the unload completion signal in tests.
From Printing a Log Line to a Real Plugin: Who Cleans Up After Registering Listeners, Tools, and Timers?
Let's bring the perspective back to that minimal plugin mentioned at the start of the previous section—the one that "prints a single log line and finishes." It serves as a pedagogical starting point, but real plugins almost never look like this: a proper plugin typically registers event listeners (ctx.on), inserts tools callable by the Agent into the tool registry (ctx.tools.register), registers an LLM adapter (ctx.llm.registerAdapter), and adds its own scheduled tasks or background polling (managed via ctx.effect). Each of these actions leaves behind a revocable registration point on the framework side.
The crux of the matter lies in the shift of responsibility ownership. In traditional plugin architectures, registration and cleanup are a pair of symmetric operations maintained manually by the plugin author: if you addEventListener in onLoad, you must removeEventListener in onUnload; if you setInterval, you must clearInterval. Missing cleanup is one of the most common bug categories in such architectures, and it is extremely difficult to surface during development—because everything works fine as long as you don't unload the plugin. It only manifests during hot reload, dynamic disabling, or when test cases repeatedly mount and unmount.
Harness's approach is to reclaim this entire set of symmetric responsibilities into the framework: any registration performed through ctx is recorded into the current plugin's Fiber scope, and the framework uniformly reconciles them upon unload. The tracking scope given in the source material includes:
- Event listeners registered via
ctx.on(event, handler)are automatically removed upon unload, with no need for manual removeListener. - Tools registered via
ctx.tools.register(tool)have their registration automatically revoked upon unload. - LLM adapters registered via
ctx.llm.registerAdapter(names, adapter)have their registration automatically revoked upon unload. - Resources created within
ctx.effect(() => cleanup)have the returned disposer cleanup function executed upon unload.
These four categories cover the vast majority of plugin resource forms. In other words, as long as plugin authors develop one habit—if you want to register something, go through the hooks provided by ctx; for resource creation outside those hooks, proactively declare it via ctx.effect—then cleanup code can essentially disappear from plugins. The value of this habit is not in saving a few lines of clearInterval, but in transforming "resource lifecycle correctness" from a factor that depends on human memory and caution into an invariant guaranteed by the framework's structure. Cleanup no longer relies on self-discipline; it relies on mechanism.
Two files under the scratch-plugin directory: the difference in dependency declarations between my-tool-plugin.ts and heartbeat.ts
The source material provides two representative example files, and they happen to represent two different ways of handling "external requirements" in plugin development, making them worth comparing side by side. The first is scratch-plugin/src/my-tool-plugin.ts, which needs to insert a tool into the tool registry, so it must declare a dependency on the tools service; the second is scratch-plugin/src/heartbeat.ts, which only needs its own timer and does not depend on capabilities provided by any other plugin, so it does not need inject and only needs ctx.effect.
// File path: scratch-plugin/src/my-tool-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-tool-plugin'
// Declare dependency: needs the tools service
export const inject = ['tools']
export function apply(ctx: Context) {
// By the time execution reaches here, ctx.tools is guaranteed to be ready
ctx.tools.register(/* ... */)
}
By comparison, my-tool-plugin.ts has an extra line, export const inject = ['tools']. This line is the contract between it and the framework: after reading this field, the framework defers the plugin's apply execution until the tools service is confirmed ready. As a result, the plugin function body can safely call ctx.tools.register directly, without writing any null checks, retries, or ready checks. heartbeat.ts, on the other hand, has no inject, because the setInterval it uses is a native runtime API, not a capability provided by another plugin. The framework has no dependency to wait for, so apply can execute immediately; the only thing it needs to tell the framework is "how this timer is destroyed," which is handled by ctx.effect.
This difference can be summarized in one sentence: inject solves "waiting for others" (when the service I need becomes available), while ctx.effect solves "managing myself" (when the resources I create are destroyed). The two are orthogonal and can be combined arbitrarily. A plugin that both depends on the tools service and starts a timed poll will write both inject and ctx.effect, and the two do not conflict with each other.
| Dimension | my-tool-plugin.ts | heartbeat.ts |
|---|---|---|
| Declaration field | export const inject = ['tools'] | No inject declaration |
| What the framework waits for before apply | Waits for the tools service to be ready | No waiting required, executes immediately |
| Type of resource created | Tool registration entry (managed by the framework) | Timer handle (custom resource) |
| Cleanup method | The inverse operation of ctx.tools.register is completed automatically by the framework | ctx.effect returns a disposer that executes clearInterval |
| Whether cleanup code must be written manually | No | A disposer must be written, but no unload event subscription is needed |
| Typical failure mode | Forgetting to write inject causes ctx.tools to be undefined | Forgetting to write return causes a timer leak |
It is worth noting that although heartbeat.ts requires a hand-written disposer line, its placement sits right next to the creation logic, forming a readable "create—destroy" paired structure; whereas the cleanup in my-tool-plugin.ts does not appear at all, because the registration action itself is a reversible bookkeeping operation provided by the framework. Together, these two forms constitute the complete picture of plugin cleanup responsibility.
Service Lookup Path: Why Other Plugins Do Not Directly Import Concrete Implementations
Let us dig one level deeper: since Plugin A needs the tools capability, why not directly import { tools } from './some-impl', instead of going around in a circle to inject inject: ['tools'] and then retrieve it from ctx? The answer to this question is the entire reason the service model exists.
Harness defines a service as a "named capability mounted on ctx," occupying a stable key such as ctx.tools, ctx.llm, ctx.agents, ctx.sessions. Other plugins look up services by key rather than obtaining a concrete class or object through import. This design yields several direct consequences:
- Replaceable implementation. Which specific implementation backs ctx.tools and which plugin provides it is of no concern to the consumer. As long as the interface contract remains stable, the framework can swap the tools implementation for another version—for example, injecting a mock tool runtime during testing and switching to an implementation with distributed tracing in production—without changing a single line of consumer code. If the consumer directly imports a concrete implementation, such replacement would require modifying code or build configuration, greatly reducing flexibility.
- Decoupled load order. The consumer does not need to know where the provider is or when it loads. The inject declaration is a declarative contract; the framework is responsible for arranging the load order on the dependency graph, and the consumer simply waits for apply to be called. Traditional approaches of manually writing load order and manually polling for readiness essentially push the responsibility of dependency graph management onto every plugin author, which inevitably leads to errors at scale.
- Aligned lifecycle. Services have a lifecycle—they belong to the provider plugin, and when the provider unloads, the service becomes invalid along with it. By looking up services through ctx, the consumer is naturally bound to the provider's lifecycle: if the provider is gone, the consumer is either unloaded in tandem or simply cannot load at all, avoiding the ghost state where "the consumer is still alive but the functionality it depends on has already disappeared."
- Naming as interface. A service occupying a stable key means that collaboration between plugins has clear boundary vocabulary. When plugin documentation states "this plugin provides the ctx.foo service, exposing methods bar/baz," other plugins can write consumer code accordingly. This key-centric approach to collaboration is coarser-grained, more stable, and better suited to a plugin ecosystem developed independently by multiple parties than importing an implementation class across packages.
The material particularly emphasizes one point that should be remembered as engineering discipline: the service names, public methods, and source locations of built-in services should be based on the information automatically generated by the repository into each service subsystem page; do not rely on any hand-written static service list. Hand-written lists become outdated, whereas generated pages and TypeScript interfaces are the authoritative source synchronized with the current code. This directly echoes the September 2026 practice discussed later.
Troubleshooting Checklist: What to Check When ctx.tools Is Unavailable in apply
The symptoms of unmet dependencies tend to be plain: apply runs, and the very first line accessing ctx.tools throws undefined, or reports some type error whose origin is unclear. These issues are not uncommon in real-world engineering. Working through the checklist below item by item will generally get you to the root cause.
- Is the inject declaration complete? The most common mistake is forgetting to write
export const inject = ['tools']. The framework relies solely on this field to decide whether to wait for a service. If you don't declare it, it won't wait, and apply may be executed before tools is ready. Note that this must be a constant exported (export) at the module's top level, not a local variable written inside apply—if it's inside the function, the framework can't read it. - Is the service name (key) spelled consistently? The strings in the inject array are the service keys and must match the actually registered service names exactly. Writing
'tool','Tools', or'toolRuntime'will not be recognized. Case sensitivity is the easiest thing to overlook during troubleshooting, because TypeScript won't correct you on string literals. - Is the plugin that provides the service actually loaded? inject only declares "I depend on it"; the premise is that someone actually provides this service. If the provider plugin upstream in the dependency chain isn't registered into the Harness, or is skipped because its own dependencies aren't satisfied, then the consumer's wait will never resolve. Check whether the provider is present in the plugin manifest and whether the provider's own inject is satisfied.
- Is the service in a ready state rather than still loading? What the framework promises is "execute apply only after the service is ready." If the provider plugin is asynchronously initializing the service (for example, connecting to a remote endpoint), apply will be deferred. This typically manifests as "delayed occurrence" rather than "undefined," but if the provider's initialization throws, the service may never reach the ready state and the consumer will hang indefinitely. Check the provider's status in the load logs to confirm whether it's stuck in initialization.
- Are you accessing ctx in the wrong scope? The availability of ctx.tools is bound to the plugin's Fiber scope. If you pass ctx to an external module and access it after the plugin is unloaded, or in a context with no injection relationship, you may get an invalidated scope. The correct approach is to complete registration within the apply scope and not hold a long-lived cross-scope ctx reference.
- Is the correct Context type imported at the type level? The example uses
import type { Context } from '@deepseek-ai/cordis'. If the type source is wrong, the compiler may fail to report an error even when you've genuinely written inject incorrectly, depriving you of static checks that could have saved you. Make sure the type definitions match the actual runtime framework version.
Condense this checklist into a single diagnostic mantra: first check whether the declaration exists, then whether the name is correct, then whether the provider is alive, and finally whether it's ready. Check in this order, and the vast majority of "can't get ctx.tools" problems will fall into one of these categories.
Latest Practice as of September 2026: Plugin Development Based on Generated Service Interfaces and inject Declarations
Fast-forward to September 2026, and this mechanism has crystallized in practice into a clear recommended path, the core of which can be summarized as: "treat the generated authoritative interfaces as the single source of truth, use inject to declare consumer-side dependencies, and use ctx.effect to declare custom resources."
The so-called "generated authoritative interfaces" refer to the subsystem pages for each service that are automatically generated by the repository, covering service names, public methods, and source code locations. The material explicitly states that the service names, public methods, and source code locations of built-in services are all automatically generated by the repository into each service's subsystem page, and that when developing plugins, developers should rely on these generated sections and the service's TypeScript interfaces as the authoritative reference. The importance of this discipline lies in the fact that the plugin ecosystem will evolve, services will add and remove methods, and a hand-written "list of services I know about" will almost inevitably become incorrect information after some version. Generated pages share the same source as the code, and interface definitions are constrained by the type system—together they constitute dependency facts that cannot lie.
When it comes down to everyday development actions, the recommended approach is as follows:
- When writing consumer code, first open the corresponding service's generated page and TypeScript interface to confirm the exact spelling of the service key, the signatures of public methods, and parameter and return types—rather than writing based on memory or outdated documentation found online.
- Export the inject array at the top level of the plugin, listing all the service keys used. For multiple dependencies, list multiple keys. This single line constitutes the entire contract between the consumer and the framework; get it right, and the framework handles everything else.
- Use ctx.<key> directly inside apply, without null checks, without delayed polling, and without hand-written ready checks, because the framework already guarantees readiness. If you do get undefined, go back to the troubleshooting checklist in the previous section rather than adding defensive fallbacks in the code to paper over the problem.
- Anything registered through ctx (on, tools.register, llm.registerAdapter) requires no cleanup code—leave it to the framework to undo in reverse order.
- Any self-created resources outside ctx's tracking scope (timers, connections, handles) must be wrapped with ctx.effect and synchronously return a disposer, letting the framework take over the destruction timing. Cultivate the habit of "declare upon creation," and isolated manual cleanup logic will never appear in your code.
- When upgrading the framework, prioritize reviewing the diff of the generated pages—changes to service interfaces will be reflected there. Adjust inject and call sites accordingly; this is far more reliable than guessing from the changelog.
The value of this practice is not just that it makes individual plugin code shorter, but that it makes the entire plugin ecosystem composable: any two plugins are coupled only through stable keys and declarative contracts, load order is sequenced by the framework, lifecycle is aligned by the framework, and cleanup is reconciled by the framework. Plugin authors can then focus their attention on the business logic itself, rather than being scattered across the details of dependency orchestration and resource aftercare. This is precisely the full meaning of the phrase in the title—the reason you can omit cleanup code is that cleanup responsibility has been structurally transferred to ctx, not because everyone is lazily skipping it.
Summary and Best Practices
Here is the entire article's key points condensed into a checklist you can stick right on your desk:
- Depend on others, declare with inject: At the plugin top level,
export const inject = ['tools']. The framework guarantees the service is ready when apply runs; the service key must match the actual service name character for character. - Manage your own resources with ctx.effect: Create resources inside the callback and synchronously return a disposer; pair setInterval with clearInterval, a connection with close, and a subscription with unsubscribe.
- A disposer is a return value, not an ordinary callback: It is only called by the framework when the plugin unloads, shares a closure with the creation action, and can naturally capture the handle.
- Framework-reversible registrations need no manual cleanup: ctx.on, ctx.tools.register, and ctx.llm.registerAdapter are automatically undone on unload, executed in reverse registration order.
- Look up services by a stable key, don't import concrete implementations: This buys you four benefits—swappable implementations, decoupled load order, aligned lifecycles, and naming as the interface.
- Troubleshooting order when ctx.tools is unavailable: Is it declared? → Is the key correct? → Is the provider alive? → Is it ready? → Is the scope correct? → Is the type source correct?
- The authoritative source as of September 2026 is the generated pages and TypeScript interfaces. Don't rely on hand-written static service lists; when upgrading, prioritize the diff of the generated blocks.
- One discipline to wrap up: For any handle that needs closing, either go through a ctx entry point or declare it with ctx.effect; self-built resources outside these two are leak candidates.