Skip to content

Contributing tools

Register a function in the host tool registry, then let an agent select it by name or category. The model receives its name, description, and input schema; the host validates arguments before executing your code. Registration alone does not add the tool to every agent’s active selection.

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,
},
policy: { readOnly: true, parallelSafe: true },
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.
group no Presents several related tools to the model as one operation named after the group. Calls carry { op, args }; approval and execution still use the original tool.
display no Presentation hints: { kind, icon, label, activityVerb }.
policy no Behavioral hints: { readOnly, mutates, parallelSafe, longRunning, compactable, persistenceQuota, untrustedSource, egress }. egress: 'url' or 'any' makes the user allow each destination once per chat outside Full access.

Use the same group on every operation in a large tool family. The model sees one browser operation instead of a separate top-level tool for every browser action:

import type { PluginContext } from '@wamp/extension-sdk';
export function registerBrowserOpen(
ctx: PluginContext,
openBrowserUrl: (url: string) => Promise<string>,
): void {
ctx.api.disposables.add(ctx.api.tools.register({
name: 'browser_open',
group: 'browser',
description: 'Open a URL in the browser.',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
additionalProperties: false,
},
execute: async (input) => openBrowserUrl(String(input.url)),
}));
}

Read-only tools run in parallel by default. Set parallelSafe: false when a read operation drives shared interactive state and overlapping calls could observe or change each other’s cursor, tab, or selection:

import type { PluginContext } from '@wamp/extension-sdk';
export function registerActiveTabReader(
ctx: PluginContext,
readActiveTab: () => Promise<string>,
): void {
ctx.api.disposables.add(ctx.api.tools.register({
name: 'browser_read_active_tab',
group: 'browser',
description: 'Read text from the active browser tab.',
policy: { readOnly: true, parallelSafe: false },
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
execute: async () => readActiveTab(),
}));
}

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 Every tool prompts, except tools marked readOnly.
workspace (default) Work inside the project runs. Built-in commands run in an OS sandbox on macOS and Linux when available; leaving it, installs, metered generation and MCP tools that require approval prompt. Your tool runs without a prompt.
full Nothing routine prompts.

A run nobody is watching (a trigger, a Goal continuation, a headless caller) cannot answer a prompt, so anything that would prompt is refused. Only a run explicitly started with full proceeds without prompts; a caller that names no mode gets workspace.

When a prompt is raised, the user sees the tool name and its arguments with Allow, Deny, and Always allow in this chat as the host’s choices. An attended foreground built-in shell prompt can also offer Always allow in this project. It shows the full command, effective directory, timeout and confinement; the saved answer applies only to the same command and directory at that confinement and no longer timeout. The user can revoke it in Desktop Settings → General. Unattended runs never use saved answers, and extension tools cannot create project rules. 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 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. If you omit policy entirely, the host defaults runtime tools to mutating unless manifest metadata explicitly marks them read-only. If you supply a policy object, declare its mutation behavior accurately.

The command sandbox. On macOS and Linux the built-in shell runs in an OS sandbox unless the chat has full access: writes stay in the project and a private temp directory, credential files are unreadable, and there is no outbound network without an approved request. It confines the built-in shell only. Your extension’s own processes run with your extension’s authority, so it is not a backstop for yours. Linux needs the system bubblewrap package and usable user namespaces; when the sandbox cannot start, commands ask before running.

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
Permission classification Fixed by the platform You supply readOnly / mutates
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 headless extension with no UI, contributing three tools. This is the whole extension.

extension.json
{
"name": "AI Tool Pack",
"version": "1.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.
}

None of the three mutates workspace or application data, so each is 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.