Skip to content

Agent Tools and Skills

Want the model to search a song or check a lottery recommendation for the user? Write that logic into a file, drop it in tools/, and the model can call it by name on the next Agent turn. There are two registration paths: tools/*.ts file convention (declarative, inherited along the plugin tree) and setup() dynamic registration (imperative, mounted per generation). Both paths share the same access predicate and the same deferred catalog.

Path One: tools/*.ts Convention

After mounting the @zhin.js/tool Feature, each .ts file under tools/ (non-recursive) in the plugin package root default-exports defineAgentTool(...):

ts
// tools/echo.ts (examples/minimal-bot)
import { defineAgentTool } from '@zhin.js/tool';
import { z } from 'zod';

export default defineAgentTool<{ message: string }>({
  description: 'Echo a message back',
  inputSchema: z.object({ message: z.string().min(1) }),
  async execute({ message }) {
    return `echo: ${message}`;
  },
});

Definition fields (packages/im/tool/src/definition.ts):

FieldRequiredDescription
descriptionYesFunctional description for the model
inputSchemaNozod object or JSON Schema, drives parameter validation and catalog display
approvalNo'never' | 'on-risk' | 'always', default 'on-risk'
platformsNoRestrict to adapter platforms (e.g., ['icqq']), empty = all
scopesNoRestrict to session scenes 'private' | 'group' | 'channel', empty = all
permissionsNoPermit string list (see access control below)
hiddenNoRegistered but not exposed to the model (callable by name only)
execute(input, context)Yescontext is the capability context (config / use(token) / owner / generation)

The tool name is the file name -- this is the name exposed to the model; note it must be unique across plugins. Child plugins can see and override parent plugins' same-named tools (resolved upward along the plugin tree). The descriptor also has qualifiedName (owner path segments and file name joined with __) for disambiguation.

Path Two: setup Dynamic Registration

When you need to decide which tools to register based on runtime conditions (configuration switches, database handles), get AgentToolsHost via agentToolsHostToken (exported from @zhin.js/plugin-runtime) inside setup():

ts
// plugins/utils/lottery/plugin.ts (excerpt)
import { definePlugin, agentToolsHostToken } from '@zhin.js/plugin-runtime';

export default definePlugin({
  name: 'lottery',
  async setup(context) {
    // When AI is not installed/enabled, the Host doesn't exist -- must guard with has() first
    if (context.resources.has(agentToolsHostToken)) {
      const agentTools = context.resources.use(agentToolsHostToken);
      const { registerLotteryAgentTools } = await import('./agent/runtime-tools.js');
      context.lifecycle.add(registerLotteryAgentTools(agentTools));
    }
  },
});

host.register(tool) registers under the current generation and returns an unregister function; hooking it to context.lifecycle enables automatic cleanup with generation turnover. Registration item (AgentToolRegistration):

ts
host.register({
  name: 'lottery_sync',          // Runtime name exposed to the model
  description: tool.description,
  inputSchema: tool.inputSchema, // zod object or JSON Schema
  source: 'lottery',             // Source label (for diagnostics)
  platforms: tool.platforms,     // Same four-tuple as path one
  scopes: tool.scopes,
  permissions: tool.permissions,
  hidden: tool.hidden,
  approval: 'never',             // 'never' | 'always', combined with ExecPolicy
  execute: (input) => tool.execute(input, { pluginName: 'lottery', runtimeName: name }),
});

Note: the execute closure captures dependencies at setup() time. Do not call plugin locators (such as getPlugin()) inside the closure -- the runtime path prohibits dynamic plugin retrieval.

Unified Access Control: canAccessTool

Tools registered via both paths are filtered by Core's canAccessTool(tool, message) against the message context on every Agent turn -- one predicate governs both paths (packages/im/core/src/built/tool.ts; on the Plugin Runtime side, it's applied through CapabilityIngress, see packages/im/agent/src/plugin-runtime/capability-ingress.ts).

Four-tuple semantics:

FieldDetermination
platformsReject if the message's source adapter name (String(message.$adapter)) is not in the list
scopesReject if the session scene (message.$channel.type, default private) is not in the list
permissionsPermit list, checked item by item (AND); commas inside parentheses mean OR
hiddenNot included in the tool list given to the model, but still executable by name

Permit syntax (packages/im/core/src/built/permit-parse.ts) has three categories: built-in adapter(name), group(id,...), private(id,...), channel(id,...), user(id,...), role(master|trusted|user); platform identity platform(adapter,perm) (e.g., group owner/admin, determined by adapter checker); unrecognized permits are always rejected.

Deferred Catalog and load_tool

Tools are not included in the full prompt. Each turn first builds a catalog of tools that pass access control; by default only alwaysLoadedTools are exposed to the model. Remaining tools are discovered and loaded on demand via three meta-tools (packages/im/agent/src/builtin/deferred-tool-meta.ts): discover searches tools/skills by query (can filter by MCP server), returning names with brief descriptions; load_tool loads a tool's schema into the session by name, making it callable; load_skill loads the complete skill instructions and unlocks its associated tools.

Loading state is persisted per session (DeferredToolSessionSnapshot), with an eviction limit. Configuration key deferredTools (ZhinAgentConfig):

KeyDefaultDescription
maxLoadedPerSession12Maximum number of tools loaded per session
discoverTopK5Number of results returned by discover
alwaysLoadedTools['ask_user', 'spawn_task', 'discover', 'load_tool', 'load_skill']Always visible to the model
mcpServers{}Override alwaysLoaded list per MCP server

The Anthropic SDK channel marks unloaded tools with deferLoading; other channels only deliver the loaded set.

skills and agents/*.agent.md

Skills and named Agents are also file conventions, discovered by the @zhin.js/skill and @zhin.js/agent-feature Features respectively.

Skills go in skills/<name>/SKILL.md (one skill per subdirectory): the body is the instruction for the model, the first Markdown heading line serves as the description, and load_skill unlocks tools associated via toolNames. Named Agents use agents/<name>.agent.md (file name must be lowercase kebab, e.g., agents/planner.agent.md): the entire Markdown file is that Agent's instructions, and the first heading line serves as the description. See examples/test-bot/agents/planner.agent.md for a real-world example.

markdown
<!-- agents/planner.agent.md -->
# planner

You are **planner** (coordinator): break down user goals, define acceptance
criteria, and coordinate specialist roles.

Plugin agent/ Directory (An Alternative Organization)

Plugins with @zhin.js/agent installed can also use an agent/ directory to centrally declare the AI surface (packages/im/agent/src/discovery/agent-surface.ts scans it):

text
my-plugin/
├── agent/
│   ├── agent.ts           # defineAgent: description, keywords, toolNames, systemPrompt
│   ├── instructions.md    # System prompt body
│   ├── tools/*.ts         # defineAgentTool (from '@zhin.js/agent/tools')
│   ├── skills/*.{md,ts}   # .md can have frontmatter (description / tools / always)
│   └── subagents/<name>/  # Recursively isomorphic sub-Agents

The difference from @zhin.js/tool's defineAgentTool: the @zhin.js/agent/tools version's execute(input, ctx) receives { pluginName, runtimeName, filePath } context as the second argument, approval supports 'always' | 'once' | 'never' or a custom predicate, and it can configure toModelOutput to shape the text returned to the model. Real-world example: plugins/utils/short-url/agent/tools/short_url.ts.

ts
// agent/tools/short_url.ts (plugins/utils/short-url, excerpt)
import { defineAgentTool } from '@zhin.js/agent/tools';
import { z } from 'zod';

export default defineAgentTool<{ url: string }>({
  description: 'Shorten a URL and return the short link',
  inputSchema: z.object({ url: z.string().min(1) }),
  keywords: ['短链', '缩短', 'shorten'],
  async execute({ url }) {
    // ...
  },
});