Skip to content

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.

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.

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

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 }.

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.

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.

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.

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.

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.

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().

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 headless extension with no UI, contributing three tools. This is the whole extension.

extension.json
{
"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 }
]
}
}
main/activate.ts
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.api needs declared.