When doing Agent development in DeepSeek Harness (hereafter referred to as dsh), what truly determines "what the model can do" is not how fancy your prompts are, but whether you have defined your tools well. The three most underrated things in tool definitions are: parameter schema, approval decisions, and the tool pipeline. The parameter schema determines whether the model can correctly produce invocation parameters, approval determines whether a given invocation should be allowed through or intercepted, and the pipeline determines which checkpoints lie between "the model issues an invocation" and "the result returns to the model," and who has the final say at each checkpoint. As part 1 of a two-part series, this article lays the foundation first: using defineTool to write your first runnable tool, greet, from scratch, dissecting field by field the responsibility boundaries of name, description, parameters, output.schema, output.render, and execute; then moving into dsh's tool execution pipeline, explaining in one pass the six fixed stages in order—tools/pre-execute → monotonic guard → tools/execute → tools/post-execute → finalizeContent → tools/result—and clarifying the difference between next() and short-circuit returns in waterfall-style event dispatch. After reading, you should be able to answer a concrete question: after the model issues a single tool invocation, what actually happens in between, and at which link in this chain can I intervene.
Tools as functions: how the name/description/parameters trio of defineTool is consumed by the model
Let's first establish the most plain and also most accurate understanding: a tool is just a clearly described function. It has a name, a description, parameters, and an output format. This sounds like a truism, but it clarifies the positioning of a tool—a tool is not "plugin metadata for the framework to read," but rather "a function signature for the model to read." In dsh, when the model generates a response, it reads the tool definitions you have registered, and then based on that information decides whether to invoke, which one to invoke, and what parameters to pass. So a tool definition actually has two audiences: one is the runtime, which uses the definition for validation and dispatch; the other is the model, which uses the definition for decision-making. The design goal of defineTool is to let a single definition satisfy the needs of both audiences at once.
defineTool accepts an object that describes all the information about the tool. The three most core fields are name, description, and parameters. They look unremarkable, but each carries a completely different amount of information and set of failure modes, and each deserves to be unpacked individually.
name is the unique identifier of the invocation, and also the string the model uses when issuing an invocation. It carries the least information, but has the highest requirements for uniqueness and readability. In engineering terms, three points require attention. First, the name must be unique within the same registry; duplicate names directly break the dispatch logic, because the invocation returned by the model contains only the name and no other identity information. Second, the name should be a readable verb-object structure or domain term, rather than an internal abbreviation—for example, greet is better than g1, and fs_write is better than w—because the model relies on names for semantic matching when selecting tools, and a vague name significantly raises the mis-invocation rate. Third, the casing and separator style of names should be globally consistent; mixing styles like greet, Greet, and greet_tool will make the model's selection behavior unstable once the number of tools grows.
description is a natural-language explanation of "when to use this tool," and it is the field in the trio with the highest information density and the one most worth polishing. The example in the source material reads 'Greet someone by name.', which conveys the action and the target in a single sentence. But in a real project, description should carry more responsibility: explaining what the tool does, what it does not do, in which scenarios it should be preferred, and which edge cases it is not responsible for. The reason is that a model's tool selection is almost entirely based on semantic matching of name + description; parameters mainly affect "how to fill in the arguments" rather than "whether to choose this tool." A common pitfall is writing an overly generic description, such as just "query data," which causes the model to flip-flop among three different query tools. The solution is to explicitly write distinguishing statements like "use for scenario X, do not use for scenario Y" in each description, making the boundaries between tools explicit.
parameters is the input schema, and defineTool uses it to infer and validate args. This is the only field in the trio that faces both the model and the runtime: the model reads it to understand the type and meaning of each parameter, and the runtime reads it to perform parameter validation and type inference. Its design requirement is a "typed definition," meaning each parameter is given its own type and description rather than throwing in a free-form object. The next section will specifically expand on the details of parameters.
Looking at the three fields together, their division of labor can be summarized in a table:
| Field | Primary audience | Information it carries | Common failure mode |
|---|---|---|---|
| name | Runtime + model | Unique identifier, invocation entry name | Name collisions, ambiguous naming leading to misselection |
| description | Mainly the model | Applicable scenarios, capability boundaries, priority | Too generic, indistinguishable among multiple tools |
| parameters | Runtime + model | Parameter types, requiredness, parameter meaning | Free-form object, validation is effectively useless |
This table is worth revisiting repeatedly. Many problems of "the model calling tools randomly" are not rooted in the model's capability, but in insufficient information supplied by these three fields. A tool definition is essentially API documentation written for the model to read; if the documentation is poorly written, the calls will naturally be chaotic.
ctx.tools.register and inject: ['tools']: the minimal prerequisite for hooking greet into the tool registry
Now that the definition is written, the next step is to hook it into dsh's tool registry. In dsh, tools are registered via ctx.tools.register. But there is a prerequisite here: your plugin must declare a dependency on the tools service, otherwise the ctx.tools namespace does not exist at all, and calling register will fail directly.
The approach in the source material looks like this:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
// 需要 tools 服务:注册工具的前提
export const inject = ['tools']
export function apply(ctx: Context) {
// 注册一个名为 greet 的工具
ctx.tools.register(defineTool({
// ...
}))
}There are three mechanisms in this code that you must understand.
The first is inject. export const inject = ['tools'] declares that this plugin depends on a service named tools. Dependency injection in plugin frameworks like Cordis is explicit: only when inject is declared will the framework prepare the tools service and attach it to ctx before apply is called; without that declaration, ctx.tools is undefined. This has a direct engineering consequence—you cannot secretly register a tool in a plugin that has not declared the dependency; the infrastructure layer closes off that path entirely. When publishing a plugin, if you forget to write inject, the most common error is a TypeError caused by ctx.tools being empty, and the fix is simply to add that declaration line.
The second is apply(ctx). export function apply(ctx: Context) is the entry point when the plugin is activated. The registration action is placed inside apply rather than at the top level of the module because top-level side effects execute earlier than the framework finishes assembling services; at that point ctx does not yet have the tools service, so registration is bound to fail. Putting all operations that depend on ctx inside apply is the most basic and most important discipline in frameworks of this kind.The registration location determines the registration timing, and the registration timing determines whether the service is already ready.
The third is the return semantics of registration. ctx.tools.register accepts a tool definition already wrapped by defineTool and registers it into the registry. Note that the call form is ctx.tools.register(defineTool({ ... }))—defineTool is wrapping/construction, and register is registration. The benefit of this two-layer structure is that the object produced by defineTool is a pure, reusable definition, while the registration action itself is a separate matter, so the two are decoupled. In engineering terms, you can extract the definition into a separate file and export it, register it conditionally in multiple apply calls, or perform boundary tests for repeated registration of the same definition in tests.
One more thing worth pointing out: export const name = 'greet-tool' — this name is the plugin name, not the tool name. The tool name is name: 'greet' inside the defineTool object. The two namespaces are completely different, and beginners easily conflate the plugin name with the tool name, then stare blankly at the two strings greet-tool and greet showing up in the logs while debugging. The plugin name is used by the framework to identify and manage the plugin, while the tool name is used by the model to initiate a call. Be sure to keep the semantics of both clear and non-conflicting when naming them.
parameters is the input schema: how defineTool derives and validates args from it
parameters is the "heaviest" field in defineTool, because it is a typed definition — defineTool derives the type of args from it and performs validation at runtime. The example from the material is:
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
}Field by field: type is the parameter's type; declaring it as string here means the parameter value must be a string; required indicates whether it is mandatory — when true, the model must supply this parameter, otherwise the call will fail validation; description is the parameter description, and its primary reader is the model — the model relies on this sentence to understand what content this parameter should contain. Together, the three form a complete parameter contract: the type determines whether it can pass validation, the required flag determines whether omission is legal, and the description determines what the model will fill in.
The most critical sentence here is: "defineTool derives and validates args." This sentence contains two layers of meaning that must be clearly distinguished.
The first layer is derivation, which happens at development time (at the type level, for TypeScript). defineTool derives the static type of args inside execute from the parameters declaration, so when you write args.name, the editor provides autocompletion, and a wrong field name is flagged in red immediately. This is a typical approach of treating the parameter definition as the single source of truth: you write parameters once, and the type is derived from it — no need to hand-write an interface again and pray the two don't drift apart. Many teams use a dual-maintenance approach for tool definitions — "hand-written interface + hand-written validation function" — which inevitably ends up with inconsistencies such as the type saying required while the validation says optional.
The second layer is validation, which happens at runtime. The arguments a model produces for a call are essentially untrusted input—the model may well omit fields, pass the wrong types, or stuff in irrelevant fields. defineTool performs validation based on parameters, blocking non-conforming calls before they reach execute. This means the args inside execute have already been validated, which is a very important engineering guarantee: inside execute you can safely use args.name as declared, without having to repeat defensive code like "if (!args.name) return error" inside every tool.
There are a few practical pitfalls around parameter schemas worth expanding on.
- Don't use free-form objects to carry parameters. Writing parameters as "an arbitrary object" renders validation meaningless, and the model loses the constraints that guide how it fills in parameters, tending instead to dump in a pile of free text. The value of a typed definition lies precisely in its constraints; giving up the constraints is giving up the definition itself.
- description should say "what to fill in," not "what it is." Writing just "name" is not enough—the model needs to know whether it's "the user's full name," "the user's nickname," or "the system ID." A slight difference in granularity leads to a big difference in the value produced.
- Parameters are frozen before they enter the policy layer. This point is stated explicitly in the second paragraph of the source material: parameters cannot be rewritten, because the history record, auditing, UI, and execution must stay consistent. In other words, later stages of the pipeline can see the parameters but cannot tamper with them. Any requirement involving parameter rewriting must be resolved before the call happens, rather than hoping to "just tweak it along the way" inside the pipeline.
- Requiredness is a hard constraint on the model. When a parameter with required set to true is missing, the call never reaches execute. So when designing a tool, you must clearly distinguish "information that must be provided by the model" from "default values that the tool can derive on its own": set required for the former, and leave the latter without required while filling in defaults inside execute.
The division of labor between output.schema and output.render: canonical values, display values, and model-visible content
Many people only care about input parameters when writing tools and ignore output parameters, with the result that the tool runs but is unusable. In its tool definition, dsh dedicates an output section to constraining output parameters, split into output.schema and output.render. The example in the source material is:
output: {
// 规范值类型:execute 的返回值
schema: { type: 'string' },
// render:把规范值转成面向模型的内容
render: (_args, value) => [{ type: 'text', text: value }],
},output.schema declares the type of the "canonical value", which is the type contract for the value returned by execute. In the example, it is declared as string, constraining execute to return a string. The canonical value is the anchor of the entire tool output system: it is the value that gets declared, validated, and frozen, and everything downstream is based on it. By analogy, the canonical value is like a strongly typed field in a database, while the rendered content is like a view oriented toward presentation. Distinguishing these two concepts is the prerequisite for understanding the latter half of the pipeline (especially the two accept decisions in post-execute).
The responsibility of output.render is to "convert the canonical value into model-facing content". It receives (_args, value), where value is the canonical value returned by execute, and returns a set of content blocks, in the example [{ type: 'text', text: value }], which is a single plain text block. The design intent here is clear:
- The canonical value faces the program: its type is explicit, it is validatable, and it can be safely consumed by upper-layer logic, making it suitable for auditing, metrics, and subsequent automated processing.
- The rendered content faces the model: what the model ultimately sees is the product of render, not the canonical value itself. Text blocks can carry human-readable wording, units, and context, while the canonical value can remain pure.
This separation brings a very practical benefit: the same tool can maintain a strict structure at the canonical value level (for example, returning a number) while wrapping it at the render level into a natural language sentence (for example, "The current temperature is 26 degrees Celsius"), so the model can understand it better. If these two things are mixed together—execute directly returning a string for the model to see—then the program side cannot reliably consume the result, and doing statistics, assertions, or secondary processing all becomes a nightmare of parsing natural language.
One warning from the pipeline must be especially remembered: content replacement is a presentation strategy, not a secrecy strategy. This means that if you process certain content in render, that only changes what the model sees; it does not mean the data is truly hidden. To hide programmatic values, you must replace the value itself or directly block the result, and this will be shown very clearly in the post-execute decision table.
The return value contract of execute: why greet returns only a string
execute is the implementation of the tool, that is, the place where the logic is actually executed. The implementation in the material is:
async execute(args) {
// 返回规范值,这里是一个字符串
return `Hello, ${args.name}!`
},It is only one line, yet it precisely demonstrates the three contracts of execute.
The first contract: execute returns the canonical value, not the final presentation content. greet returns the string Hello, ${args.name}!, which must conform to the string type declared by output.schema. As for what the model ultimately sees, that is render's job. This is exactly "the boundary between the implementation layer and the declaration layer"—the implementation layer is only responsible for producing a canonical value that conforms to the constraints of the declaration layer; how it is presented is not decided by the implementation layer. Mixing these two layers together is the most common structural mistake in tool definitions.
The second contract: args has already been validated. Because parameters declares name: { type: 'string', required: true }, args.name inside execute is guaranteed to exist and is guaranteed to be a string. This is why, if you grep through a real codebase, you will rarely see redundant validation of args inside execute—that validation has already been done at an earlier stage of the pipeline. Conversely, if you still write a large number of "what if the parameter is missing" branches inside execute, then it is very likely that parameters was not defined well, or that you have conflated business-level pre-validation with parameter validation. Business-logic validation (for example, "does this name exist in the database") of course still needs to be written inside execute, but it is a different matter from parameter validation.
The third contract: execute can be asynchronous. The example uses async and returns a Promise, which leaves ample room for IO operations inside the tool. The essence of a tool is "a function that the Agent uses to get work done," and the vast majority of real work requires IO: reading files, making requests, querying databases. Designing execute to be async-capable is the prerequisite for making tools truly practical.
Looking at the three contracts together, execute's positioning is very clear: it is a function that has already been fed valid parameters and only needs to focus on doing the work, producing a correctly typed canonical value. Parameter validity is handled by the schema, presentation form is handled by render, and execute is only responsible for the middle part—the actual business logic. The engineering benefit brought by this separation of responsibilities is: the implementation of the tool becomes extremely simple, while the constraints on the tool become extremely strong.
Replace scratch-plugin/src/my-plugin.ts with the first runnable tool
With the theory covered, putting it into practice requires only one action: replace the contents of scratch-plugin/src/my-plugin.ts with the complete code below. It is a minimal tool plugin that can be run directly.
// 文件路径:scratch-plugin/src/my-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
// 需要 tools 服务:注册工具的前提
export const inject = ['tools']
export function apply(ctx: Context) {
// 注册一个名为 greet 的工具
ctx.tools.register(defineTool({
// 工具名:模型会以这个名字发起调用
name: 'greet',
// 工具说明:告诉模型什么时候用
description: 'Greet someone by name.',
// 入参 schema:defineTool 会推导并校验 args
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
// 输出定义
output: {
// 规范值类型:execute 的返回值
schema: { type: 'string' },
// render:把规范值转成面向模型的内容
render: (_args, value) => [{ type: 'text', text: value }],
},
// 工具实现:真正执行逻辑
async execute(args) {
// 返回规范值,这里是一个字符串
return `Hello, ${args.name}!`
},
}))
}The implementation steps can be organized into a checklist—follow it and nothing gets missed:
- Locate the file: Open
scratch-plugin/src/my-plugin.tsand replace its entire contents. - Write the imports: Import the
Contexttype from@deepseek-ai/cordis(note it's an import type, since it's only used for type annotations), and importdefineToolfrom@deepseek-ai/dsh-tools. - Export name:
export const name = 'greet-tool'—this is the plugin name, used by the framework to identify the plugin. - Declare inject:
export const inject = ['tools']—this is the prerequisite for accessing ctx.tools. - Export apply: Inside apply, call
ctx.tools.register(defineTool({ ... }))to register the complete tool definition into the registry. - Fill in all six definition fields: name, description, parameters, output.schema, output.render, execute—don't omit a single one. Miss any of them, and either the tool becomes invisible or its output becomes unusable.
The other value of this code is that it ties together all the concepts covered earlier in this section into a single thread: inject and apply handle "mounting," name/description/parameters handle "how the model selects and fills it in," output.schema/output.render handle "how the result is presented to the model," and execute handles "doing the actual work." A 30-line file is the minimal closed loop of a complete tool.
tools/pre-execute → monotonic guard → tools/execute → tools/post-execute → finalizeContent → tools/result: the fixed order of a single call
Now for the second half of this section: once the model issues a tool call, what actually happens between that moment and the tool being executed with the result returned to the model? The answer isn't as simple as "just call a function"—it's a pipeline with a fixed order. The official documentation summarizes the order as: tools/pre-execute → monotonic guard → tools/execute → tools/post-execute → finalizeContent → tools/result.
These six stages each have clearly defined responsibilities, which can be understood as follows:
| Stage | Type | Responsibility | Can it rewrite this call |
|---|---|---|---|
| tools/pre-execute | waterfall | Hosts hooks, permissions, sandbox, and other reorderable policies | Yes (allow / deny / ask) |
| monotonic guard | guard | A final line of defense that only allows reduction, never revocation | Can deny, cannot restore to allow |
| tools/execute | around dispatch | Wraps the actual tool body invocation, handling timeouts, retries, and metrics | Can replace the required exec.signal |
| tools/post-execute | decision | Inspection or rewriting before result normalization | Yes (accept / block) |
| finalizeContent | definition's own callback | The final content-only invariant | Content-layer corrections only |
| tools/result | synchronous notification | Observes the authoritative result after freezing | Cannot rewrite, observation only |
There's a very key summary in the source material: the first three waterfalls can rewrite a call, while finalizeContent and tools/result, controlled by the definition itself, run afterward. This sentence draws the pipeline's main dividing line—the first half is the domain of policy and dispatch, where multiple plugins can coordinate or even overturn each other's decisions; the second half gradually converges toward "result finalization," ultimately freezing into an immutable authoritative result.
Memorize this order as one sentence: "pre-execute decides whether it can be done, execute decides how it's done, post-execute decides how the result is presented, and result is only responsible for taking a look at the final result." This sentence is a selection mnemonic—when you encounter "which extension point should I hook my requirement into," first ask yourself which category your requirement belongs to, and the answer will basically emerge.
One more common misconception to correct: a pipeline is not a loose collection of "hooks you can insert anywhere," but a chain with a strict ordering and a relationship of decreasing authority. The earlier a stage is, the greater its authority and the more malleable it is; the later a stage is, the less authority it has and the harder its constraints become. The purpose of this design is to layer "negotiable policy" and "immovable invariants" across time, avoiding the situation where a later-registered plugin quietly overturns a security decision that has already been made. Once you understand this gradient, you won't attach mandatory security constraints to the wrong stage when designing your own plugins.
waterfall and next(): how listeners delegate the decision or short-circuit the return
To truly make good use of the pipeline, you must first understand the waterfall (waterfall-style event) event dispatch pattern. It is not an ordinary event broadcast—an ordinary broadcast is "every listener runs once, each doing its own thing," whereas a waterfall listener holds a decision in its hands and can either pass it down or stop it on the spot.
Specifically, a waterfall listener has two exits:
- Call next() to delegate downward: after the listener has made its own judgment, it calls next() to hand the decision to subsequent listeners in the pipeline. If the current listener has no opinion, this is the standard approach. Multiple policy plugins are chained together via next(), forming a negotiable decision chain.
- Return a decision directly to short-circuit the entire chain: the listener directly returns a typed decision without calling next(), and this pipeline terminates there; subsequent listeners will not execute. Short-circuiting is a strong action—used well, it guarantees efficiency and safety; used poorly, it becomes an interception problem that is hard to troubleshoot.
tools/pre-execute is the first waterfall in the pipeline. It is responsible for carrying "hook, permission, sandbox"-type reorderable policies. The reason it is called "reorderable" is that a listener can pass the decision to the next listener via next(), and the order of multiple policy plugins can be adjusted in configuration. This stands in sharp contrast to the monotonic guards discussed later—the order of guards cannot change the direction of the result, because guards have no allow result.
pre-execute returns a typed decision, PreToolDecision, which has three possible values:
| Decision | Meaning | Subsequent behavior |
|---|---|---|
{ kind: 'allow' } | Allow this call | Continue through the monotonic guards and the stages after that |
{ kind: 'deny'; reason: string } | Deny this call | Materialize as an error result; the tool body is skipped |
{ kind: 'ask'; reason?: string } | Ask the user | Continue only if the approval service returns allowed-once; otherwise deny |
The design of these three decision tiers is quite deliberate. allow is the most common path for letting a call through; deny immediately terminates the call and materializes the rejection as an error result returned to the model—note that it's an "error result" rather than "returning nothing," so the model receives information that the call failed and thus has a chance to adjust its strategy; ask hands the decision to a human, triggering a one-time inquiry via ctx.approval, and only proceeds if the approval service returns allowed-once, otherwise it is rejected outright. The approval details involved in the ask branch will be covered in the next post; for now, just remember its semantics: ask is not "allow by default," but "deny by default unless the user explicitly grants one-time permission." The choice of this default value is crucial, as it determines the safe direction when approval fails.
When writing a pre-execute listener, there are several engineering points you must keep in mind.
- Arguments cannot be rewritten. The material explicitly states: arguments cannot be rewritten, because the history, audit trail, UI, and execution must remain consistent. This constraint exists to ensure that "the call you see" and "the call actually executed" are one and the same. Any policy that relies on tampering with arguments to work is a flawed design.
- next() is a delegation, not an optional decoration. If a listener allows a call through but doesn't call next(), subsequent listeners are silently skipped, which can easily cause security policies that should have taken effect to not take effect. Calling next() when allowing a call through should be a reflex-level habit.
- Be careful with short-circuit timing. Returning deny terminates immediately, and subsequent listeners no longer execute. In other words, if you place a high-priority deny up front, it gives no one else any chance to negotiate. This is usually correct (security first), but you should be clear that it's an intentional choice.
- When to use pre-execute. Use it when a policy needs one of the three action types—allow, deny, or ask—and you want policies to be freely orderable relative to one another. Plugins such as sandbox, permissions, and plan-mode all use this extension point.
To ground this mechanism in engineering practice, here's a ready-to-paste example of a permission-gate plugin that fully demonstrates the two exits of a waterfall (returning deny to short-circuit, and calling next() to delegate):
// File path: my-plugins/permission-gate/src/index.ts
// A permission-gate plugin based on tools/pre-execute.
// It returns typed decisions: deny on a blacklist hit, otherwise call next() to delegate.
import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
// Blacklist: tools that are forbidden from directly writing to the filesystem in the runoob project.
// A minimal set is used here for demonstration; a real project could query a database or ask an approval service.
const DENY_TOOLS = new Set(['fs_write', 'fs_edit'])
// Policy decision function: returns whether this call is allowed.
// exec carries the immutable call identity (callId, name, arguments, agent, token, signal).
async function isAllowed(exec: ToolExecution): Promise<boolean> {
if (DENY_TOOLS.has(exec.name)) return false
// Additional example: the runoob demo forbids modifying .env files (arguments are frozen before entering the policy).
const raw = exec.arguments as { path?: string }
if (typeof raw.path === 'string' && raw.path.includes('.env')) return false
return true
}
export const name = 'permission-gate'
export function apply(ctx: Context) {
// tools/pre-execute is a waterfall: listeners can return a decision or call next() to delegate.
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
// Returning deny immediately terminates this call, and subsequent listeners no longer execute.
return { kind: 'deny', reason: 'Denied by policy: this tool is not allowed in the runoob workspace.' }
}
// Allow: hand the decision to subsequent listeners in the pipeline.
return next()
})
}This code can serve almost as a template for "how to write a policy plugin." It demonstrates several key points: listeners are attached to tools/pre-execute via ctx.on; exec carries an immutable call identity, including callId, name, arguments, agent, token, and signal; policy decisions are asynchronous, because a real project may need to query a database or ask an approval service, so the decision function is declared async; when a policy is hit, it returns deny to short-circuit, along with an explicit reason string so the model and developers can pinpoint the cause; when allowing, it returns next(), passing the decision down the chain. These five points cover 90% of how policy-type plugins are written.
Also worth mentioning is the assertion exec.arguments as { path?: string }. It shows that arguments are frozen before entering the policy—you can only read them, not modify them. The policy can see the value of path, and it is precisely based on this visible value that it makes its judgment, so rules like "forbid modifying .env" can be implemented reliably. This detail in turn confirms the earlier principle: arguments cannot be rewritten, which is both a requirement for audit consistency and gives policy judgments a stable factual basis.
At this point, the foundation for Part 1 is laid: what a tool definition is, how to register it, how to declare parameters and outputs, what the six stages of the execution pipeline are, how decision-making authority flows through the waterfall, and what a genuinely usable permission-gating plugin looks like. Next, Part 2 will continue deeper into the pipeline—why the monotonic guard deliberately has no allow result, how tools/execute replaces the signal to impose a deadline, the difference between the two accept decisions in tools/post-execute: rewriting content versus rewriting values, what exactly finalizeContent—the "final content-only invariant"—is responsible for, and why tools/result can only observe and not transform. Once you understand these, you can truly step in where you should and let go where you should.
In the previous section, we finished breaking down the field structure of defineTool, the derivation mechanism of the parameter schema, and the complete path by which a tool is registered into the registry. But defining a tool is only the first step: after the model emits a tool call, there is still a fixed chain—officially called the tool execution pipeline—before the tool body actually runs and the result returns to the model context. In this section, we will break down this chain stage by stage and implement a genuinely usable permission-gating plugin.
PreToolDecision's three states: the behavioral differences of allow, deny(reason), and ask(reason?)
tools/pre-execute is the first waterfall in the entire pipeline, and it is also the primary place that carries policy. Its return value is not a boolean, but a typed decision: PreToolDecision. This point is crucial: a boolean has only two states, whereas in real engineering a policy often needs a third state—"I can't decide for you, go ask the user." This is exactly why ask exists.
The follow-up behavior of the three decisions differs as follows. Compare them one by one, and don't mix them up:
- { kind: 'allow' }: Allow this call. After a listener returns it, the pipeline continues downward, enters the monotonic guard, and then enters tools/execute. Note that allow does not bypass the guard—the guard can still intercept the call after allow.
- { kind: 'deny'; reason: string }: Deny this call. The registry will materialize this denial into an error result, meaning what the model ultimately reads is an error message with the reason, while the tool body is skipped entirely, and execute will not run at all. deny is an immediate termination: subsequent listeners no longer get a chance to execute.
- { kind: 'ask'; reason?: string }: Ask the user. This triggers a one-time inquiry flow of ctx.approval. Unlike an ordinary "popup confirmation," ask has stricter semantics: it continues only if the approval service returns allowed-once; any other result (denial, timeout, no response) is treated as a denial.
Putting these three states into a table for comparison makes the differences clearer:
| Decision | Meaning | Follow-up behavior | Whether it triggers the approval service |
|---|---|---|---|
| { kind: 'allow' } | Allow this call | Continue through the monotonic guard and subsequent stages | No |
| { kind: 'deny'; reason } | Deny this call | Materialize into an error result, and the tool body is skipped | No |
| { kind: 'ask'; reason? } | Ask the user | Continue only if the approval service returns allowed-once; otherwise deny | Yes, one-time |
It must be especially emphasized here that allow does not equal "already executed." Many beginners have an intuitive misconception when writing hooks: they think that if the first listener returns allow, everything is fine. In fact, allow only means "I don't object"; it hands the decision to whoever comes later in the pipeline. If there are still guards and other policy plugins afterward, the call may still be intercepted. Conversely, deny has extremely high priority: once returned, it short-circuits, and no one can save it.
As for ask, its typical use case is operations that are "destructive but reversible, and where the user clearly knows what they are doing"—for example, writing to a user-specified directory, or executing a shell command the user just dictated. Such operations are not suited to being blocked outright with a blanket deny, nor to being unconditionally allowed; handing them to the user for one-time confirmation is the most reasonable approach. Note the weight of the name allowed-once: it is permission for "this one call," not permission for "this tool," and certainly not permission for "this session." If you want a session-level allowlist, you have to maintain the state yourself inside the plugin—you cannot expect the approval service to remember it for you.
When should you use pre-execute? One sentence to decide: use it when your policy needs one of the three actions—allow, deny, or ask—and you want policies to be freely orderable among themselves. The sandbox, permission, plan-mode, and other plugins in the official ecosystem all use this extension point.
Why arguments cannot be rewritten: history, audit, UI, and execution must stay consistent
This is a design constraint that is easy to overlook but painful once you trip over it: during the tools/pre-execute stage, arguments cannot be rewritten. You can read exec.arguments, but you cannot modify it.
Why? Starting from the consistency constraint, arguments are the "fact" of this call. They are observed simultaneously by at least four consumers:
- History: what the session transcript records is the call arguments as originally emitted by the model. If some policy secretly changes the arguments, the history no longer matches the actual execution, and replay becomes distorted.
- Audit: the audit system needs to answer "what did the model actually ask to do." If the arguments are changed midway, does the audit log record the post-change value or the original value? Either choice is wrong—recording the original value makes it inconsistent with execution, while recording the post-change value conceals the model's true intent.
- UI: the interface shows the user "the Agent wants to call fs_write, with path ./a.txt." If a policy swaps the path to ./b.txt and the UI does not sync, what the user sees and what actually happens are no longer the same thing.
- Execution: the arguments the tool body receives must be the single copy agreed upon by history, audit, and UI.
To align these four, the only solution is to freeze arguments. As mentioned earlier, exec carries an immutable call identity, specifically including fields such as callId, name, arguments, agent, token, and signal. These are the ID card of this call: callId is the unique number, name is the name of the tool being called, arguments is the argument snapshot, agent identifies the initiator, token links to the authorization context, and signal is the carrier of the cancellation signal.
So you run into a very typical engineering problem: the parameters have already been frozen before the policy runs, so what do you do if you want to perform "parameter-level validation"? The answer is that you can only read, not modify, and express your stance through the decision result. For example, if you find that a path contains .env in a permission gate, what you should do is deny, not "rewrite the path and then let it through." The latter would break the four consistency constraints above.
For the need to "correct parameters," the correct outlet is the block branch of tools/post-execute—after the tool has run, use feedback to hand the correction back to the model, and let the model re-initiate the call itself. This way the parameter change happens in the model's next round of decision-making, and the history remains self-consistent.
ctx.tools.guard() and ToolGuard: a monotonic guard with no allow result
The flexibility of waterfall comes at a cost, and that cost is called "being overridable." Once a later-registered listener returns allow, the deny from an earlier listener is bypassed. In the vast majority of policy scenarios this is fine, because policies are inherently orderable and negotiable. But there is one class of requirements that does not play by these rules: invariants.
The requirement of an invariant is "final rejection, and no one can revoke it." This is when you bring out ctx.tools.guard() to register a ToolGuard. Its type signature is very expressive:
// ToolGuard: scope-aware final pre-dispatch policy
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
Please focus your attention on the return type: string | undefined. Returning a string means rejection, and the string itself is the reason for rejection; returning undefined means maintaining the status quo. There is deliberately no allow result here.
What does the absence of allow mean? It means listeners cannot "overturn" each other. You cannot revoke an earlier guard's rejection by registering a new guard and returning some kind of "allow"—because that kind of return value simply does not exist. This is the meaning of the word monotonic: only subtract, never add; only restrict permissions, never grant them. Permissions can only be continuously narrowed along the guard chain, and can never be loosened.
The Readonly<ToolExecution> here is also worth mentioning. What the guard receives is a read-only view; it has no ability to modify parameters, nor to modify the call identity. The only thing a guard can do is one thing: determine whether this call violates an invariant, and if it does, reject it with a reason.
In practice, the selection rule is quite straightforward:
- Reorderable policies → put them in tools/pre-execute. For example, business-level rate limiting, canary rollouts, and scenario-specific permissions—the order of these policies may change with configuration.
- Invariants that must ultimately take effect and cannot be revoked → put them in ctx.tools.guard(). For example, red lines like "under no circumstances may system directories be written to" or "under no circumstances may keys be sent externally."
A common engineering pitfall is writing red lines into pre-execute. Because pre-execute is a waterfall, a subsequent plugin returning allow can bypass it, rendering the red line useless. Conversely, putting orderable business policies into guard is also inappropriate—guard has no allow, so all guards can only either "abstain" or "deny." You cannot use it to express "I approve this scenario." The two types of mechanisms each have their own role; don't mix them.
tools/execute's around-dispatch: ToolDispatchExecution and the replacement rules for exec.signal
After passing through the policy layer and the guard layer, the call is finally about to actually execute. tools/execute is responsible for "around-dispatch"—wrapping the actual invocation of the tool body. It is called "around" because the execution context provided by this extension point allows you to do a layer of work both before and after the body.
What kinds of things are suitable to do at this layer? Timeouts, retries, and metric collection—these three are the standard residents. The reason is intuitive:
- Timeouts need to impose a deadline on the call, which is essentially manipulating the signal, and only this layer can do that.
- Retries need to know whether the body failed and how many times it failed, requiring a loop wrapped around the body.
- Metric collection needs to measure the body's duration and count successes and failures, and must also time things outside the body.
The view available at this layer is ToolDispatchExecution. Only this one view can replace the required exec.signal, in order to impose a deadline. The replacement rules are quite particular, so please remember them:
It can be replaced, but not removed; the registry re-merges the caller's signal before invoking the tool body.
This sentence needs to be understood in two halves. The first half, "can be replaced," means you can override the current signal with one that carries a deadline, thereby achieving timeout control. The second half, "cannot be removed" plus "the registry re-merges the caller's signal," means that even if you replace the signal, the registry will still re-merge the caller's original signal before actually invoking the body.
Why go to this trouble? Because the right to cancel belongs to the caller. A user clicks cancel, an upper-layer Agent decides to abort, the entire session is killed—these signals must be able to penetrate through to the tool body. If some plugin could "replace away" the caller's cancellation signal by swapping the signal, the tool could never be stopped again, which is unacceptable. So the design builds in double insurance: you can layer on your own deadline, but the caller's cancellation is always preserved.
In practice this leads to a noteworthy behavior: you register a timeout plugin, and when the timeout fires, the body's signal aborts; but at the same time, if the caller also aborts, whichever arrives first takes effect. So the correct way to write a tool body is to respond to the signal, rather than assuming "as long as I haven't timed out, I definitely won't be cancelled."
Next up is tools/post-execute. It performs checks or rewrites after the tool has executed and before the result is normalized, and its return value is the typed PostToolDecision. This extension point is the policy location at the "result layer," exactly symmetric to pre-execute's "intent layer."
PostToolDecision's four result rewrites: accept(content?), accept(value), block(feedback), and the confidentiality boundary
PostToolDecision appears to have more possible values than PreToolDecision because "accepting" has two levels of granularity: changing the display, and changing the canonical value. Add rejection, and there are four categories in total:
| Decision | Meaning | Cost and side effects |
|---|---|---|
| { kind: 'accept'; content? } | Accept the result, optionally replacing the displayed content | Preserves the canonical value and metadata, replacing only the part shown to the model |
| { kind: 'accept'; value } | Accept the result, optionally replacing the canonical value | Triggers re-validation and recomputation of content |
| { kind: 'block'; feedback } | Block the result | Turns the corrective feedback into an error result returned to the model |
| (no return / pass through) | Keep the original result | The call continues down the pipeline |
The key point is the difference between the first two kinds of accept—this is where confusion most easily arises:
- accept(content?): replaces only the display content. The canonical value (that is, the value returned by execute) and the metadata are both preserved as-is. It fits scenarios like "I want to give the model a different phrasing," for example condensing a lengthy JSON summary into a paragraph of natural language.
- accept(value): replaces the canonical value. This is a heavy operation—the registry takes the new value and re-runs it through output.schema validation, and calls output.render to recompute the model-facing content. In other words, once you change the value, content is automatically recomputed along with it, and you don't need to (and had better not) hand-write content at the same time.
The semantic difference between these two determines that they fit entirely different scenarios. If you just want to change "the wording shown to the model," use content; if you genuinely want to change "the authoritative result produced by this tool call," use value, and accept the cost of re-validation and the risk of failure—if the new value doesn't satisfy output.schema, then this replacement is wrong.
Next, block(feedback). It replaces the tool's result with an error result, and the error content is your feedback. This is not "hiding the result," but rather "telling the model that this result is unusable, please correct it according to the feedback and try again." So the wording of feedback should read like a code review comment to a colleague, not a bare "invalid." For example, for a path traversal, the feedback should clearly state "the path exceeds the allowed working directory, please use a relative path within the working directory instead."
Finally, a confidentiality boundary that must be underlined:
Content replacement is a display strategy, not a confidentiality strategy. To hide a programmatic value, you must replace that value or block the result.
The meaning of this sentence is: if you only change content, the canonical value still flows through the pipeline, and anywhere downstream that can see the canonical value (auditing, logs, other hooks) can obtain it. If some field is sensitive information and you want it to never appear in the result at all, then you must use accept(value) to strip it out of the canonical value, or simply block. Expecting that "just tweaking the display text hides it" does not hold.
This boundary has an extremely high rate of pitfalls in real projects, especially when credentials, internal paths, and internal service names are involved. A sound approach is: list all fields that "must disappear" and handle the canonical value in post-execute; leave fields that are merely "ugly" to content.
finalizeContent and tools/result: the final content invariant and read-only observation of frozen results
After post-execute, there are still two more stages, and their division of labor is very clear.
The first is finalizeContent. It is a callback owned by the tool definition (ToolDefinition) itself—note the keyword "owned by itself"—it does not belong to a hook plugin, but is rather something you can declare when writing defineTool. The registry will call it exactly once. Its role is the "final content-only invariant":
- Synchronous execution: no asynchronous waiting, no new scheduling points introduced.
- Only final corrections at the content level: what it can touch is the content; it cannot overturn the canonical values that have already been accepted.
Because this callback belongs to the tool definition itself, it is naturally suited for placing "content rules that this tool should satisfy no matter what." For example, if the output of a certain type of tool must carry a fixed trailing hint, then write it here—done in one step, without depending on the registration order of any plugin.
After finalizeContent finishes running, the registry will materialize and freeze the accepted result, and then trigger tools/result. tools/result is a synchronous notification, and its purpose is to let you observe that frozen, immutable authoritative result. Three characteristics should be remembered together:
- Synchronous: the notification is emitted synchronously and will not be deferred to the next tick.
- Read-only: observers cannot transform the result. The result has already been frozen; even if you want to change it, there is no entry point.
- Failure isolation: errors in the observer itself will be isolated and will not affect the main flow. This is especially important for observability plugins—a broken instrumentation call should not bring down the business invocation.
So when should you use tools/result, and when should you use tools/post-execute? Here are the selection criteria:
- Need auditing, metrics, capturing the final result → use tools/result. What it sees is the final truth.
- Need to transform the result or attach context → use tools/post-execute. Because at the result stage, it can no longer be changed.
The official documentation once gave a very memorable mnemonic for choosing, and I will reorganize the pipeline order here: pre-execute decides "whether it can be done," execute decides "how it is done," post-execute decides "how the result is presented," and result is only responsible for "taking a look at the final result." The guard in the middle is a red-line switch that runs throughout, and no one can overturn it.
Write out the full order of the entire pipeline so you can cross-reference it when troubleshooting: tools/pre-execute → monotonic guard → tools/execute → tools/post-execute → finalizeContent → tools/result. The first three waterfalls can rewrite a single invocation, while finalizeContent and tools/result, which are controlled by the definition itself, run after them.
September 2026 in practice: the current approach to writing permission gates and policy ordering with pre-execute
Enough theory—let's get practical. The official documentation uses a "permission gate" as an example to show how a hook plugin uses tools/pre-execute, and we'll rewrite it into a version you can drop straight into a project.
First, an easily overlooked prerequisite: a hook plugin is just an ordinary Cordis plugin and requires no external protocol. This means two things. First, you don't need to invent some new format for hooks—the standard Cordis plugin conventions of export name, export apply, and inject still apply. Second, since it's an ordinary plugin, it enjoys everything in the plugin ecosystem—it can be installed, uninstalled, and ordered via configuration.
The policy in the example below is: tools that hit the blacklist are denied outright; additionally, any attempt to touch .env is intercepted. Note how the code accesses arguments—arguments are already frozen before entering the policy, so we can only read them, not modify them.
// File path: my-plugins/permission-gate/src/index.ts
// A permission gate plugin based on tools/pre-execute.
// It returns a typed decision: deny if the blacklist is hit, otherwise call next() to delegate.
import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
// Blacklist: tools that directly write to the filesystem are forbidden in the runoob project.
// A simple set is used here for demonstration; in a real project you could query a database or ask an approval service.
const DENY_TOOLS = new Set(['fs_write', 'fs_edit'])
// Policy decision function: returns whether this invocation is allowed.
// exec carries the immutable invocation identity (callId, name, arguments, agent, token, signal).
async function isAllowed(exec: ToolExecution): Promise<boolean> {
if (DENY_TOOLS.has(exec.name)) return false
// Additional example: modifying .env files is forbidden in the runoob demo (arguments are frozen before entering the policy).
const raw = exec.arguments as { path?: string }
if (typeof raw.path === 'string' && raw.path.includes('.env')) return false
return true
}
export const name = 'permission-gate'
export function apply(ctx: Context) {
// tools/pre-execute is a waterfall: a listener can return a decision or call next() to delegate.
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
// Returning deny immediately terminates this invocation, and subsequent listeners no longer run.
return { kind: 'deny', reason: 'Denied by policy: this tool is not allowed in the runoob workspace.' }
}
// Allow: hand the decision to subsequent listeners in the pipeline.
return next()
})
}
There are several design details in this file worth unpacking, all of which represent the standard approach to writing this kind of gatekeeping plugin as of September 2026:
- Separation of decision logic from registration logic. isAllowed is a purely functional decision, and ctx.on is only responsible for translating the decision result into an action. The benefit of this is testability: you can write unit tests for isAllowed, constructing various exec shapes, without needing to spin up the plugin runtime.
- Deny on a hit, and never attempt to "fix it and let it through". The reasoning has already been thoroughly covered earlier in "Why parameters cannot be rewritten": parameters are the frozen identity of the call, and changing them breaks consistency. If you really need the model to retry with different parameters, that is the job of a post-execute block or the next round of conversation.
- Allowing must explicitly return next(). This is the line most easily overlooked. The semantics of waterfall are that the listener passes the decision down the chain; if you neither call next() nor return a decision, the call chain just stalls there. An empty return, forgetting to return, or returning only undefined are all different forms of "silent suspension."
- Write the deny reason as a complete sentence meant for humans. This is because the reason will be materialized into an error result handed to the model, and the quality of the wording directly determines whether the model can self-correct in the next round. The example writes Denied by policy: this tool is not allowed in the runoob workspace., which explicitly states "in which environment it is not allowed," so the model at least knows to switch environments or switch tools.
Now let's talk about the current approach to policy ordering. pre-execute is a reorderable policy layer, and the order of multiple policy plugins can be adjusted in configuration. This is extremely useful in real projects: you might have three layers—"organization-level policy," "project-level policy," and "session-level policy"—and logically you want the organization level to run first (denying obvious violations earlier), the project level second, and the session level last for fine-grained judgment. The way to implement this is by controlling the plugin registration/loading order, rather than hardcoding priority numbers in the code.
Adjustable ordering brings with it a problem you must be careful about: deny short-circuits, but allow does not. If your organization-level policy returns deny for a given call, none of the subsequent policies execute—this is the desired behavior. But if the organization-level policy returns allow, it does not cause subsequent policies to skip their checks; subsequent policies can still deny. So when designing multi-layer policies, you should put "the strictest, the one you most want to reject first" at the front, and "the more granular, the more willing to allow" at the back. Reversing this arrangement is a common mistake.
One more note on coordination with guards. If your gatekeeping has true red lines, such as "no writing to system directories regardless of policy ordering," then that segment should not be written in this pre-execute plugin. Instead, you should register another ToolGuard using ctx.tools.guard(). This is because a pre-execute plugin can be overridden by a later allow, but a guard cannot. A pragmatic project structure is: guard for red lines, pre-execute for negotiable policies, post-execute for result rewriting and feedback, and result for instrumentation and auditing. With all four layers in their proper places, maintenance later won't turn into layers fighting each other.
Finally, here's a deployment and verification checklist to help you confirm the gatekeeping is actually in effect:
- Call a blacklisted tool (such as fs_write) and confirm that what's returned is the error result corresponding to deny, rather than the result of the tool actually executing.
- Call an allowed tool whose arguments contain .env, and confirm it gets blocked.
- Call a completely normal tool and confirm you still get the real result—this step is to rule out the classic accident of "forgetting to return next(), causing everything to hang."
- Temporarily register a red line via guard, and confirm that allow in pre-execute cannot bypass it.
Summary and Best Practices
Condensing the key points of the entire article into an actionable checklist—run through it when writing tools and wiring up pipelines:
- Tool definition uses defineTool, with all fields present: name, description, parameters, output.schema, output.render, execute. output.schema declares the canonical value type, output.render is responsible for translating the canonical value into model-facing content, and execute returns the canonical value.
- Registration goes through ctx.tools.register; the plugin must export inject = ['tools'] to explicitly declare its dependencies.
- Memorize the pipeline order: tools/pre-execute → monotonic guard → tools/execute → tools/post-execute → finalizeContent → tools/result.
- PreToolDecision has three states: allow lets it continue; deny(reason) materializes into an error result and skips the tool body; ask(reason?) triggers a one-time query via ctx.approval, and only allowed-once continues.
- Never rewrite arguments. arguments are frozen before the policy, for consistency across history, auditing, UI, and execution. If you need to correct arguments, go through post-execute's block to feed back to the model for a retry.
- Use ctx.tools.guard() for invariants. The ToolGuard signature is (execution: Readonly<ToolExecution>) => string | undefined; returning a string means reject, undefined means keep the status quo; there is no allow, so order can never flip a rejection back to allowed—that is what makes it monotonic.
- Put timeouts, retries, and metrics in tools/execute. It receives ToolDispatchExecution, and only here can you replace exec.signal to impose a deadline; the rule is "replaceable, not removable," and the registry re-merges the caller's signal before the invocation body.
- Distinguish the granularity of PostToolDecision: accept(content?) only swaps the display content, preserving the canonical value and metadata; accept(value) swaps the canonical value, which re-validates and recomputes the content; block(feedback) turns corrective feedback into an error result.
- Don't rely on content for confidentiality. Content replacement is a display strategy, not a confidentiality strategy; to hide programmatic values, you must replace value or block.
- finalizeContent is the tool's own callback, called exactly once by the registry, synchronously, doing only final content corrections; afterward the result is materialized and frozen, triggering a synchronous tools/result.
- tools/result is read-only observation: observers cannot transform the result, and observer failures are isolated without affecting the main flow. Use it for auditing, metrics, and capturing the final result; to transform the result or attach context, use post-execute.
- The gatekeeping plugin is just an ordinary Cordis plugin. On hitting a blacklist (such as fs_write, fs_edit) or a sensitive path (containing .env), return deny; to let it through you must explicitly return next(), and omitting it will hang the entire chain.
- Policy ordering relies on plugin registration order, not hardcoded priority. Put the strictest, most eager-to-reject ones first; remember that deny short-circuits and allow does not, so the ordering design should revolve around "who has the right to reject first."
- Layer things where they belong: guard for red-line invariants, pre-execute for negotiable policy, execute for cross-cutting concerns, post-execute for result rewriting and feedback, result for instrumentation and auditing.
- Run the four-step verification before going live: blacklist blocked, sensitive arguments blocked, normal tools still pass, guard red lines cannot be bypassed by allow.
At this point, the two sections on defineTool for defining tools and the tool execution pipeline are complete. Tool definitions determine what the model "can see and how it can call it," while the pipeline determines "which gates each call passes through and how the result returns to the model." Once both layers are clear, moving on to advanced topics like approval, sandboxing, and observability becomes much smoother.