AI
Modular's AI API has two halves. A mod declares reusable capabilities when it loads, then uses runtime handles while commands, tools, views, and effects run.
defineMod handler context ├── ai.tools[] model-callable work ctx.ai.ask() ├── ai.agents[] reusable model roles ctx.ai.agents └── prompts[] composer instructions ctx.ai.sessions ctx.ai.models ctx.ai.transcription current conversation └── ctx.session session and turn operations inside tools and slash commands
Pick the owning primitive
| Need | Use | Why |
|---|---|---|
| Let the model call code | Tool | The model chooses when to invoke it |
| Reuse a named model role | Agent | The role owns a prompt and optional model |
| Let a person insert static instructions | Prompt | The normalized text enters the composer |
| Ask one question without retaining a handle | Sessions | ctx.ai.ask returns text directly |
| Keep conversation history | Session | A session owns history, model, metadata, and lineage |
| Observe or cancel submitted work | Turn | A turn handle owns one submission lifecycle |
| Branch or delegate work | Lineage | Spawn and fork preserve different relationships |
| Send context beside a prompt | Attachment | Attachments stay typed and separate from prompt text |
| Convert live audio to text | Transcription | Streaming owns provider, audio, and cancellation state |
One composed mod
This mod declares all three authoring primitives and uses the declared agent from a command.
import { function agent(input: AgentInput): AgentDeclare a reusable agent: a name, a system prompt, and optionally a model. This describes an agent, it does not run one. There is no handler, and nothing executes when the mod activates. Put the result under `ai.agents` and the host registers it so the user or another agent can call it by id, `<extensionId>/<name>`. That is the line between this and the rest of the AI surface. To actually run something, use `ctx.ai.ask` for a single answer or `ctx.ai.sessions` for a conversation. To give the model a capability it can invoke, write a `tool`, which does have a handler.agent, function command<TId extends string>(commandValue: CommandInput<TId>): Command<TId>Declare an action the user runs from the command palette or a keybinding. Reach for this when a person triggers the behavior. The `title` is what they read in the palette, `category` groups the entry, and `when` decides whether it shows up at all. The handler takes no argument, only a context. Two neighbours are easy to confuse. `slashCommand` is also user-triggered, but it is typed inside a chat session and receives free text plus the live AI session. `tool` is not user-triggered at all, the model decides to call it mid-turn. The result goes in the mod's top-level `commands` array, alongside any slash commands. The `id` is the address a keybinding or a `command:` hook targets, so keep it stable.command, 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 prompt(input: PromptInput): PromptDeclare reusable prompt text the user can drop into the composer. Put the result in the mod's top-level `prompts` array. It is text and nothing more: no model, no handler, no execution. An agent is the next step up, it binds prompt text to a model under a callable name. The same `Prompt` value can serve both, handed to `agent({ prompt })` and listed in `prompts`, so the agent and the user work from one wording.prompt, 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 CountFindingsInput { readonly CountFindingsInput.findings: readonly string[]findings: readonly string[]; } const const countFindings: PlainTool<"review_count_findings", CountFindingsInput>countFindings = tool<"review_count_findings", CountFindingsInput>(toolValue: PlainToolInput<"review_count_findings", CountFindingsInput>): PlainTool<"review_count_findings", CountFindingsInput> (+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<"review_count_findings">.name: "review_count_findings"name: 'review_count_findings', ToolInputCommon<TName extends string>.description: stringdescription: 'Count the confirmed findings in a completed review.', PlainToolInput<"review_count_findings", CountFindingsInput>.run(input: CountFindingsInput, 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: CountFindingsInputinput: CountFindingsInput) =>var String: StringConstructor (value?: any) => stringAllows manipulation and formatting of text strings and determination and location of substrings within strings.String(input: CountFindingsInputinput.CountFindingsInput.findings: readonly string[]findings.ReadonlyArray<string>.length: numberGets the length of the array. This is a number one higher than the highest element defined in an array.length), }); const const reviewInstructions: PromptreviewInstructions = function prompt(input: PromptInput): PromptDeclare reusable prompt text the user can drop into the composer. Put the result in the mod's top-level `prompts` array. It is text and nothing more: no model, no handler, no execution. An agent is the next step up, it binds prompt text to a model under a callable name. The same `Prompt` value can serve both, handed to `agent({ prompt })` and listed in `prompts`, so the agent and the user work from one wording.prompt({ PromptInput.name: stringShort identifier, also what the user sees when picking the prompt.name: 'review-instructions', PromptInput.description: stringOne line telling the user what inserting this will do.description: 'Insert the standard review instructions.', PromptInput.content: stringThe text inserted into the composer. {@link prompt } removes the common indentation and surrounding whitespace, so multiline template literals can follow the surrounding TypeScript indentation.content: 'Report confirmed defects with exact file locations.', }); const const reviewer: Agentreviewer = function agent(input: AgentInput): AgentDeclare a reusable agent: a name, a system prompt, and optionally a model. This describes an agent, it does not run one. There is no handler, and nothing executes when the mod activates. Put the result under `ai.agents` and the host registers it so the user or another agent can call it by id, `<extensionId>/<name>`. That is the line between this and the rest of the AI surface. To actually run something, use `ctx.ai.ask` for a single answer or `ctx.ai.sessions` for a conversation. To give the model a capability it can invoke, write a `tool`, which does have a handler.agent({ AgentInput.name: stringShort identifier. The callable agent id is `<extensionId>/<name>`.name: 'reviewer', AgentInput.description: stringWhat this agent is good for. Shown to the user and read by the model when routing.description: 'Reviews a change and reports confirmed defects.', AgentInput.prompt: string | PromptThe system prompt, either inline text or a {@link Prompt } shared with the composer.prompt: const reviewInstructions: PromptreviewInstructions, }); const const reviewChange: Command<"review.current-change">reviewChange = command<"review.current-change">(commandValue: CommandInput<"review.current-change">): Command<"review.current-change">Declare an action the user runs from the command palette or a keybinding. Reach for this when a person triggers the behavior. The `title` is what they read in the palette, `category` groups the entry, and `when` decides whether it shows up at all. The handler takes no argument, only a context. Two neighbours are easy to confuse. `slashCommand` is also user-triggered, but it is typed inside a chat session and receives free text plus the live AI session. `tool` is not user-triggered at all, the model decides to call it mid-turn. The result goes in the mod's top-level `commands` array, alongside any slash commands. The `id` is the address a keybinding or a `command:` hook targets, so keep it stable.command({ id: "review.current-change"id: 'review.current-change', title: stringtitle: 'Review Current Change', run: (ctx: CommandContext) => Awaitable<unknown>run: async ctx: CommandContextctx => { const const handle: AiAgentHandlehandle = await ctx: CommandContextctx.ContextBase.ai: AiRuntimeApiOne off answers, agents, models, and sessions.ai.AiRuntimeApi.agents: AiAgentsApiagents.AiAgentsApi.get(agent: string | Agent): Promise<AiAgentHandle>get(const reviewer: Agentreviewer); const const result: AiRunResultresult = await const handle: AiAgentHandlehandle.AiAgentHandle.run(prompt: string, options?: AiSendOptions): Promise<AiRunResult> (+1 overload)run('Review the current workspace change.'); await ctx: CommandContextctx.ui: UiCapabilityNotifications, dialogs, quick input, and progress.ui.UiCapability.notification: UiNotificationCapabilitynotification.UiNotificationCapability.info(request: UiNotificationRequest): Promise<UiActionResult>info({ UiNotificationRequest.message: stringmessage: const result: AiRunResultresult.AiRunResult.text: stringtext }); }, }); export defaultdefineMod<{ readonly metadata: { readonly displayName: "Review"; readonly description: "Reusable review capabilities"; }; readonly prompts: readonly [Prompt]; readonly ai: { readonly tools: readonly [PlainTool<"review_count_findings", CountFindingsInput>]; readonly agents: readonly [Agent]; }; readonly commands: readonly [Command<"review.current-change">]; }>(definition: { readonly metadata: { readonly displayName: "Review"; readonly description: "Reusable review capabilities"; }; readonly prompts: readonly [Prompt]; readonly ai: { readonly tools: readonly [PlainTool<"review_count_findings", CountFindingsInput>]; readonly agents: readonly [Agent]; }; readonly commands: readonly [Command<"review.current-change">]; }): 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: "Review"; readonly description: "Reusable review capabilities"; }metadata: { displayName: "Review"displayName: 'Review', description: "Reusable review capabilities"description: 'Reusable review capabilities', }, prompts: readonly [Prompt]prompts: [const reviewInstructions: PromptreviewInstructions],ai: { readonly tools: readonly [PlainTool<"review_count_findings", CountFindingsInput>]; readonly agents: readonly [Agent]; }ai: { tools: readonly [PlainTool<"review_count_findings", CountFindingsInput>]tools: [const countFindings: PlainTool<"review_count_findings", CountFindingsInput>countFindings], agents: readonly [Agent]agents: [const reviewer: Agentreviewer], }, commands: readonly [Command<"review.current-change">]commands: [const reviewChange: Command<"review.current-change">reviewChange], });
The values compose without another registry or schema:
Promptis listed for the composer and passed directly toagent.Agentis listed for registration and passed directly toctx.ai.agents.get.- The tool's input annotation becomes its runtime input contract.
The declared agent tool allowlist is not executable yet, so this example does
not attach countFindings to reviewer. The Agents guide covers
that boundary explicitly.
The runtime map
| Surface | Lifetime | Primary result |
|---|---|---|
ctx.ai.ask | One request | string |
ctx.ai.agents | Declared-agent catalog | Metadata or AiAgentHandle |
ctx.ai.sessions | Workspace session catalog | Metadata or AiSessionHandle |
ctx.session | Current conversation | AiSessionHandle |
AiSessionHandle.send | One submitted turn | AiTurnHandle |
AiSessionHandle.run | One completed turn | Text or validated data |
ctx.ai.models | Current model catalog | AiModelInfo[] |
ctx.ai.transcription | One audio stream | Live parts and a final result |
Use the narrowest surface that owns the state you need. Do not reconstruct session or turn lifecycle from rendered transcript messages when handles already expose that lifecycle directly.