Skip to main content

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.

ShapeHandler returnsTranscript
Plain toolText or an AiToolResultThe host's standard tool row
Rendered toolYour typed resultText 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>): Mod
The 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.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
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`.
@example```ts tool({ name: 'review_search', description: 'Search the workspace for a term and return matching lines.', run: async (input: { readonly query: string }, ctx) => { await ctx.tool.activity.update({ kind: 'status', text: 'searching' }); const hits = await search(input.query, ctx.cancellationSignal); return hits.join('\n'); }, }); ```@categoryAuthoring
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`.
@example```ts tool({ name: 'review_search', description: 'Search the workspace for a term and return matching lines.', run: async (input: { readonly query: string }, ctx) => { await ctx.tool.activity.update({ kind: 'status', text: 'searching' }); const hits = await search(input.query, ctx.cancellationSignal); return hits.join('\n'); }, }); ```@categoryAuthoring
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: boolean
The **`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.
@paramsplitter An object that can split a string.@paramlimit A value used to limit the number of elements returned in the 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.
@parampredicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
filter
(word: stringword => word: stringword.String.length: number
Returns the length of a String object.
length
> 0);
return
var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String
(const words: string[]words.Array<string>.length: number
Gets or sets the length of the array. This is a number one higher than the highest index in the array.
length
);
}, }); export default
defineMod<{
    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>];
    };
}): Mod
The 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.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
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>): Mod
The 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.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
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`.
@example```ts tool({ name: 'review_search', description: 'Search the workspace for a term and return matching lines.', run: async (input: { readonly query: string }, ctx) => { await ctx.tool.activity.update({ kind: 'status', text: 'searching' }); const hits = await search(input.query, ctx.cancellationSignal); return hits.join('\n'); }, }); ```@categoryAuthoring
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 operation
Promise
<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.
@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
(hit: SearchHithit => `${hit: SearchHithit.SearchHit.path: stringpath}:${hit: SearchHithit.SearchHit.line: numberline} ${hit: SearchHithit.SearchHit.excerpt: stringexcerpt}`)
.Array<string>.join(separator?: string): string
Adds all the elements of an array into a string, separated by the specified separator string.
@paramseparator A string used to separate one element of the array from the next in the resulting string. If omitted, the array elements are separated with a comma.
join
('\n'),
}, }); export default
defineMod<{
    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>];
    };
}): Mod
The 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.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
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>): Mod
The 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.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
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`.
@example```ts tool({ name: 'review_search', description: 'Search the workspace for a term and return matching lines.', run: async (input: { readonly query: string }, ctx) => { await ctx.tool.activity.update({ kind: 'status', text: 'searching' }); const hits = await search(input.query, ctx.cancellationSignal); return hits.join('\n'); }, }); ```@categoryAuthoring
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 operation
Promise
<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: number
Gets 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: number
Gets 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 default
defineMod<{
    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>];
    };
}): Mod
The 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.
@example```ts export default defineMod({ metadata: { displayName: 'Review', description: 'Review helpers for the current change.', }, commands: [ command({ id: 'review.open', title: 'Review: Open', run: openPanel }), slashCommand({ id: 'review', description: 'Review this change', run: review }), ], ai: { tools: [reviewTool], }, }); ```@categoryAuthoring
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:

MemberUse
ctx.sessionThe session running the tool
ctx.tool.toolCallIdThe host-owned invocation identity
ctx.tool.activity.update(...)A title, current status, or ordered log entry
ctx.cancellationSignalCancellation 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:

UpdateRule
{ 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.