Agent tools and Skills
With AI enabled, the bot answers messages that match no command using an LLM. This page is about letting it do things:
- Tool: a function the AI can call — check the weather, search music, query a database.
- Skill: a playbook the AI loads only when needed, optionally with its own Tools.
- Prompt Section: background knowledge for the AI, such as product terms and answer style.
- Sub-agent: a specialized assistant with its own responsibilities, rules and tools.
Before you start
Run npx zhin setup --ai first. The wizard installs a model SDK and declares @zhin.js/tool and @zhin.js/prompt-section, which is what makes tools/ and prompt-sections/ recognized. See the AI overview.
Your first tool
// tools/weather/index.ts
import { defineAgentTool } from '@zhin.js/tool';
import { z } from 'zod';
export default defineAgentTool<{ city: string }>({
description: 'Get the current weather for a city',
inputSchema: z.object({ city: z.string().min(1) }),
requiresApproval: 'never',
async execute({ city }) {
const response = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`);
return response.text();
},
});Save, then ask the bot in a DM "What's the weather in Berlin?" — the AI calls this tool and answers from the result.
descriptionandinputSchemaare written for the model: say clearly what the tool does and what the parameters are, so it gets called at the right moment.inputSchemacan be a Zod 4 object or a JSON Schema whose root isobject. The framework validates arguments before calling.- Whatever
executereturns goes back to the model, which turns it into a reply.
Common tasks
Restrict where a tool is available
export default defineAgentTool({
description: 'Kick a group member',
platforms: ['icqq'], // QQ only
scopes: ['group'], // group chats only
permissions: ['role(master)'], // only in conversations started by a master
// ...
});When the conditions don't hold, the model doesn't see the tool at all. permissions uses the same rules as a command's permit. hidden: true registers the tool without offering it to the model; it can still be called by name.
Ask the user to confirm first
export default defineAgentTool({
description: 'Delete a record',
requiresApproval: 'always',
// ...
});| Value | When the user must confirm |
|---|---|
'always' | Every call |
'once' | The first call in a conversation |
'on-risk' (default) | When risky. Unknown plugin operations are confirmed; bash, file and network tools skip a second confirmation once security policy has verified the exact command, path or URL |
'never' | Never |
'never' only skips the confirmation step — it cannot bypass permissions or network, filesystem and shell security policies.
Offer a tool based on config
When a config switch or available resource decides, register in setup in plugin.ts:
// plugins/utils/lottery/plugin.ts (excerpt)
import { definePlugin } from 'zhin.js';
import { defineAgentTool } from '@zhin.js/tool';
export default definePlugin({
name: 'lottery',
async setup(context) {
if (!context.config.get().agentToolsEnabled) return; // not offered when disabled
context.addTool('lottery_sync', defineAgentTool({
description: 'Synchronize lottery draws',
requiresApproval: 'always',
inputSchema: { type: 'object', properties: {} },
execute: async (_input, toolContext) => {
const database = toolContext.use(lotteryDatabaseToken);
return database.sync();
},
}));
},
});addTool behaves exactly like the tools/ folder and is replaced automatically on hot reload — no manual unregistering.
Use plugin services inside a tool
The second argument to execute gives access to plugin services and the current context:
| Field | Description |
|---|---|
config | This plugin's config |
use(token) | Get services the plugin provides |
origin / principal | Which conversation the call came from and which user it acts for |
policy | The security policy in effect |
question | Ask the user something (next section) |
$client | Platform client inferred from the adapter |
Always get dependencies through this argument rather than looking up other plugins at execution time, so the tool uses the right version of each resource during hot reloads.
config is unknown by default. To type it, use defineAgentTool.withConfig: fix the config type first and give the input type on the second call (with a Zod inputSchema the input type is inferred):
export default defineAgentTool.withConfig<WeatherConfig>()<{ city: string }>({
description: 'Look up the weather',
inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
execute({ city }, { config }) {
return fetchWeather(city, config.apiKey); // config: WeatherConfig
},
});defineAgentTool<Input, Result, Config>({...}) keeps working.
Ask the user from a tool
Use context.question when you need more input from the user. Unattended runs such as schedules don't provide it, so handle its absence. The built-in ask_user tool uses the same mechanism and matches replies by conversation and user identity.
Tools only offered in certain situations
Where a tool lives decides when the model can see it:
| Location | Visible to the model |
|---|---|
tools/<name>/index.ts | Once the plugin is enabled (discoverable) |
skills/<skill>/tools/<name>/index.ts | After that Skill is loaded |
agents/<agent>/tools/<name>/index.ts | When that sub-agent is selected |
agents/<agent>/skills/<skill>/tools/<name>/index.ts | When that Agent is selected and its Skill loaded |
Keep root tools/ for general, frequent tools that need no extra explanation. Tools that only make sense for one platform, workflow or role belong in that Skill or Agent so they don't take up prompt space otherwise. Every tool is still validated at startup, even when not yet offered.
Keep a tool resident
Plugin tools are deferred by default: the model only sees their name and a one-line description in the system prompt's tool index, and gets the parameter schema through discover / load_tool when it needs one (calling the tool directly also loads it). For a tool needed in almost every conversation, declare alwaysLoaded: true so its schema is sent to the model on every turn:
export default defineAgentTool({
description: 'Translate text into a target language',
alwaysLoaded: true,
execute: async (input) => { /* ... */ },
})Keep the resident set small
Every resident tool's schema is sent on every model call. It uses context, adds token cost, and gives the model more tools to hesitate between. Only opt in for the one or two tools that are truly frequent; leave the rest deferred.
Rules:
- Leaving it out or setting
falsekeeps the tool deferred, so existing plugins behave exactly as before. - The flag only decides whether a tool is resident; it never widens admission. A tool that fails
platforms,scopesorpermissionsstays invisible, andhiddentools or private tools under a Skill / Agent directory are never made resident by it. - At most 8 plugin tools per turn become resident through this flag. Extra tools stay deferred in tool order (still reachable with
discover), and a warning is logged once. - Operators have the final say: with
ai.agent.deferredTools.alwaysLoadedMode: replace, the resident set is exactlyalwaysLoadedToolsand plugin declarations are ignored. Listing a tool name inalwaysLoadedToolsalso makes it resident, without the limit of 8. - Platform-only tools (with
platformsset) are already resident once they pass admission, so they don't need the flag.
Give the AI business context
Use a Prompt Section for product terms, answer conventions or tool-use constraints:
// prompt-sections/project-rules/index.ts
import { defineAgentPromptSection } from '@zhin.js/prompt-section';
export default defineAgentPromptSection({
title: 'Project rules',
content: 'Answer with repository-local terminology and cite changed files.',
layer: 'context',
order: 70,
retention: 'preferred',
maxChars: 1000,
profiles: ['interactive'],
});The folder name is the section's name; no id needed. After saving, new conversations use the new content and conversations already in progress keep the old version.
orderdecides position within a layer;retentiondecides what gets dropped first when the prompt budget is tight. Fields: Reference.- Console's capability catalog shows whether Prompt Sections are active (source, version, budget policy) but never their text, since it may contain internal policy. Runnable example:
examples/full-bot/prompt-sections/custom/index.ts. - A Prompt Section only changes the model's context; it grants no tools, data or approval rights.
Declaring the Prompt Section capability by hand
Without npx zhin setup --ai, declare both the dependency and the Feature in package.json:
{
"dependencies": {
"@zhin.js/prompt-section": "latest"
},
"zhin": {
"features": [
{ "package": "@zhin.js/prompt-section", "api": "^1.0.0" }
]
}
}Package a procedure as a Skill
A Skill is skills/<name>/SKILL.md: a playbook with frontmatter. The model normally sees only its summary, loads the full text when needed, and that unlocks the tools in its folder.
---
name: memory-consolidate
description: At turn end or when the master says "remember", write 1–3 retrievable facts to memory_entries
tools:
- memory_upsert
- memory_search
---
(Detailed steps go here…)Split a Skill that covers several independently triggered tasks, so a simple task doesn't pull in every tool. Skills shipped by adapters go in agents/<platform>/skills/<name>/ and appear only in that platform's conversations.
Define a sub-agent
A sub-agent is an agents/<name>/ folder with its own responsibilities, boundaries and tools. It must contain four files:
| File | Contents |
|---|---|
agent.json | Name, model, extra public tools, etc. |
system.md | Role and responsibilities |
boundaries.md | What it must not do |
conventions.md | Working conventions; may only extend the plugin root's AGENTS.md, never contradict it |
Optional workflows/, tools/, skills/, hooks/ and knowledge/ hold scenario flows, private tools, private Skills, hooks and a knowledge base. Mistakes that recur belong in conventions.md.
A platform adapter's Agent is selected automatically when a message arrives from that platform; if one platform has several automatic candidates, routing reports a conflict — merge responsibilities or let the user choose explicitly. Full format: @zhin.js/agent-feature.
Recommended layout
my-plugin/
├── AGENTS.md # rules for the main Agent
├── tools/
│ └── short-url/
│ ├── index.ts # declares the tool only
│ └── client.ts # implementation in the same folder
├── skills/short-url/
│ ├── SKILL.md
│ ├── tools/normalize/index.ts # Skill-only tool
│ └── hooks/audit/index.ts
├── hooks/audit/index.ts
└── agents/reviewer/
├── agent.json
├── system.md
├── boundaries.md
├── conventions.md
├── workflows/
├── tools/check-result/index.ts # Agent-only tool
├── skills/review/
│ ├── SKILL.md
│ ├── tools/check-result/index.ts
│ └── hooks/audit/index.ts
├── hooks/audit/index.ts
└── knowledge/- A tool's
index.tsonly declares; the implementation sits in the same folder. Code shared by several tools of one Skill goes in the Skill root (e.g.skills/github-account/handlers.ts). - Don't reach into plugin internals from leaf folders with
../../../../src/.... Genuinely shared runtime contracts should be a stable package API, or be imported once through a bridge module owned by the Skill / Agent.
Reference
Tool fields
| Field | Required | Description |
|---|---|---|
description | Yes | What the tool does, for the model |
inputSchema | No | Zod 4 object or a JSON Schema with an object root; validated before execution |
requiresApproval | No | 'never' | 'on-risk' | 'once' | 'always', default 'on-risk' |
platforms | No | Restrict to platforms, e.g. ['icqq']; empty means all |
scopes | No | Restrict to 'private' | 'group' | 'channel'; empty means all |
permissions | No | Permission rules; all must pass |
hidden | No | Registered but not offered to the model; still callable by name |
alwaysLoaded | No | true sends the schema to the model on every turn; default false (deferred). See Keep a tool resident |
execute(input, context) | Yes | The function; context described above |
Permission rules
permissions is parsed by @zhin.js/permission: adapter(name), group(id,...), private(id,...), channel(id,...), user(id,...), role(master|trusted|user), plus adapter-checked platform identity platform(adapter,perm) (e.g. group owner / admin). All rules in the list must pass; commas inside one rule mean any-of. Unrecognized rules are always denied.
Prompt Section fields
| Field | Meaning |
|---|---|
layer | Semantic layer: role / task / context / safety, etc. |
order | Within a layer, larger numbers come first |
retention | When over budget: required can't be dropped (the turn fails explicitly instead of silently truncating); preferred is kept preferentially; opportunistic yields first |
maxChars | Character limit for this section |
profiles | interactive (chat), schedule (scheduled runs), or both |
platforms | Optional: inject only on given platforms, e.g. ['github'] |
The total budget is ai.agent.systemPromptMaxChars.
Tool discovery settings
Tools aren't all stuffed into the prompt. ai.agent.deferredTools (ZhinAgentConfig) controls on-demand loading:
| Key | Default | Description |
|---|---|---|
maxLoadedPerSession | 12 | Max tools loaded at once per conversation |
discoverTopK | 5 | Results returned by discover |
alwaysLoadedTools | ['ask_user', 'spawn_task', 'discover', 'load_tool', 'load_skill'] | Always visible to the model; configured names are added to the defaults |
alwaysLoadedMode | 'extend' | 'replace' makes alwaysLoadedTools replace the defaults entirely |
mcpServers | {} | Per-MCP-server overrides of the alwaysLoaded list |
How it works
- Admission. At the start of each turn, tools are filtered by the message's platform, scope and sender; tools that don't qualify are invisible to the model.
- On-demand loading. By default only
alwaysLoadedTools, platform-only tools that passed admission, and plugin tools declaredalwaysLoaded: trueare exposed. The model searches tools and Skills withdiscover(names and summaries), loads a tool's parameter schema withload_tool, and loads a Skill's full text — unlocking its tools — withload_skill. Loaded state is kept per conversation and evicted past the limit; concurrent turns and sub-tasks are isolated. The Anthropic channel sends unloaded tools with adeferLoadingflag; other channels send only loaded ones. - Approval and execution. After admission and before execution,
requiresApprovalis checked; execution is bound to the owning plugin's config and resources.
Implementation details
- Both registration styles write to one capability table published by a single
ToolIndex; there is no second dynamic registry.addTool()writes during the new version's preparation; if preparation fails it never becomes visible, and on success it goes live with the whole version. - The admission predicate is Core's
canAccessTool(tool, message)(packages/im/core/src/built/tool.ts), applied on the Plugin Runtime side throughCapabilityIngress(packages/im/agent/src/plugin-runtime/capability-ingress.ts).platformscomparesString(message.$adapter);scopescomparesmessage.$channel.type(defaultprivate). - Model-visible names (
qualifiedName): root plugin tools keep their local name; child plugin tools join the plugin path segments and folder name with__, e.g.maps__get-weather. Execution stays bound to the original plugin's pinned version context. - On-demand loading is managed by a per-turn deferred controller (
packages/im/agent/src/tool-catalog/deferred-turn-controller.ts), with state inDeferredToolSessionSnapshot, never keyed by IMMessage. ask_useris a framework capability that requests input through the current turn'sQuestionPort; unattended turns (e.g. schedules) get no port and never fall back to a global message, adapter or user queue.- Agent / Skill private tools are validated during startup preparation but don't enter the model's tool catalog early.
Next steps
- AI overview: models, trigger rules, memory and MCP.
- Agent deep dive: security policies, approval, sub-tasks and execution logs.
- AI Agent learning path: add an Agent to an IM bot step by step.