Why Tools Are the Core of Any Agent
An LLM by itself only outputs text. Agents that book flights, write code, and query databases do so through tools — the LLM decides which tool to call and with what arguments, and the tool actually executes.
Without a tool system, an Agent is just a chat interface with extra steps.
The dsh tool system is not a simple function registry. It handles every engineering problem along the entire chain from "the model decides which function to call" to "the result safely returns to the model":
- The model needs a JSON Schema to understand tool parameter formats — dsh generates one from your TypeScript types automatically
- Tool execution can be dangerous — dsh provides a three-layer pipeline for interception
- Some tool operations are irreversible — dsh has a built-in approval mechanism that pauses for user confirmation before dangerous actions
- Different Agents need different tool subsets — dsh supports fine-grained scope isolation
This article walks through each of these mechanisms.
What Is ctx.tools
ctx.tools is a Cordis Service of type ToolRuntime. It's the entry point for the tool system, responsible for:
- Registration: manages the global tool table, with support for scope-level tool shadowing
-
Schema projection: projects internal definitions into
ToolSchema[]the model can understand -
Execution: drives the three-phase pipeline, returning a typed
ToolExecutionResult
Using it requires declaring the dependency in your plugin:
export const inject = ['tools']
export function apply(ctx: Context): void {
// Register a tool
ctx.tools.register(myTool)
// Get the schemas visible to the current scope (for the model)
const schemas = ctx.tools.schemas()
}
When a plugin is unloaded, all tools registered via ctx.tools.register are automatically deregistered — this is the Cordis Effect mechanism in action (covered in Part 02).
defineTool: Type-Safe Tool Definitions
You can implement the ToolDefinition interface directly, but dsh provides the defineTool helper that does compile-time type inference and runtime validation of parameters and return values.
A minimal example:
import { defineTool } from '@deepseek-ai/dsh-tools'
const greetTool = defineTool({
// Tool name (the model uses this to call it)
name: 'greet_user',
// Tool description (the model uses this to decide when to use it)
description: 'Send a greeting to a user by name.',
// Parameter schema: uses dsh's DSL, not raw JSON Schema
parameters: {
name: {
type: 'string',
required: true, // required: true = mandatory
description: 'The user name to greet.',
},
formal: {
type: 'boolean',
description: 'Use formal greeting if true.',
// No required: true = optional
},
},
// Output definition: declare the return value schema and how to render it for the model
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
// Execution function: args type is inferred automatically from parameters
async execute(args, exec) {
// TypeScript knows args.name: string, args.formal?: boolean
const prefix = args.formal ? 'Good day' : 'Hello'
return `${prefix}, ${args.name}!`
},
})
defineTool does three things:
- Infers the TypeScript type of
argsfromparameters— no manual type annotations needed - Compiles the parameter schema DSL into JSON Schema — sent to the model automatically
- Runtime validation: mismatched parameters throw
ToolArgsError; invalid return values throwToolOutputError
The Schema DSL Type System
dsh's schema DSL (ValueSchemaSpec) supports these node types:
// String
{ type: 'string', enum?: string[], const?: string }
// Numbers
{ type: 'number' }
{ type: 'integer' }
// Boolean
{ type: 'boolean' }
// Array
{ type: 'array', items?: ValueSchemaSpec }
// Object (additionalProperties is mandatory)
{ type: 'object', properties?: ParameterSchemaSpec, additionalProperties: boolean }
// Unconstrained JSON (any JSON value)
{ type: 'json' }
// Exactly one branch must match
{ oneOf: [spec1, spec2, ...] }
Why not use raw JSON Schema directly?
Raw JSON Schema is too broad — many keywords dsh doesn't enforce. With the dsh DSL, you know at compile time that every constraint you declare is one dsh will actually enforce. No "wrote it but it doesn't do anything" dead zones.
Type inference goes 16 container levels deep, then falls back to JsonValue — this prevents TypeScript's type instantiation stack from overflowing.
The Three-Phase Execution Pipeline
This is the most important part of the tool system. When the Agent Loop receives a tool call request from the model, it walks this pipeline:
Model requests "call shell_run(cmd='ls -la')"
│
▼
┌─────────────────────┐
│ tools/pre-execute │ waterfall: allow / deny / ask
└─────────────────────┘
│ allow (approved)
▼
┌─────────────────────┐
│ Guards │ monotonic deny-only checks
└─────────────────────┘
│ passes
▼
┌─────────────────────┐
│ tools/execute │ waterfall: around-dispatch (timeout / retry / metrics)
└─────────────────────┘
│
▼ tool execute() runs
│
┌─────────────────────┐
│ tools/post-execute │ waterfall: accept / replace / block
└─────────────────────┘
│
▼
┌─────────────────────┐
│ finalizeContent │ tool-owned last-mile content transform (optional)
└─────────────────────┘
│
▼
┌─────────────────────┐
│ tools/result │ emit: read-only observation for logging / metrics
└─────────────────────┘
│
▼
Returns ToolExecutionResult to the Agent Loop
Three core phases:
Phase 1: tools/pre-execute (Before Execution)
This is where you decide whether a tool call is allowed to run. Listeners return one of three decisions:
type PreToolDecision =
| { kind: 'allow' } // Allow execution
| { kind: 'deny'; reason: string } // Deny, with a reason passed to the model
| { kind: 'ask'; reason?: string } // Pause and request user approval
ask is the core of dsh's permission system. When a listener returns ask, dsh calls the ctx.get('approval') service and waits for user confirmation before continuing. If no approval service is present, ask automatically becomes deny.
A common use case — adding permission interception to the shell command tool:
ctx.on('tools/pre-execute', async (exec, next) => {
// Only intercept shell_run
if (exec.name !== 'shell_run') return next()
const cmd = (exec.arguments as { command: string }).command
// Destructive commands require user confirmation
if (cmd.includes('rm') || cmd.includes('sudo')) {
return { kind: 'ask', reason: `Will run: ${cmd}` }
}
return next()
})
Note: tools/pre-execute is a waterfall — just like covered in Part 02, listeners must either call next() or return a decision. Not calling next() silently cuts off all downstream listeners.
Phase 2: tools/execute (Around Dispatch)
This is where the tool body actually runs. Listeners wrap around the tool execution, good for:
// Adding timeout control
ctx.on('tools/execute', async (exec, next) => {
const timer = new Promise<ToolExecutionResult>((_, reject) =>
setTimeout(() => reject(new Error('Tool timeout')), 30_000)
)
return Promise.race([next(), timer])
})
// Adding execution time metrics
ctx.on('tools/execute', async (exec, next) => {
const start = performance.now()
const result = await next()
console.log(`${exec.name} took ${performance.now() - start}ms`)
return result
})
Listeners may only modify exec.signal (the cancellation signal), not the tool arguments — arguments are already written to the session log at this point; changing them would create a mismatch between what's logged and what actually ran.
Phase 3: tools/post-execute (After Execution)
After the tool runs, this phase decides what result the model gets:
type PostToolDecision =
| { kind: 'accept' } // Accept the original result
| { kind: 'accept'; content: ContentBlock[] } // Replace displayed content (keeps canonical value)
| { kind: 'accept'; value: JsonValue } // Replace canonical value (re-renders content)
| { kind: 'block'; feedback: ContentBlock[] } // Convert to an error result with corrective feedback
block is useful for output validation — if a tool returns unexpected content, intercept it and let the model retry:
ctx.on('tools/post-execute', async (exec, result, next) => {
if (exec.name === 'read_file' && !result.isError) {
const content = result.value as string
if (content.length > 50_000) {
// Truncate and tell the model
return {
kind: 'accept',
content: [{ type: 'text', text: `[Truncated] ${content.slice(0, 50_000)}...` }],
}
}
}
return next()
})
Guards: Monotonic Denial
Guards registered with ctx.tools.guard() run after tools/pre-execute and before the tool body. They can only deny — even if the waterfall allowed a call, a guard can still veto it at the last moment:
// Guards return a string (denial reason) or undefined (no-op)
ctx.tools.guard((exec) => {
// Block prompt injection patterns in any tool call
const args = JSON.stringify(exec.arguments)
if (args.includes('ignore previous instructions')) {
return 'Potential prompt injection detected'
}
// Return undefined = no-op
})
The monotonicity guarantee: once a guard denies a call, no subsequent guard can un-deny it. This prevents a malicious plugin from registering a guard that undoes security policy set by another plugin.
Scope Isolation (ToolRestriction)
Different Agent tasks may need different tool subsets. A code-review agent should only need read-only file tools and shouldn't be able to execute shell commands.
ctx.tools.restrict() lets you filter tools at the agent scope level:
// Allow only file-reading tools
const disposer = ctx.tools.restrict({
allow: ['read_file', 'list_files', 'search_files'],
})
// Or: block dangerous tools
const disposer = ctx.tools.restrict({
deny: ['shell_run', 'write_file', 'delete_file'],
})
// Clean up when done
disposer()
When multiple restrict calls are active, they intersect: a tool is only visible to the model if all active rules allow it.
Scope-local tool registrations are not affected by restrictions — a subagent's own private tools are always visible to itself.
Parallel Tool Calls
Models sometimes request multiple tool calls in a single response. dsh supports parallel execution, but tools must explicitly opt in as concurrency-safe:
const safeReadTool = defineTool({
name: 'read_file',
// ...
// Read-only; safe to run alongside other calls
isConcurrencySafe: (args) => true,
execute: async (args, exec) => {
return fs.readFile(args.path, 'utf-8')
},
})
Tools without isConcurrencySafe or that return anything other than true default to exclusive mode — one at a time, each waiting for the previous to complete.
This is a defensive design: slightly slower serial execution beats state corruption. Opting into parallelism is a contract the tool author makes, not something the framework infers for you.
Walkthrough: A Weather Query Tool from Scratch
Let's put everything together and write a complete custom tool:
// packages/my-tools/src/weather.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'tool-weather'
export const inject = ['tools']
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'get_weather',
description: 'Get current weather for a city. Returns temperature and conditions.',
parameters: {
city: {
type: 'string',
required: true,
description: 'City name, e.g. "Beijing" or "Shanghai".',
},
unit: {
type: 'string',
enum: ['celsius', 'fahrenheit'],
description: 'Temperature unit. Defaults to celsius.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
city: { type: 'string' },
temperature: { type: 'number' },
unit: { type: 'string' },
condition: { type: 'string' },
},
},
// render converts the structured value to the text the model sees
render: (_args, value) => {
const v = value as { city: string; temperature: number; unit: string; condition: string }
return [{
type: 'text',
text: `Weather in ${v.city}: ${v.temperature}°${v.unit === 'celsius' ? 'C' : 'F'}, ${v.condition}`,
}]
},
},
// Read-only operation; safe for parallel execution
isConcurrencySafe: () => true,
async execute(args, exec) {
const unit = args.unit ?? 'celsius'
// In production: call a real weather API here
// exec.signal lets us respond to cancellation
const response = await fetch(
`https://weather-api.example.com/current?city=${encodeURIComponent(args.city)}&unit=${unit}`,
{ signal: exec.signal },
)
if (!response.ok) {
throw new Error(`Weather API returned ${response.status}`)
}
const data = await response.json()
return {
city: args.city,
temperature: data.temperature,
unit,
condition: data.condition,
}
},
// Custom UI presentation: shown while the call is in progress
presentCall: (args) => ({
card: 'generic',
title: `Checking weather in ${(args as { city: string }).city}`,
kind: 'fetch',
}),
// Custom UI presentation: shown after the call completes
presentResult: (args, result) => ({
card: 'generic',
title: result.isError
? `Weather check failed`
: `Weather in ${(args as { city: string }).city}`,
}),
}))
}
After adding it to your dsh config, that's everything:
-
ctx.tools.schemas()automatically includesget_weather's JSON Schema, and the model can see it - All model calls go through the full three-phase pipeline automatically
- Return values are validated, rendered, and written to the session log
Design Principles Summary
Looking back at the dsh tool system's overall design philosophy:
| Design Decision | Reason |
|---|---|
Arguments are frozen after pre-execute
|
Logs, audit trails, and UI must stay in sync with execution |
| Guards can only deny, never allow | Prevents later-registered plugins from undoing security policy |
output.render is a pure function |
Session replay needs to reproduce the presentation faithfully |
isConcurrencySafe defaults to exclusive |
Defensive design: correctness over throughput |
| Tools are a Cordis Service | Registration/deregistration lifecycle follows the plugin; HMR doesn't leak tools |
Tool schemas never expose execute/present callbacks |
The model only sees name/description/parameters
|
One principle runs through the entire dsh tool system: at every stage of a tool call, there is exactly one clearly-scoped extension point, with explicit responsibility boundaries — you can't cross them.
That's why you can safely hang a permission interceptor in production without worrying that it'll conflict with internal logic you can't see.
What's Next in the Series
The tool system answers "what can an Agent do." The next article on the Agent Loop covers "how does one conversation turn actually run" — the complete flow from model output through tool calls to loop termination, and all the places you can insert control logic.
Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.
Find more on my Homepage
Top comments (0)