Tools
A tool is a typed capability the model can call during a turn. The model reads its description, supplies the input, and receives the result.
| Shape | Handler returns | Transcript |
|---|---|---|
| Plain tool | Text or an AiToolResult | The host's standard tool row |
| Rendered tool | Your typed result | Text for the model, with an optional custom view |
A plain tool
Use a plain tool when text is the complete result.
import { function defineMod<const TMod extends ModDefinition>(definition: TMod & ValidateModHooks<TMod>): ModThe entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.defineMod, function tool<TName extends string, TInput>(toolValue: PlainToolInput<TName, TInput>): PlainTool<TName, TInput> (+1 overload)Declare a capability the AI agent can call during a turn. This is the one member of the trio the user does not trigger. The model reads `description` and decides on its own whether to call, so write that field for the model, not for a menu. Say what the tool does and when it is the right choice. `command` and `slashCommand` are the user-triggered neighbours. The input contract is derived from the annotation on `run`'s first parameter, so there is no schema to hand-write and no second place for the shape to drift. The handler runs in the ext-host with full Node access. Its context carries the calling session, the tool call id, an activity channel for progress, and a cancellation signal worth honoring on long work. Tools go under `ai.tools` in `defineMod`, not in `commands`.tool } from '@modular/sdk'; interface CountWordsInput { readonly CountWordsInput.passage: stringpassage: string; } const const countWords: PlainTool<"research_word_count", CountWordsInput>countWords = tool<"research_word_count", CountWordsInput>(toolValue: PlainToolInput<"research_word_count", CountWordsInput>): PlainTool<"research_word_count", CountWordsInput> (+1 overload)Declare a capability the AI agent can call during a turn. This is the one member of the trio the user does not trigger. The model reads `description` and decides on its own whether to call, so write that field for the model, not for a menu. Say what the tool does and when it is the right choice. `command` and `slashCommand` are the user-triggered neighbours. The input contract is derived from the annotation on `run`'s first parameter, so there is no schema to hand-write and no second place for the shape to drift. The handler runs in the ext-host with full Node access. Its context carries the calling session, the tool call id, an activity channel for progress, and a cancellation signal worth honoring on long work. Tools go under `ai.tools` in `defineMod`, not in `commands`.tool({ ToolInputCommon<"research_word_count">.name: "research_word_count"name: 'research_word_count', ToolInputCommon<TName extends string>.description: stringdescription: 'Count the words in a passage.', PlainToolInput<"research_word_count", CountWordsInput>.run(input: CountWordsInput, ctx: ToolContext): AiToolHandlerResult | Promise<AiToolHandlerResult>Does the work. Annotate the first parameter, the compiler reads that annotation to build the input contract the model is given.run: async (input: CountWordsInputinput: CountWordsInput, ctx: ToolContextctx) => { await ctx: ToolContextctx.tool: ToolInvocationtool.ToolInvocation.activity: { update(update: AiToolCallActivityUpdate): Promise<void>; }activity.function update(update: AiToolCallActivityUpdate): Promise<void>update({ kind: "status"kind: 'status', text: stringtext: 'Counting words', }); if (ctx: ToolContextctx.cancellationSignal: AbortSignalcancellationSignal.AbortSignal.aborted: booleanThe **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (true) or not (false). [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted)aborted) { return 'Cancelled before counting.'; } const const words: string[]words = input: CountWordsInputinput.CountWordsInput.passage: stringpassage .String.split(splitter: { [Symbol.split](string: string, limit?: number): string[]; }, limit?: number): string[] (+1 overload)Split a string into substrings using the specified separator and return them as an array.split(/\s+/) .Array<string>.filter(predicate: (value: string, index: number, array: string[]) => unknown, thisArg?: any): string[] (+1 overload)Returns the elements of an array that meet the condition specified in a callback function.filter(word: stringword => word: stringword.String.length: numberReturns the length of a String object.length > 0); returnvar String: StringConstructor (value?: any) => stringAllows manipulation and formatting of text strings and determination and location of substrings within strings.String(const words: string[]words.Array<string>.length: numberGets or sets the length of the array. This is a number one higher than the highest index in the array.length); }, }); export defaultdefineMod<{ readonly metadata: { readonly displayName: "Research"; readonly description: "Model-facing research capabilities"; }; readonly ai: { readonly tools: readonly [PlainTool<"research_word_count", CountWordsInput>]; }; }>(definition: { readonly metadata: { readonly displayName: "Research"; readonly description: "Model-facing research capabilities"; }; readonly ai: { readonly tools: readonly [PlainTool<"research_word_count", CountWordsInput>]; }; }): ModThe entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.defineMod({metadata: { readonly displayName: "Research"; readonly description: "Model-facing research capabilities"; }metadata: { displayName: "Research"displayName: 'Research', description: "Model-facing research capabilities"description: 'Model-facing research capabilities', },ai: { readonly tools: readonly [PlainTool<"research_word_count", CountWordsInput>]; }ai: { tools: readonly [PlainTool<"research_word_count", CountWordsInput>]tools: [const countWords: PlainTool<"research_word_count", CountWordsInput>countWords] }, });
The input annotation is the contract. Modular generates the runtime validator
from CountWordsInput, so the author does not maintain a second Zod or JSON
Schema definition.
A typed result
Use a rendered tool when the result has structure worth keeping. run returns
the domain value. view.text decides what the model reads.
import { function defineMod<const TMod extends ModDefinition>(definition: TMod & ValidateModHooks<TMod>): ModThe entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.defineMod, function tool<TName extends string, TInput>(toolValue: PlainToolInput<TName, TInput>): PlainTool<TName, TInput> (+1 overload)Declare a capability the AI agent can call during a turn. This is the one member of the trio the user does not trigger. The model reads `description` and decides on its own whether to call, so write that field for the model, not for a menu. Say what the tool does and when it is the right choice. `command` and `slashCommand` are the user-triggered neighbours. The input contract is derived from the annotation on `run`'s first parameter, so there is no schema to hand-write and no second place for the shape to drift. The handler runs in the ext-host with full Node access. Its context carries the calling session, the tool call id, an activity channel for progress, and a cancellation signal worth honoring on long work. Tools go under `ai.tools` in `defineMod`, not in `commands`.tool } from '@modular/sdk'; interface SearchInput { readonly SearchInput.query: stringquery: string; readonly SearchInput.limit: numberlimit: number; } interface SearchHit { readonly SearchHit.path: stringpath: string; readonly SearchHit.line: numberline: number; readonly SearchHit.excerpt: stringexcerpt: string; } interface SearchResult { readonly SearchResult.query: stringquery: string; readonly SearchResult.hits: readonly SearchHit[]hits: readonly SearchHit[]; } const const searchWorkspace: RenderedTool<"research_search_workspace", SearchInput, SearchResult>searchWorkspace = tool<"research_search_workspace", SearchInput, SearchResult>(toolValue: RenderedToolInput<"research_search_workspace", SearchInput, SearchResult>): RenderedTool<"research_search_workspace", SearchInput, SearchResult> (+1 overload)Declare a tool that returns a typed result and renders its own view. Same call as the plain form, with a `view` added. `view.text` produces what the model reads, so `run` returns your own type rather than a string.tool({ ToolInputCommon<"research_search_workspace">.name: "research_search_workspace"name: 'research_search_workspace', ToolInputCommon<TName extends string>.description: stringdescription: 'Search the workspace and return matching source locations.', RenderedToolInput<"research_search_workspace", SearchInput, SearchResult>.run(input: SearchInput, ctx: ToolContext): SearchResult | Promise<SearchResult>Does the work. Both the input and result contracts come from this signature's annotations, so type it precisely.run: async (input: SearchInputinput: SearchInput): interface Promise<T>Represents the completion of an asynchronous operationPromise<SearchResult> => ({ SearchResult.query: stringquery: input: SearchInputinput.SearchInput.query: stringquery, SearchResult.hits: readonly SearchHit[]hits: [], }), RenderedToolInput<"research_search_workspace", SearchInput, SearchResult>.view: ToolView<SearchInput, SearchResult>How the result reaches the model, and optionally the transcript.view: { text: (result: SearchResult) => stringtext: result: SearchResultresult => result: SearchResultresult.SearchResult.hits: readonly SearchHit[]hits .ReadonlyArray<SearchHit>.map<string>(callbackfn: (value: SearchHit, index: number, array: readonly SearchHit[]) => string, thisArg?: any): string[]Calls a defined callback function on each element of an array, and returns an array that contains the results.map(hit: SearchHithit => `${hit: SearchHithit.SearchHit.path: stringpath}:${hit: SearchHithit.SearchHit.line: numberline} ${hit: SearchHithit.SearchHit.excerpt: stringexcerpt}`) .Array<string>.join(separator?: string): stringAdds all the elements of an array into a string, separated by the specified separator string.join('\n'), }, }); export defaultdefineMod<{ readonly metadata: { readonly displayName: "Research"; readonly description: "Model-facing research capabilities"; }; readonly ai: { readonly tools: readonly [RenderedTool<"research_search_workspace", SearchInput, SearchResult>]; }; }>(definition: { readonly metadata: { readonly displayName: "Research"; readonly description: "Model-facing research capabilities"; }; readonly ai: { readonly tools: readonly [RenderedTool<"research_search_workspace", SearchInput, SearchResult>]; }; }): ModThe entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.defineMod({metadata: { readonly displayName: "Research"; readonly description: "Model-facing research capabilities"; }metadata: { displayName: "Research"displayName: 'Research', description: "Model-facing research capabilities"description: 'Model-facing research capabilities', },ai: { readonly tools: readonly [RenderedTool<"research_search_workspace", SearchInput, SearchResult>]; }ai: { tools: readonly [RenderedTool<"research_search_workspace", SearchInput, SearchResult>]tools: [const searchWorkspace: RenderedTool<"research_search_workspace", SearchInput, SearchResult>searchWorkspace] }, });
The input and result annotations are both compiled into runtime contracts. A
custom view.render is optional. Add one only when a human needs more than the
standard transcript row.
A custom transcript view
view.render receives a discriminated lifecycle state and one owned webview.
Handle all four phases instead of guessing whether input or result exists.
import { function defineMod<const TMod extends ModDefinition>(definition: TMod & ValidateModHooks<TMod>): ModThe entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.defineMod, function tool<TName extends string, TInput>(toolValue: PlainToolInput<TName, TInput>): PlainTool<TName, TInput> (+1 overload)Declare a capability the AI agent can call during a turn. This is the one member of the trio the user does not trigger. The model reads `description` and decides on its own whether to call, so write that field for the model, not for a menu. Say what the tool does and when it is the right choice. `command` and `slashCommand` are the user-triggered neighbours. The input contract is derived from the annotation on `run`'s first parameter, so there is no schema to hand-write and no second place for the shape to drift. The handler runs in the ext-host with full Node access. Its context carries the calling session, the tool call id, an activity channel for progress, and a cancellation signal worth honoring on long work. Tools go under `ai.tools` in `defineMod`, not in `commands`.tool } from '@modular/sdk'; interface DependencyInput { readonly DependencyInput.packageName: stringpackageName: string; } interface DependencyResult { readonly DependencyResult.packageName: stringpackageName: string; readonly DependencyResult.dependents: readonly string[]dependents: readonly string[]; } const const inspectDependency: RenderedTool<"dependencies_inspect", DependencyInput, DependencyResult>inspectDependency = tool<"dependencies_inspect", DependencyInput, DependencyResult>(toolValue: RenderedToolInput<"dependencies_inspect", DependencyInput, DependencyResult>): RenderedTool<"dependencies_inspect", DependencyInput, DependencyResult> (+1 overload)Declare a tool that returns a typed result and renders its own view. Same call as the plain form, with a `view` added. `view.text` produces what the model reads, so `run` returns your own type rather than a string.tool({ ToolInputCommon<"dependencies_inspect">.name: "dependencies_inspect"name: 'dependencies_inspect', ToolInputCommon<TName extends string>.description: stringdescription: 'Find packages that depend on one workspace package.', RenderedToolInput<"dependencies_inspect", DependencyInput, DependencyResult>.run(input: DependencyInput, ctx: ToolContext): DependencyResult | Promise<DependencyResult>Does the work. Both the input and result contracts come from this signature's annotations, so type it precisely.run: async (input: DependencyInputinput: DependencyInput): interface Promise<T>Represents the completion of an asynchronous operationPromise<DependencyResult> => ({ DependencyResult.packageName: stringpackageName: input: DependencyInputinput.DependencyInput.packageName: stringpackageName, DependencyResult.dependents: readonly string[]dependents: [], }), RenderedToolInput<"dependencies_inspect", DependencyInput, DependencyResult>.view: ToolView<DependencyInput, DependencyResult>How the result reaches the model, and optionally the transcript.view: { text: ToolResultText<DependencyResult>text: result: DependencyResultresult => `${result: DependencyResultresult.DependencyResult.packageName: stringpackageName} has ${result: DependencyResultresult.DependencyResult.dependents: readonly string[]dependents.ReadonlyArray<string>.length: numberGets the length of the array. This is a number one higher than the highest element defined in an array.length} dependents.`, render: ToolViewRenderer<DependencyInput, DependencyResult>render: (state: ToolViewRenderState<DependencyInput, DependencyResult>state, ctx: ToolViewRenderContextctx) => { switch (state: ToolViewRenderState<DependencyInput, DependencyResult>state.phase: "preparing" | "failed" | "running" | "completed"phase) { case 'preparing': ctx: ToolViewRenderContextctx.ToolViewRenderContext.webview: ToolViewWebviewwebview.ToolViewWebview.html: stringhtml = '<p>Preparing dependency inspection.</p>'; return; case 'running': ctx: ToolViewRenderContextctx.ToolViewRenderContext.webview: ToolViewWebviewwebview.ToolViewWebview.html: stringhtml = '<p>Inspecting workspace dependencies.</p>'; return; case 'completed': ctx: ToolViewRenderContextctx.ToolViewRenderContext.webview: ToolViewWebviewwebview.ToolViewWebview.html: stringhtml = `<p>Found ${state: { readonly phase: "completed"; readonly input: DependencyInput; readonly result: DependencyResult; }state.result: DependencyResultresult.DependencyResult.dependents: readonly string[]dependents.ReadonlyArray<string>.length: numberGets the length of the array. This is a number one higher than the highest element defined in an array.length} dependents.</p>`; return; case 'failed': ctx: ToolViewRenderContextctx.ToolViewRenderContext.webview: ToolViewWebviewwebview.ToolViewWebview.html: stringhtml = '<p>Dependency inspection failed.</p>'; return; } }, }, }); export defaultdefineMod<{ readonly metadata: { readonly displayName: "Dependencies"; readonly description: "Inspect workspace dependencies"; }; readonly ai: { readonly tools: readonly [RenderedTool<"dependencies_inspect", DependencyInput, DependencyResult>]; }; }>(definition: { readonly metadata: { readonly displayName: "Dependencies"; readonly description: "Inspect workspace dependencies"; }; readonly ai: { readonly tools: readonly [RenderedTool<"dependencies_inspect", DependencyInput, DependencyResult>]; }; }): ModThe entry point of the SDK. A mod's module default-exports one `defineMod` call and nothing else. The returned value is data. It registers nothing, starts nothing, and touches no host state. The host folds it twice. At build time it runs the module in a throwaway sandbox and reads the value to derive the serializable manifest. At runtime it folds the same value again to wire your handlers into their lanes: ext-host for commands, tools, effects, and hooks; the sandboxed renderer for views and widgets. That double fold is why the module must be pure. It is re-executed to produce the manifest, so anything at the top level, a file read, a network call, a timer, a `console` line, runs during a build with no host attached. Put behavior inside handlers: `run`, `activate`, `handle`. The `ValidateModHooks` constraint makes the call fail to typecheck when a `hook.beforeAction` targets an action that is neither a known host action nor `command:` plus one of this mod's own command ids.defineMod({metadata: { readonly displayName: "Dependencies"; readonly description: "Inspect workspace dependencies"; }metadata: { displayName: "Dependencies"displayName: 'Dependencies', description: "Inspect workspace dependencies"description: 'Inspect workspace dependencies', },ai: { readonly tools: readonly [RenderedTool<"dependencies_inspect", DependencyInput, DependencyResult>]; }ai: { tools: readonly [RenderedTool<"dependencies_inspect", DependencyInput, DependencyResult>]tools: [const inspectDependency: RenderedTool<"dependencies_inspect", DependencyInput, DependencyResult>inspectDependency] }, });
view.text is required because it is the model-facing result. view.render is
optional presentation for a person. Do not make the model scrape the custom
view to recover the result.
Tool context
ToolContext gives the handler four things
that only exist during a tool call:
| Member | Use |
|---|---|
ctx.session | The session running the tool |
ctx.tool.toolCallId | The host-owned invocation identity |
ctx.tool.activity.update(...) | A title, current status, or ordered log entry |
ctx.cancellationSignal | Cancellation for long-running work |
Do not retain the context after run finishes. The invocation owner disposes
its activity and cancellation bridge with the call.
Activity mutations have different state rules:
| Update | Rule |
|---|---|
{ kind: 'title', text } | First title wins |
{ kind: 'status', text } | Replaces the current running status |
{ kind: 'log', message } | Appends an ordered, host-identified log entry |
Completed and cancelled tool calls ignore later activity updates.
Result shapes
A plain tool returns either a string or an AiToolResult containing text and
an optional { mimeType, content } output. A rendered tool returns its own
typed result and must provide view.text.
Use the smallest shape that preserves meaning. Do not return anonymous JSON as a string when the result is a stable domain value that a rendered tool can model directly.
Placement and reachability
Tools go under ai.tools, not in commands. A person invokes a command. A
model invokes a tool.
An agent has a tools field intended to scope what it may call.
That allowlist is not executable yet. Leave it off because even an empty array
currently fails activation.