Skip to content

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 ​

ts
// 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.

  • description and inputSchema are written for the model: say clearly what the tool does and what the parameters are, so it gets called at the right moment.
  • inputSchema can be a Zod 4 object or a JSON Schema whose root is object. The framework validates arguments before calling.
  • Whatever execute returns goes back to the model, which turns it into a reply.

Common tasks ​

Restrict where a tool is available ​

ts
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 ​

ts
export default defineAgentTool({
  description: 'Delete a record',
  requiresApproval: 'always',
  // ...
});
ValueWhen 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:

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:

FieldDescription
configThis plugin's config
use(token)Get services the plugin provides
origin / principalWhich conversation the call came from and which user it acts for
policyThe security policy in effect
questionAsk the user something (next section)
$clientPlatform 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):

ts
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:

LocationVisible to the model
tools/<name>/index.tsOnce the plugin is enabled (discoverable)
skills/<skill>/tools/<name>/index.tsAfter that Skill is loaded
agents/<agent>/tools/<name>/index.tsWhen that sub-agent is selected
agents/<agent>/skills/<skill>/tools/<name>/index.tsWhen 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:

ts
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 false keeps 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, scopes or permissions stays invisible, and hidden tools 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 exactly alwaysLoadedTools and plugin declarations are ignored. Listing a tool name in alwaysLoadedTools also makes it resident, without the limit of 8.
  • Platform-only tools (with platforms set) 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:

ts
// 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.

  • order decides position within a layer; retention decides 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:

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.

markdown
---
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:

FileContents
agent.jsonName, model, extra public tools, etc.
system.mdRole and responsibilities
boundaries.mdWhat it must not do
conventions.mdWorking 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.

text
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.ts only 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 ​

FieldRequiredDescription
descriptionYesWhat the tool does, for the model
inputSchemaNoZod 4 object or a JSON Schema with an object root; validated before execution
requiresApprovalNo'never' | 'on-risk' | 'once' | 'always', default 'on-risk'
platformsNoRestrict to platforms, e.g. ['icqq']; empty means all
scopesNoRestrict to 'private' | 'group' | 'channel'; empty means all
permissionsNoPermission rules; all must pass
hiddenNoRegistered but not offered to the model; still callable by name
alwaysLoadedNotrue sends the schema to the model on every turn; default false (deferred). See Keep a tool resident
execute(input, context)YesThe 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 ​

FieldMeaning
layerSemantic layer: role / task / context / safety, etc.
orderWithin a layer, larger numbers come first
retentionWhen over budget: required can't be dropped (the turn fails explicitly instead of silently truncating); preferred is kept preferentially; opportunistic yields first
maxCharsCharacter limit for this section
profilesinteractive (chat), schedule (scheduled runs), or both
platformsOptional: 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:

KeyDefaultDescription
maxLoadedPerSession12Max tools loaded at once per conversation
discoverTopK5Results 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 ​

  1. 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.
  2. On-demand loading. By default only alwaysLoadedTools, platform-only tools that passed admission, and plugin tools declared alwaysLoaded: true are exposed. The model searches tools and Skills with discover (names and summaries), loads a tool's parameter schema with load_tool, and loads a Skill's full text — unlocking its tools — with load_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 a deferLoading flag; other channels send only loaded ones.
  3. Approval and execution. After admission and before execution, requiresApproval is 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 through CapabilityIngress (packages/im/agent/src/plugin-runtime/capability-ingress.ts). platforms compares String(message.$adapter); scopes compares message.$channel.type (default private).
  • 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 in DeferredToolSessionSnapshot, never keyed by IM Message.
  • ask_user is a framework capability that requests input through the current turn's QuestionPort; 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 ​