Contributing tools
A tool is a function the model can call. Your extension registers one, and from that moment any agent in the host can use it: the tool’s name, description, and input schema go out with the request, the model decides to call it, and the host validates the arguments and runs your code.
After this page you can register a tool, write an input schema the model gets right, return a result that renders well in chat, and know exactly which approval gates apply to it.
Register the tool
Section titled “Register the tool”Tools are registered at runtime from your extension’s main half. There is no manifest key for the executable definition — the manifest only carries optional presentation metadata, covered further down.
import type { PluginContext } from '@wamp/extension-sdk';
export async function activate(ctx: PluginContext): Promise<void> { ctx.api.disposables.add( ctx.api.tools.register({ name: 'word_count', description: 'Count words in the provided text. Returns { words, characters, lines }.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to analyze' }, }, required: ['text'], additionalProperties: false, }, execute: async (input) => { const text = String(input.text ?? ''); return JSON.stringify({ words: text.trim().split(/\s+/).filter(Boolean).length, characters: text.length, lines: text.split('\n').length, }); }, }), );}register returns a disposable. Hand it to ctx.api.disposables and the host
unregisters the tool when your extension deactivates or reloads; skip that and
the tool outlives the code behind it.
No permission is required. ctx.api.tools is always present.
The definition
Section titled “The definition”| Field | Required | What it does |
|---|---|---|
name |
yes | The identifier the model calls. Must match [a-zA-Z0-9_-]+. |
description |
yes | The only thing telling the model when to use this. Write it for a reader who cannot see your code. |
inputSchema |
yes | JSON Schema for the arguments. Sent to the model and enforced before execute. |
execute |
yes | (input, ctx) => Promise<string | ToolContentBlock[]> |
category |
no | Functional grouping, defaults to workflow. Agent definitions accept category names as well as tool names when they select a tool set. |
display |
no | Presentation hints: { kind, icon, label, activityVerb }. |
policy |
no | Behavioral hints: { readOnly, mutates, longRunning, compactable, persistenceQuota, untrustedSource }. |
Names are global — prefix them
Section titled “Names are global — prefix them”The name you register is the name the model sees. Nothing is namespaced for you,
and registering a name another extension already owns throws
Tool "x" already registered by "other-extension". Prefix with something
specific to your extension — crm_create_contact rather than create — for the
same reason you would not export a global function called get.
Re-registering the same name from the same extension is allowed, which is what makes hot reload work while you are developing.
What the model sees
Section titled “What the model sees”Three things, and nothing else: the name, the description, and the input schema. It does not see your code, your category, or your display metadata.
That makes the description the highest-leverage part of the definition. Say what the tool does, what it returns, and when it is the right choice — the same information you would put in a docstring for a colleague who has never seen the module.
The input schema is compiled with Ajv when you register. An invalid schema throws
immediately (Invalid inputSchema for tool "x"), so a typo fails at activation
rather than mid-conversation. On every call the arguments are validated before
execute runs; a violation never reaches your code, and the model gets back
Invalid input for "x": /text must be string — which it can usually correct on
the next turn. Setting additionalProperties: false and listing required
fields is worth the two extra lines.
What the tool returns
Section titled “What the tool returns”Either a plain string, or an array of content blocks for multimodal output:
type ToolContentBlock = | { type: 'text'; text: string } | { type: 'image'; data: string; mediaType: string; dims?: { w: number; h: number } };Both shapes are normalized by the host, so a string is the right answer most of
the time. For structured results, JSON.stringify the object — the model reads
JSON well, and a stable key set is easier for it to use than prose.
If execute throws, the exception becomes an error result attached to the tool
call. The conversation continues and the model can react. It does not crash the
run, and your throw new Error('…') message is what the model reads, so make it
say what went wrong and what would fix it.
How the result renders
Section titled “How the result renders”Every tool call gets a card in the transcript. By default it is the generic card:
your icon and label, a running-then-settled status, and a one-line detail the
host derives from the call’s input — it looks for a recognizable key such as
path, url, command, query, description, or prompt, then falls back to
any short string in the input. Naming your schema’s most descriptive field one of
those is the cheapest way to make the card readable.
Which card renders is chosen from the tool’s display metadata, with the generic card as the fallback for an unrecognized kind. You can supply that metadata two ways; pick one.
Inline, alongside the definition. Good when the tool and its presentation live in the same file.
ctx.api.tools.register({ name: 'crm_create_contact', description: 'Create a CRM contact and return its id.', category: 'workflow', display: { kind: 'extension', icon: 'UserPlusIcon', label: 'Create contact', activityVerb: 'Creating contact' }, policy: { readOnly: false, mutates: true }, inputSchema: { /* … */ }, execute: async (input) => { /* … */ },});Declaratively, in the manifest. Visible in the marketplace listing and
applied before your main has run, which matters for a lazily activated
extension.
{ "contributes": { "toolMetadata": [ { "id": "crm_create_contact", "icon": "UserPlusIcon", "label": "Create contact", "category": "extension", "readOnly": false } ] }}| Metadata field | Effect |
|---|---|
icon |
Icon on the tool card and in the activity strip. |
label |
Short human name. Without it, the card humanizes the tool name (word_count → “Word count”). |
activityVerb |
Present-progressive copy while the tool runs (“Creating contact”). Inline display only. |
category / display.kind |
Which card component renders the call. Unknown values get the generic card. |
readOnly |
Skips the approval prompt in the strictest mode. |
longRunning |
Uses the extended 10-minute timeout instead of the 60-second default. |
compactable |
Lets old results be dropped during context compaction. |
persistenceQuota |
Character threshold above which results spill to disk instead of filling the context. |
Do not declare both for the same tool id. The manifest entry is registered first and wins; the inline block is then skipped.
Consent, approval, and dangerous work
Section titled “Consent, approval, and dangerous work”Three separate gates sit between the model’s decision and your execute. Know
which ones you can influence, because two of them are not yours.
Approval mode. The user chooses one per conversation.
| Mode | Behavior |
|---|---|
ask_before |
Every tool prompts, except tools marked readOnly. |
auto |
Tools on the user’s dangerous list prompt; everything else runs. |
auto_full |
Nothing prompts. |
When a prompt is raised, the user sees the tool name and its arguments with allow,
deny, and allow-for-this-session as the choices. A tool that is not marked
readOnly is presented as potentially destructive, because from the host’s point
of view an unknown tool might be. Denial comes back to the model as
Tool use denied by user: <name>, and the request fails closed if it goes
unanswered for five minutes.
The one lever you have here is policy.readOnly (or readOnly in
toolMetadata). Set it on a tool with no side effects and it stops interrupting
the user in ask_before mode. Set it on a tool that writes and you have removed
a safeguard the user was relying on.
Plan mode. While a conversation is planning, tools declared
policy.mutates: true are refused with an explanation telling the model to
leave plan mode first. Declare mutates: true on anything with externally
observable side effects. This is the honest way to make a write-shaped tool
behave correctly in a read-only phase, and it costs you one line.
Structural hard-deny. A short list of patterns that cannot be approved at
all — rm -rf /, curl … | sh, force-pushing a protected branch. Today every
one of them is scoped to the built-in shell tool, so it does not apply to
extension tools. Do not treat it as a backstop for yours.
A fourth gate is available to extensions rather than imposed on them: a
PreToolUse hook can inspect any tool call and block it with a reason. That is
how an extension enforces a policy across tools it does not own.
Extension tools versus built-in tools
Section titled “Extension tools versus built-in tools”They land in the same registry and reach the model the same way. What differs:
| Built-in | From an extension | |
|---|---|---|
| Registered by | The platform, at startup | Your activate(), via ctx.api.tools.register |
| Owner | core |
Your extension id |
| Execution context | Rich internal context — file service, terminal, workspace paths | A deliberately minimal public context |
| Name lists | Present in the platform’s safe and dangerous name lists | Absent from both; you supply readOnly / mutates instead |
| Availability | Always | Registered when your extension activates |
That last row has a consequence worth knowing. If your extension is lazily
activated, declare onTool:<name> as an activation event and the host will
activate you the first time the model calls the tool, then look the tool up
again:
{ "activationEvents": ["onTool:crm_create_contact"] }Without that, a tool whose extension has not activated yet resolves to
Unknown tool.
The execution context is intentionally small. execute receives (input, ctx),
and ctx is typed unknown because the only stable public member is a
reverse-RPC requester used when your extension’s server half needs to drive
something that physically lives on the user’s machine. Everything else your tool
needs — storage, HTTP, secrets — you already have from the ctx.api captured in
activate().
Scoping tools to your own session
Section titled “Scoping tools to your own session”A page that runs its own AI session can narrow the tool set to just yours, so the model is not choosing between your three tools and the platform’s several dozen:
const ai = useAISession({ agent: 'meta', tools: { include: ['crm_*'] }, systemPromptExtension: 'You manage contacts in this CRM.',});Names compare exactly; a trailing * matches a prefix. exclude works the same
way. An empty include array gives the session no tools at all, which is the
right setting for a draft-this-text button that must not have side effects.
This is also the second reason to prefix your tool names — a prefix is what makes
include a one-line filter.
A complete tool pack
Section titled “A complete tool pack”A headless extension with no UI, contributing three tools. This is the whole extension.
{ "name": "AI Tool Pack", "version": "1.0.0", "engines": { "wamp": "^12.0.0" }, "description": "Utility tools for any agent.", "main": "dist/main.js", "icon": "Wrench", "permissions": [], "activationEvents": ["onStartupFinished"], "contributes": { "toolMetadata": [ { "id": "ai_pack_now", "icon": "ClockIcon", "label": "Current time", "category": "extension", "readOnly": true }, { "id": "ai_pack_uuid", "icon": "HashIcon", "label": "Generate UUID", "category": "extension", "readOnly": true }, { "id": "ai_pack_word_count", "icon": "FileTextIcon", "label": "Word count", "category": "extension", "readOnly": true } ] }}import type { PluginContext } from '@wamp/extension-sdk';
export async function activate(ctx: PluginContext): Promise<void> { ctx.api.disposables.add( ctx.api.tools.register({ name: 'ai_pack_now', description: 'Return the current time as an ISO 8601 timestamp.', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, execute: async () => new Date().toISOString(), }), );
ctx.api.disposables.add( ctx.api.tools.register({ name: 'ai_pack_uuid', description: 'Generate a new random UUID v4.', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, execute: async () => globalThis.crypto.randomUUID(), }), );
ctx.api.disposables.add( ctx.api.tools.register({ name: 'ai_pack_word_count', description: 'Count words in the provided text. Returns JSON: { words, characters, lines }.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to analyze' }, }, required: ['text'], additionalProperties: false, }, execute: async (input) => { const text = String(input.text ?? ''); return JSON.stringify({ words: text.trim().split(/\s+/).filter(Boolean).length, characters: text.length, lines: text.split('\n').length, }); }, }), );
ctx.log.info('ai-tool-pack activated — 3 tools registered');}
export async function deactivate(): Promise<void> { // Disposables added to ctx.api.disposables unregister the tools.}All three are pure functions of their input, so all three are readOnly: true
and none of them interrupt the user.
- Agents and skills — give a persona a curated tool set, or teach the model how to use yours well.
- Typed data — the store a write-shaped tool usually writes to.
- Permissions — what the rest of
ctx.apineeds declared.