Skip to main content

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

CallLineageHistory
ctx.ai.sessions.create()rootEmpty
session.spawn()spawnEmpty child inheriting model and working directory
session.fork()forkCopies the parent through its current end
turn.fork()forkCopies the parent through that exact completed turn
agentHandle.spawn()rootEmpty 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>): 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 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.
@categoryAuthoring
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.
@categoryAuthoring
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(): string
Removes 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: number
Returns the length of a String object.
length
=== 0) {
return 'Usage: /task <prompt>'; } const const work: AiSessionHandlework = await ctx: SlashCommandContextctx.session: AiSessionHandle
The 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 | undefined
Immutable 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 | undefined
Immutable 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: AiSessionHandle
The 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 default
defineMod<{
    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">];
}): 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: "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:

KindImportant fields
rootNo parent fields
spawnparentSessionId, parentChatUri, depth, spawnedAt, trigger
forkparentSessionId, 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: AiSessionLineage
How 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.