Turns
A session owns conversation history. A turn owns one submitted unit of work.
| Method | Waits for completion | Returns | Use it when |
|---|---|---|---|
run(prompt) | Yes | { text } | The caller needs the final answer |
run<T>(prompt) | Yes | Validated T | The caller needs domain data, not prose |
send(message) | No | AiTurnHandle | The caller needs observation, cancellation, or an exact-turn fork |
notify(...) | No | AiTurnHandle | Background work must report without impersonating the user |
Completed text
import { 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 } from '@modular/sdk'; export defaultdefineMod<{ readonly metadata: { readonly displayName: "Summaries"; readonly description: "Generate concise summaries"; }; readonly commands: readonly [Command<string>]; }>(definition: { readonly metadata: { readonly displayName: "Summaries"; readonly description: "Generate concise summaries"; }; readonly commands: readonly [Command<string>]; }): 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: "Summaries"; readonly description: "Generate concise summaries"; }metadata: { displayName: "Summaries"displayName: 'Summaries', description: "Generate concise summaries"description: 'Generate concise summaries', }, commands: readonly [Command<string>]commands: [ command<string>(commandValue: CommandInput<string>): Command<string>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: stringid: 'summaries.workspace', title: stringtitle: 'Summarize Workspace', run: (ctx: CommandContext) => Awaitable<unknown>run: async ctx: CommandContextctx => { const const session: AiSessionHandlesession = await ctx: CommandContextctx.ContextBase.ai: AiRuntimeApiOne off answers, agents, models, and sessions.ai.AiRuntimeApi.sessions: AiSessionsApisessions.AiSessionsApi.create(config?: AiSessionConfig): Promise<AiSessionHandle>Start a new root session.create({ AiSessionConfig.title?: string | undefinedtitle: 'Summary' }); const const result: AiRunResultresult = await const session: AiSessionHandlesession.AiSessionHandle.run(prompt: string, options?: AiSendOptions): Promise<AiRunResult> (+1 overload)Send a prompt and wait for the finished answer. The generic overload validates the reply against `T` and rejects if the model returns something that does not match, so a caller never has to parse prose.run( 'Summarize the current workspace in five concrete bullets.' ); ctx: CommandContextctx.ContextBase.logger: LoggerYour mod's log channel.logger.Logger.info(message: string, ...args: readonly unknown[]): voidinfo(const result: AiRunResultresult.AiRunResult.text: stringtext); }, }), ], });
The text overload returns AiRunResult.
It rejects when the turn fails or is cancelled.
Validated domain data
The structured overload derives a runtime contract from the TypeScript type argument. The model response must satisfy that contract before the promise resolves.
import { 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 } from '@modular/sdk'; interface ReleaseRisk { readonly ReleaseRisk.area: "data" | "runtime" | "interface"area: 'data' | 'runtime' | 'interface'; readonly ReleaseRisk.severity: "low" | "medium" | "high"severity: 'low' | 'medium' | 'high'; readonly ReleaseRisk.evidence: readonly string[]evidence: readonly string[]; } interface ReleaseReview { readonly ReleaseReview.summary: stringsummary: string; readonly ReleaseReview.risks: readonly ReleaseRisk[]risks: readonly ReleaseRisk[]; } export defaultdefineMod<{ readonly metadata: { readonly displayName: "Release Review"; readonly description: "Produce validated release reviews"; }; readonly commands: readonly [Command<string>]; }>(definition: { readonly metadata: { readonly displayName: "Release Review"; readonly description: "Produce validated release reviews"; }; readonly commands: readonly [Command<string>]; }): 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: "Release Review"; readonly description: "Produce validated release reviews"; }metadata: { displayName: "Release Review"displayName: 'Release Review', description: "Produce validated release reviews"description: 'Produce validated release reviews', }, commands: readonly [Command<string>]commands: [ command<string>(commandValue: CommandInput<string>): Command<string>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: stringid: 'release-review.structured', title: stringtitle: 'Run Structured Release Review', run: (ctx: CommandContext) => Awaitable<unknown>run: async ctx: CommandContextctx => { const const session: AiSessionHandlesession = await ctx: CommandContextctx.ContextBase.ai: AiRuntimeApiOne off answers, agents, models, and sessions.ai.AiRuntimeApi.sessions: AiSessionsApisessions.AiSessionsApi.create(config?: AiSessionConfig): Promise<AiSessionHandle>Start a new root session.create({ AiSessionConfig.title?: string | undefinedtitle: 'Release review' }); const const review: ReleaseReviewreview = await const session: AiSessionHandlesession.AiSessionHandle.run<ReleaseReview>(prompt: string, options?: AiSendOptions): Promise<ReleaseReview> (+1 overload)Send a prompt and wait for the finished answer. The generic overload validates the reply against `T` and rejects if the model returns something that does not match, so a caller never has to parse prose.run<ReleaseReview>( 'Return a release review that matches the requested structure.' ); ctx: CommandContextctx.ContextBase.logger: LoggerYour mod's log channel.logger.Logger.info(message: string, ...args: readonly unknown[]): voidinfo(`${const review: ReleaseReviewreview.ReleaseReview.risks: readonly ReleaseRisk[]risks.ReadonlyArray<T>.length: numberGets the length of the array. This is a number one higher than the highest element defined in an array.length} risks: ${const review: ReleaseReviewreview.ReleaseReview.summary: stringsummary}`); }, }), ], });
run<T> rejects on turn failure, cancellation, missing delivery, or invalid
data. Do not catch that error and parse the prose as a fallback. That would
discard the guarantee the structured overload exists to provide.
Observe and cancel a submission
send accepts a typed prompt or notification message and returns immediately
after the host accepts the submission.
import { function slashCommand<TId extends string>(slashCommandValue: SlashCommandInput<TId>): SlashCommand<TId>Declare a command the user types inside a chat session. The `id` is the name typed after the slash, `description` is the line shown in the typeahead, and `argumentHint` is the placeholder for whatever the user types after the name. The handler receives that text, or `undefined` when the user typed nothing, plus a context carrying the AI session the command was typed into. Use this instead of `command` when the behavior belongs to a conversation and needs the session. Use `command` when it belongs to the workbench and runs from the palette. Use `tool` when the model, not the user, should decide to run it. Slash commands go in the same top-level `commands` array as palette commands.slashCommand, 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 } from '@modular/sdk'; const const investigate: SlashCommand<"investigate">investigate = slashCommand<"investigate">(slashCommandValue: SlashCommandInput<"investigate">): SlashCommand<"investigate">Declare a command the user types inside a chat session. The `id` is the name typed after the slash, `description` is the line shown in the typeahead, and `argumentHint` is the placeholder for whatever the user types after the name. The handler receives that text, or `undefined` when the user typed nothing, plus a context carrying the AI session the command was typed into. Use this instead of `command` when the behavior belongs to a conversation and needs the session. Use `command` when it belongs to the workbench and runs from the palette. Use `tool` when the model, not the user, should decide to run it. Slash commands go in the same top-level `commands` array as palette commands.slashCommand({ id: "investigate"id: 'investigate', description: stringdescription: 'Start an investigation in the current conversation.', argumentHint?: string | undefinedargumentHint: '<question>', run: (argument: string | undefined, ctx: SlashCommandContext) => Awaitable<unknown>run: async (argument: string | undefinedargument, ctx: SlashCommandContextctx) => { const const question: string | undefinedquestion = argument: string | undefinedargument?.String.trim(): stringRemoves the leading and trailing white space and line terminator characters from a string.trim(); if (const question: string | undefinedquestion === var undefinedundefined || const question: stringquestion.String.length: numberReturns the length of a String object.length === 0) { return 'Usage: /investigate <question>'; } const const turn: AiTurnHandleturn = await ctx: SlashCommandContextctx.session: AiSessionHandleThe chat session the command was typed into.session.AiSessionHandle.send(message: AiMessage, options?: AiSendOptions): Promise<AiTurnHandle>Put a message into this session. The message's `type` says what kind of thing it is. Who produced it is stamped by the host from the calling context, so a mod cannot claim to be the user or another mod. Both kinds drive a turn in the session.send({ AiPromptMessage.type: "prompt"type: 'prompt', AiPromptMessage.text: stringtext: const question: stringquestion, }); const const terminal: AiTurnTerminalSnapshotterminal = await const turn: AiTurnHandleturn.AiTurnHandle.completion(): Promise<AiTurnTerminalSnapshot>completion(); switch (const terminal: AiTurnTerminalSnapshotterminal.kind: "completed" | "failed" | "cancelled"kind) { case 'completed': returnconst terminal: { readonly kind: "completed"; readonly submissionId: SubmissionId; readonly sessionId: SessionId; readonly turnId: TurnId; readonly result: AiRunResult; readonly delivery: AiTurnDelivery; }terminal.result: AiRunResultresult.AiRunResult.text: stringtext; case 'failed': return `Investigation failed: ${const terminal: { readonly kind: "failed"; readonly submissionId: SubmissionId; readonly sessionId: SessionId; readonly target: AiTurnTarget; readonly error: { readonly message: string; }; }terminal.error: { readonly message: string; }error.message: stringmessage}`; case 'cancelled': return 'Investigation cancelled.'; } }, }); export defaultdefineMod<{ readonly metadata: { readonly displayName: "Investigation"; readonly description: "Conversation investigation commands"; }; readonly commands: readonly [SlashCommand<"investigate">]; }>(definition: { readonly metadata: { readonly displayName: "Investigation"; readonly description: "Conversation investigation commands"; }; readonly commands: readonly [SlashCommand<"investigate">]; }): 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: "Investigation"; readonly description: "Conversation investigation commands"; }metadata: { displayName: "Investigation"displayName: 'Investigation', description: "Conversation investigation commands"description: 'Conversation investigation commands', }, commands: readonly [SlashCommand<"investigate">]commands: [const investigate: SlashCommand<"investigate">investigate], });
AiTurnHandle exposes:
| Member | Contract |
|---|---|
submissionId | Stable identity for this submission |
get() | Current queued, running, needs-input, or terminal snapshot |
observe(listener) | Snapshot changes until the listener is disposed |
completion() | One terminal completed, failed, or cancelled value |
cancel() | Requests cancellation and returns the terminal snapshot |
fork(config?) | Forks immediately after this completed turn |
Notifications
session.notify({ title, body }) is shorthand for a notification message. The
host marks it as a system notification. A mod cannot claim that the user sent
it.
Notifications still drive a turn and return an AiTurnHandle. Use them for
background reports, not for ordinary model prompts.