Lineage
Lineage records how a session came to exist. It is a closed union with three states:
root no parent spawn fresh child, no copied conversation └── parent session created by a command or tool fork copied conversation through one anchor └── parent session later turns diverge independently
Creation methods
| Call | Lineage | History |
|---|---|---|
ctx.ai.sessions.create() | root | Empty |
session.spawn() | spawn | Empty child inheriting model and working directory |
session.fork() | fork | Copies the parent through its current end |
turn.fork() | fork | Copies the parent through that exact completed turn |
agentHandle.spawn() | root | Empty session configured by the declared agent |
agentHandle.spawn() uses the workspace session catalog. It does not create a
child of the current session. Use ctx.session.spawn() when parent-child
lineage matters.
Background work from the current turn
This slash command forks the current conversation, runs work, then creates a report fork anchored after the completed work turn. The summary returns to the original conversation as a notification.
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 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 } from '@modular/sdk'; const const task: SlashCommand<"task">task = slashCommand<"task">(slashCommandValue: SlashCommandInput<"task">): SlashCommand<"task">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: "task"id: 'task', description: stringdescription: 'Fork this conversation and run background work.', argumentHint?: string | undefinedargumentHint: '<prompt>', run: (argument: string | undefined, ctx: SlashCommandContext) => Awaitable<unknown>run: async (argument: string | undefinedargument, ctx: SlashCommandContextctx) => { const const prompt: string | undefinedprompt = argument: string | undefinedargument?.String.trim(): stringRemoves the leading and trailing white space and line terminator characters from a string.trim(); if (const prompt: string | undefinedprompt === var undefinedundefined || const prompt: stringprompt.String.length: numberReturns the length of a String object.length === 0) { return 'Usage: /task <prompt>'; } const const work: AiSessionHandlework = await ctx: SlashCommandContextctx.session: AiSessionHandleThe chat session the command was typed into.session.AiSessionHandle.fork(config?: AiSessionForkConfig): Promise<AiSessionHandle>Copy this conversation into a new session that continues independently. The child sees everything said up to now; nothing said in either session afterwards reaches the other.fork({ AiSessionForkConfig.metadata?: AiSessionDisplayMetadata | undefinedImmutable presentation metadata stored on the forked session.metadata: { label: stringlabel: 'Task', icon: stringicon: 'CodiconChecklist', }, }); const const completedWork: AiTurnHandlecompletedWork = await const work: AiSessionHandlework.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 prompt: stringprompt }); const const report: AiSessionHandlereport = await const completedWork: AiTurnHandlecompletedWork.AiTurnHandle.fork(config?: AiSessionForkConfig): Promise<AiSessionHandle>Fork this session immediately after this completed turn.fork({ AiSessionForkConfig.metadata?: AiSessionDisplayMetadata | undefinedImmutable presentation metadata stored on the forked session.metadata: { label: stringlabel: 'Task report' }, }); const const summary: AiRunResultsummary = await const report: AiSessionHandlereport.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 completed work, including files, commands, and results.' ); await ctx: SlashCommandContextctx.session: AiSessionHandleThe chat session the command was typed into.session.AiSessionHandle.notify(notification: Omit<AiNotificationMessage, "type">): Promise<AiTurnHandle>Shorthand for `send({ type: 'notification', ... })`. The message arrives tagged as a system event, not as something the user said, which is how background work reports back without impersonating the user.notify({ title: stringtitle: `Task finished: ${const prompt: stringprompt}`, body: stringbody: `${const summary: AiRunResultsummary.AiRunResult.text: stringtext}\n\nSession ${const work: AiSessionHandlework.AiSessionHandle.id: string & $brand<"SessionId">Stable identifier for this session.id}`, }); return `Started task ${const work: AiSessionHandlework.AiSessionHandle.id: string & $brand<"SessionId">Stable identifier for this session.id}.`; }, }); export defaultdefineMod<{ readonly metadata: { readonly displayName: "Background Tasks"; readonly description: "Fork conversations into background work"; }; readonly commands: readonly [SlashCommand<"task">]; }>(definition: { readonly metadata: { readonly displayName: "Background Tasks"; readonly description: "Fork conversations into background work"; }; readonly commands: readonly [SlashCommand<"task">]; }): 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: "Background Tasks"; readonly description: "Fork conversations into background work"; }metadata: { displayName: "Background Tasks"displayName: 'Background Tasks', description: "Fork conversations into background work"description: 'Fork conversations into background work', }, commands: readonly [SlashCommand<"task">]commands: [const task: SlashCommand<"task">task], });
The distinction between work.fork() and completedWork.fork() matters. The
turn handle is the exact anchor after the work finished. Forking the session
again only means "copy through the current end," which can change if another
turn enters the session first.
Conversation-relative commands such as /task require an existing
conversation with a completed turn. A fresh session has no conversation state
to branch from.
Read lineage
AiSessionLineage is discriminated by
kind:
| Kind | Important fields |
|---|---|
root | No parent fields |
spawn | parentSessionId, parentChatUri, depth, spawnedAt, trigger |
fork | parentSessionId, parentTurnOrdinal, parentRunnerId, depth, forkedAt |
Use session.parent() and session.children() for handles. Use
session.lineage when the creation record itself decides behavior.
import type { AiSessionHandle } from '@modular/sdk'; export function function describeOrigin(session: AiSessionHandle): stringdescribeOrigin(session: AiSessionHandlesession: AiSessionHandle): string { switch (session: AiSessionHandlesession.AiSessionHandle.lineage: AiSessionLineageHow this session came to exist: root, spawned, or forked.lineage.kind: "root" | "fork" | "spawn"kind) { case 'root': return 'Root conversation'; case 'spawn': return `Spawned at depth ${session: AiSessionHandlesession.AiSessionHandle.lineage: { readonly kind: "spawn"; readonly spawnedAt: string; readonly depth: number; readonly parentSessionId: SessionId; readonly parentChatUri: string; readonly trigger: AiSpawnTrigger; }How this session came to exist: root, spawned, or forked.lineage.depth: numberdepth}`; case 'fork': return `Forked after parent turn ${session: AiSessionHandlesession.AiSessionHandle.lineage: { readonly kind: "fork"; readonly depth: number; readonly forkedAt: string; readonly parentTurnOrdinal: number; readonly parentRunnerId: AiRunnerId; readonly parentSessionId: SessionId; readonly parentUserMessageId?: string; }How this session came to exist: root, spawned, or forked.lineage.parentTurnOrdinal: numberparentTurnOrdinal}`; } }
Do not flatten lineage into parentId?: string. That loses whether history was
copied, the exact fork anchor, and the command or tool that created a spawn.