# Contributing tools Register a function the AI can call — its input schema, its result, how it renders in chat, and what the approval system does with it. Source: https://docs.vampikez.fun/build/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. ## 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. ```ts check // main/activate.ts export async function activate(ctx: PluginContext): Promise { 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. ### 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` | | `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: ```ts check export function registerBrowserOpen( ctx: PluginContext, openBrowserUrl: (url: string) => Promise, ): 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: ```ts check export function registerActiveTabReader( ctx: PluginContext, readActiveTab: () => Promise, ): 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(), })); } ``` :::caution[A dot in the name throws at registration] The name is validated against `[a-zA-Z0-9_-]+` and a violation throws `Invalid tool name "x.y" — use only letters, numbers, underscores, hyphens`, which fails your `activate()` and faults the extension. Dots are excluded because several model providers reject them in tool names. Use `_` as the separator: `crm_create_contact`, not `crm.create_contact`. ::: ### 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 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 Either a plain string, or an array of content blocks for multimodal output: ```ts 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 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. ```ts 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. ```json { "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 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: `, 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. :::caution[You cannot mark your own tool as always-requiring-approval] The platform keeps one fixed list of built-in actions that prompt in `workspace`; your tool is not on it. In `workspace` mode a tool you did not mark `readOnly` still runs without a prompt. If an operation is destructive enough that it must be confirmed, confirm it inside `execute` — put the check in your own code rather than assuming the platform will ask. ::: **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. ## 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 | | 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:` as an activation event and the host will activate you the first time the model calls the tool, then look the tool up again: ```json { "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 complete tool pack A headless extension with no UI, contributing three tools. This is the whole extension. ```json title="extension.json" check { "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 } ] } } ``` ```ts check // main/activate.ts export async function activate(ctx: PluginContext): Promise { 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 { // 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. ## Next - [Agents and skills](/build/agents-and-skills/) — give a persona a curated tool set, or teach the model how to use yours well. - [Typed data](/build/typed-data/) — the store a write-shaped tool usually writes to. - [Permissions](/build/permissions/) — what the rest of `ctx.api` needs declared.