Skip to main content

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

NeedUseWhy
Let the model call codeToolThe model chooses when to invoke it
Reuse a named model roleAgentThe role owns a prompt and optional model
Let a person insert static instructionsPromptThe normalized text enters the composer
Ask one question without retaining a handleSessionsctx.ai.ask returns text directly
Keep conversation historySessionA session owns history, model, metadata, and lineage
Observe or cancel submitted workTurnA turn handle owns one submission lifecycle
Branch or delegate workLineageSpawn and fork preserve different relationships
Send context beside a promptAttachmentAttachments stay typed and separate from prompt text
Convert live audio to textTranscriptionStreaming 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): Agent
Declare 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.
@exampleA reviewer agent sharing its prompt with the composer ```ts const reviewPrompt = prompt({ name: 'review-checklist', description: 'Insert the standard review checklist.', content: 'Review correctness, cancellation, and cleanup.', }); const reviewer = agent({ name: 'reviewer', description: 'Reviews a change and reports concrete defects.', prompt: reviewPrompt, }); ```@categoryAuthoring
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.
@categoryAuthoring
command
, 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 prompt(input: PromptInput): Prompt
Declare 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.
@example```ts const commitStyle = prompt({ name: 'commit-style', description: 'Insert the commit message rules for this repo.', content: ` # Commit message Start with a bracketed area, for example `[terminal]`. `, }); ```@categoryAuthoring
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`.
@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 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`.
@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<"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) => string
Allows 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: number
Gets 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): Prompt
Declare 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.
@example```ts const commitStyle = prompt({ name: 'commit-style', description: 'Insert the commit message rules for this repo.', content: ` # Commit message Start with a bracketed area, for example `[terminal]`. `, }); ```@categoryAuthoring
prompt
({
PromptInput.name: string
Short identifier, also what the user sees when picking the prompt.
name
: 'review-instructions',
PromptInput.description: string
One line telling the user what inserting this will do.
description
: 'Insert the standard review instructions.',
PromptInput.content: string
The 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): Agent
Declare 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.
@exampleA reviewer agent sharing its prompt with the composer ```ts const reviewPrompt = prompt({ name: 'review-checklist', description: 'Insert the standard review checklist.', content: 'Review correctness, cancellation, and cleanup.', }); const reviewer = agent({ name: 'reviewer', description: 'Reviews a change and reports concrete defects.', prompt: reviewPrompt, }); ```@categoryAuthoring
agent
({
AgentInput.name: string
Short identifier. The callable agent id is `<extensionId>/<name>`.
name
: 'reviewer',
AgentInput.description: string
What 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 | Prompt
The 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.
@categoryAuthoring
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: AiRuntimeApi
One 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: UiCapability
Notifications, 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 default
defineMod<{
    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">];
}): 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: "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:

  • Prompt is listed for the composer and passed directly to agent.
  • Agent is listed for registration and passed directly to ctx.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

SurfaceLifetimePrimary result
ctx.ai.askOne requeststring
ctx.ai.agentsDeclared-agent catalogMetadata or AiAgentHandle
ctx.ai.sessionsWorkspace session catalogMetadata or AiSessionHandle
ctx.sessionCurrent conversationAiSessionHandle
AiSessionHandle.sendOne submitted turnAiTurnHandle
AiSessionHandle.runOne completed turnText or validated data
ctx.ai.modelsCurrent model catalogAiModelInfo[]
ctx.ai.transcriptionOne audio streamLive 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.