Skip to content
draft Visibility internal Owner erik@uvilo.com Approver _ Created 2026-06-08 Updated 2026-06-08

Migrate to uvilo-mono Plan 2

Scope: Build sub-agent spawning as a native AI Tool feature inside uvilo-mono, replacing the need for an external MCP server. Covers Research §4 Option B. This plan is self-contained — it does not reference any external spawn MCP server. Depends on Plan 1 (McpServerManager) for MCP tool integration in sub-agents; native AI tools work independently.


Functional Requirements

The spawn system must provide three tools available to any Bot at inference time:

ToolPurpose
spawnAgentCreate a sub-agent conversation, send initial message, run inference. Returns result synchronously or asynchronously (job ID). Optionally triggers an Inngest event on completion or runs under a different user’s context.
checkAgentQuery status and retrieve results from a previously spawned sub-agent.
abortAgentCancel a running sub-agent inference.

Each tool is a native AI Tool registered in @erikdakoda/ai-tool’s aiToolRegistry, NOT an MCP tool. This means:

  • No HTTP calls — sub-agent inference runs in-process via streamText() directly
  • No separate auth — the tool inherits the parent request’s authenticated session
  • No separate process management — sub-agents run as async operations within the BotCraft Node.js process

Architecture Overview

Parent Bot (streamText)

  ├─ calls spawnAgent tool
  │    │
  │    ├─ creates Convo in DB (sub-agent conversation)
  │    ├─ resolves Bot config (model, system prompt, tools)
  │    ├─ calls streamText() in-process for sub-agent
  │    │    │
  │    │    ├─ sub-agent may call its own tools (native AI + MCP)
  │    │    └─ sub-agent completes (or is aborted)
  │    │
  │    ├─ sync mode: blocks until done, returns final message
  │    └─ async mode: returns job ID immediately

  ├─ calls checkAgent tool (async mode only)
  │    └─ returns status + partial/full results

  └─ calls abortAgent tool
       └─ cancels sub-agent's streamText via AbortController

Key principle: Sub-agent inference is a nested streamText() call running in the same process. The parent tool blocks (sync) or detaches (async) while the sub-agent runs its own multi-step inference loop.


Source Repository Locations

All new code goes into the uvilo-mono monorepo:

ItemTarget PackagePath
AgentJob model@erikdakoda/inferencepackages/@erikdakoda/inference/models/AgentJob.zmodel
SubAgentManager@erikdakoda/inferencepackages/@erikdakoda/inference/sub-agent/SubAgentManager.ts
SubAgentJob@erikdakoda/inferencepackages/@erikdakoda/inference/sub-agent/SubAgentJob.ts
Types@erikdakoda/inferencepackages/@erikdakoda/inference/sub-agent/types.ts
spawnAgent AI Tool@erikdakoda/ai-toolpackages/@erikdakoda/ai-tool/ai-tools/spawnAgent.ts
checkAgent AI Tool@erikdakoda/ai-toolpackages/@erikdakoda/ai-tool/ai-tools/checkAgent.ts
abortAgent AI Tool@erikdakoda/ai-toolpackages/@erikdakoda/ai-tool/ai-tools/abortAgent.ts
Tool registrations@erikdakoda/ai-toolpackages/@erikdakoda/ai-tool/ai-server-tools/spawnAgent.ts etc.
BotCraft importapps/bot-craftapps/bot-craft/src/aiServerTools.ts

Existing packages used:

  • @erikdakoda/ai-tool — tool registry (registerAiTool, getAiTools, AiTool type)
  • @erikdakoda/inferenceChatPostHandler pattern, streamText() call, ConvoForInferenceSelect
  • @erikdakoda/botgetLanguageModel(), getModelParameters(), computeBotSystemPrompt(), Bot model
  • @erikdakoda/convoConvo model, getNewConvo(), getNewAiMessage(), convertAiMessageMetadata()
  • @erikdakoda/database — Prisma client (getEnhancedExtendedPrisma()), getServerAuthSession()
  • @erikdakoda/queue — Inngest client (sendInngestEvent()) for workflow continuation on sub-agent completion

Task 1 — Create AgentJob Database Model

Define the persistent storage for sub-agent jobs. Without a DB table, async job results are lost on server restart, there’s no audit trail, and checkAgent can’t survive across deploys. The in-memory SubAgentManager cache sits on top of this table.

1.1 Create ZenStack model

Create packages/@erikdakoda/inference/models/AgentJob.zmodel:

import "../../auth/models/NamedItem"
import "../../bot/models/Bot"
import "../../convo/models/Convo"
import "../../user/models/User"

enum AgentJobState {
  pending
  running
  completed
  failed
  aborted
}

enum SpawnMode {
  sync
  async
}

model AgentJob extends NamedItem {
  /** The Bot this sub-agent runs as. */
  bot                     Bot              @relation(fields: [botId], references: [id])
  botId                   String
  /** The conversation this sub-agent created. */
  convo                   Convo?           @relation(fields: [convoId], references: [id])
  convoId                 String?
  /** The parent conversation that spawned this job. */
  parentConvo             Convo?           @relation(fields: [parentConvoId], references: [id])
  parentConvoId           String?
  /** The user whose context the sub-agent runs under. */
  owner                   User             @relation(fields: [ownerId], references: [id])
  /** The user who triggered the spawn (may differ from owner when runAsUserId is used). */
  triggeredBy             User             @relation(fields: [triggeredById], references: [id])
  triggeredById           String

  state                   AgentJobState    @default(pending)
  mode                    SpawnMode        @default(sync)
  /** Final assistant message text (set on completion). */
  result                  String?
  /** Error message (set on failure). */
  error                   String?
  /** Token usage from the sub-agent inference. */
  inputTokens             Int              @default(0)
  outputTokens            Int              @default(0)
  reasoningTokens         Int              @default(0)
  totalTokens             Int              @default(0)
  /** Number of tool-use steps the sub-agent took. */
  steps                   Int              @default(0)
  /** Spawn depth (0 = top-level, 1 = first sub-agent, etc.). */
  depth                   Int              @default(0)

  /** Inngest event name to fire on completion (async mode only). Stored so the event fires even after restart. */
  completionEventName     String?
  /** Additional data for the Inngest completion event (JSON). */
  completionEventData     Json?
  /** Whether the Inngest completion event has been fired. */
  completionEventFired   Boolean          @default(false)

  /** The input message that initiated the sub-agent. */
  inputMessage            String
  /** Config overrides applied to this spawn (JSON). */
  overrides               Json?
  /** Bot reference at spawn time (botId or group+handle, JSON). */
  botRef                  Json

  startedAt               DateTime?
  completedAt             DateTime?

  @@index([ownerId])
  @@index([triggeredById])
  @@index([state])
  @@index([botId])
}

1.2 Add relations to existing models

Add back-references to Bot.zmodel and Convo.zmodel:

In packages/@erikdakoda/bot/models/Bot.zmodel, add to the Bot model:

agentJobs    AgentJob[]

In packages/@erikdakoda/convo/models/Convo.zmodel, add to the Convo model:

agentJobsAsSubAgent    AgentJob[]  @relation("AgentJobConvo")
agentJobsAsParent      AgentJob[]  @relation("AgentJobParentConvo")

In packages/@erikdakoda/auth/models/User.zmodel (or wherever the User model lives), add:

agentJobsAsOwner       AgentJob[]  @relation("AgentJobOwner")
agentJobsAsTrigger     AgentJob[]  @relation("AgentJobTrigger")

1.3 Run ZenStack/Prisma generation

cd /workspace/erik/uvilo-mono
pnpm zenstack generate
pnpm prisma generate

1.4 Create migration

pnpm prisma migrate dev --name add_agent_job

1.5 Verify

  • AgentJob table exists in the database
  • Prisma client includes AgentJob CRUD operations
  • Relations to Bot, Convo, and User compile without errors

Task 2 — Define Sub-Agent Types

Establish the TypeScript type definitions for sub-agent jobs, their lifecycle states, and the data structures passed between the spawn tools and the SubAgentManager.

2.1 Create type definitions

Create packages/@erikdakoda/inference/sub-agent/types.ts:

import type { AiMessageForUi } from '@dakoda/convo/types';
import type { IntegrationName, AiModelName } from '@dakoda/bot/shared/aiModels';
import type { ReasoningEffort } from '@dakoda/database';

/** Lifecycle states for a sub-agent job */
export type SubAgentJobState =
  | 'pending'     // Created, not yet started
  | 'running'     // Inference in progress
  | 'completed'   // Finished successfully
  | 'failed'      // Errored out
  | 'aborted';    // Cancelled by parent

/** How the parent waits for the sub-agent */
export type SpawnMode = 'sync' | 'async';

/** Configuration for resolving which Bot the sub-agent runs as */
export type SubAgentBotRef =
  | { botId: string }                          // Direct Bot ID
  | { botGroup: string; botHandle: string };   // Lookup by unique group+handle

/** Optional overrides for the sub-agent's inference config */
export interface SubAgentOverrides {
  /** Override the Bot's default model. Format: "<integration>:<modelName>" e.g. "openai:gpt-5.4-nano" */
  modelOverride?: string;
  /** Override the Bot's default system prompt. If set, replaces the computed system prompt entirely. */
  systemPromptOverride?: string;
  /** Append extra instructions to the Bot's system prompt. */
  systemPromptAppend?: string;
  /** Override temperature (0–2). If unset, uses Bot's default. */
  temperature?: number;
  /** Override reasoning effort. If unset, uses Bot's default. */
  reasoningEffort?: ReasoningEffort;
  /** Maximum number of tool-use steps the sub-agent may take (default: 25) */
  maxSteps?: number;
  /** Whitelist of tool names the sub-agent may use. Empty = all available tools. */
  toolFilter?: string[];
}

/** Input for the spawnAgent tool */
export interface SpawnAgentInput {
  /** The Bot to spawn as. Identified by ID or group+handle. */
  bot: SubAgentBotRef;
  /** The initial user message to send to the sub-agent. */
  message: string;
  /** Execution mode: 'sync' blocks until done; 'async' returns job ID immediately. Default: 'sync'. */
  mode?: SpawnMode;
  /** Optional config overrides for this spawn. */
  overrides?: SubAgentOverrides;
  /**
   * An Inngest event to trigger when the sub-agent completes.
   * Only valid in async mode. The event is sent with the job result as data,
   * enabling downstream Inngest functions to continue a workflow.
   * If omitted, no event is fired on completion.
   */
  completionEvent?: InngestCompletionEvent;
  /**
   * User ID whose context the sub-agent should run under.
   * If provided, the sub-agent creates the Convo and messages under this user
   * instead of the current (parent) user. This enables a user session to
   * trigger background agent sessions that run under a different user's account.
   * The caller must be authorized to spawn as this user (verified by
   * SpawnContext against allowed delegates, or the same user).
   * If omitted, defaults to the current user from SpawnContext.
   */
  runAsUserId?: string;
}

/** Inngest event payload to send on sub-agent completion */
export interface InngestCompletionEvent {
  /** The Inngest event name (e.g., "sub-agent/completed"). */
  name: string;
  /** Optional additional data to merge into the event payload alongside the job result. */
  data?: Record<string, unknown>;
}

/** Input for the checkAgent tool */
export interface CheckAgentInput {
  /** The job ID returned by spawnAgent (async mode). */
  jobId: string;
  /** If true and job is still running, return partial output (messages so far). Default: false. */
  includePartial?: boolean;
}

/** Input for the abortAgent tool */
export interface AbortAgentInput {
  /** The job ID of the running sub-agent to cancel. */
  jobId: string;
}

/** Output for the spawnAgent tool (sync mode) */
export interface SpawnAgentSyncOutput {
  mode: 'sync';
  jobId: string;
  convoId: string;
  /** Final assistant message text from the sub-agent. */
  result: string;
  /** All messages from the sub-agent conversation (user + assistant turns). */
  messages: AiMessageForUi[];
  /** Token usage from the sub-agent inference. */
  usage: {
    inputTokens: number;
    outputTokens: number;
    reasoningTokens: number;
    totalTokens: number;
  };
  /** Number of tool-use steps the sub-agent took. */
  steps: number;
  /** Final state of the job. */
  state: 'completed' | 'failed' | 'aborted';
  /** Error message if state is 'failed'. */
  error?: string;
}

/** Output for the spawnAgent tool (async mode) */
export interface SpawnAgentAsyncOutput {
  mode: 'async';
  jobId: string;
  convoId: string;
  state: 'running';
}

/** Output for the checkAgent tool */
export interface CheckAgentOutput {
  jobId: string;
  state: SubAgentJobState;
  /** Present when state is 'completed'. Final assistant message text. */
  result?: string;
  /** Present when state is 'completed' or includePartial is true. */
  messages?: AiMessageForUi[];
  /** Present when state is 'completed'. */
  usage?: SpawnAgentSyncOutput['usage'];
  /** Present when state is 'completed'. */
  steps?: number;
  /** Present when state is 'failed'. */
  error?: string;
}

/** Output for the abortAgent tool */
export interface AbortAgentOutput {
  jobId: string;
  state: 'aborted';
  /** Partial output captured before abort, if any. */
  partialResult?: string;
}

/** Internal job record tracked by SubAgentManager */
export interface SubAgentJobRecord {
  jobId: string;
  convoId: string;
  botId: string;
  parentConvoId: string;
  mode: SpawnMode;
  state: SubAgentJobState;
  createdAt: Date;
  startedAt?: Date;
  completedAt?: Date;
  /** The AbortController for cancelling inference. */
  abortController: AbortController;
  /** Resolved when inference completes. */
  completionPromise?: Promise<SpawnAgentSyncOutput>;
  /** Resolves/rejects the completion promise. */
  completionResolve?: (output: SpawnAgentSyncOutput) => void;
  completionReject?: (error: Error) => void;
  /** Cached output (set on completion). */
  output?: SpawnAgentSyncOutput;
  /** Error message (set on failure). */
  error?: string;
  /** Inngest event to fire on completion (from SpawnAgentInput). */
  completionEvent?: InngestCompletionEvent;
  /** User ID the sub-agent runs under (from SpawnAgentInput, or parent user). */
  effectiveUserId: string;
}

2.2 Verify

  • Types compile without errors against existing package types
  • No circular dependencies (types.ts imports only from @dakoda/convo/types, @dakoda/bot/shared/aiModels, @dakoda/database)

Task 3 — Build SubAgentManager

Singleton service that manages the lifecycle of all spawned sub-agents. Uses the AgentJob DB table as the source of truth for persistence, and keeps an in-memory cache of running jobs (with their AbortController) for real-time control.

3.1 Create SubAgentManager

Create packages/@erikdakoda/inference/sub-agent/SubAgentManager.ts:

import { getEnhancedExtendedPrisma } from '@dakoda/database/server/getEnhancedExtendedPrisma';
import { SubAgentJobRecord, SubAgentJobState, type SpawnMode } from './types';

export class SubAgentManager {
  private static instance: SubAgentManager;
  /** In-memory cache of running jobs (AbortController only lives in memory). */
  private runningJobs: Map<string, SubAgentJobRecord> = new Map();

  private constructor() {}

  static getInstance(): SubAgentManager {
    if (!SubAgentManager.instance) {
      SubAgentManager.instance = new SubAgentManager();
    }
    return SubAgentManager.instance;
  }

  /** Create a job: persist to DB and register in-memory. */
  async createJob(params: {
    jobId: string;
    botId: string;
    parentConvoId: string;
    ownerId: string;
    triggeredById: string;
    mode: SpawnMode;
    inputMessage: string;
    botRef: object;
    overrides?: object;
    completionEventName?: string;
    completionEventData?: object;
    depth: number;
  }): Promise<SubAgentJobRecord> {
    const db = await getEnhancedExtendedPrisma();

    // Persist to DB
    await db.agentJob.create({
      data: {
        id: params.jobId,
        name: `sub-agent-${params.jobId}`,
        botId: params.botId,
        parentConvoId: params.parentConvoId,
        ownerId: params.ownerId,
        triggeredById: params.triggeredById,
        mode: params.mode,
        inputMessage: params.inputMessage,
        botRef: params.botRef,
        overrides: params.overrides ?? null,
        completionEventName: params.completionEventName ?? null,
        completionEventData: params.completionEventData ?? null,
        depth: params.depth,
        state: 'pending',
      },
    });

    // Register in-memory
    const abortController = new AbortController();
    let completionResolve!: (output: any) => void;
    let completionReject!: (error: Error) => void;
    const completionPromise = new Promise((resolve, reject) => {
      completionResolve = resolve;
      completionReject = reject;
    });

    const record: SubAgentJobRecord = {
      jobId: params.jobId,
      convoId: '',
      botId: params.botId,
      parentConvoId: params.parentConvoId,
      mode: params.mode,
      state: 'pending',
      createdAt: new Date(),
      abortController,
      completionPromise,
      completionResolve,
      completionReject,
      effectiveUserId: params.ownerId,
    };

    this.runningJobs.set(params.jobId, record);
    return record;
  }

  /** Get a running job's in-memory record (for AbortController, completion promise, etc.). */
  getRunningJob(jobId: string): SubAgentJobRecord | undefined {
    return this.runningJobs.get(jobId);
  }

  /** Get any job from DB (for checkAgent, which may query completed jobs no longer in memory). */
  async getJob(jobId: string): Promise<any | null> {
    // Check memory first (running jobs have richer data like AbortController)
    const running = this.runningJobs.get(jobId);
    if (running) return running;

    const db = await getEnhancedExtendedPrisma();
    return db.agentJob.findUnique({ where: { id: jobId } });
  }

  /** Update job state in both DB and memory. */
  async setJobState(jobId: string, state: SubAgentJobState, error?: string): Promise<void> {
    const db = await getEnhancedExtendedPrisma();
    const updateData: any = { state };
    if (error) updateData.error = error;
    if (state === 'running') updateData.startedAt = new Date();
    if (state === 'completed' || state === 'failed' || state === 'aborted') {
      updateData.completedAt = new Date();
    }
    await db.agentJob.update({ where: { id: jobId }, data: updateData });

    const job = this.runningJobs.get(jobId);
    if (job) {
      job.state = state;
      if (error) job.error = error;
    }
  }

  /** Set job output: persist result to DB and resolve the in-memory completion promise. */
  async setJobOutput(jobId: string, output: any): Promise<void> {
    const db = await getEnhancedExtendedPrisma();
    await db.agentJob.update({
      where: { id: jobId },
      data: {
        state: 'completed',
        convoId: output.convoId,
        result: output.result,
        inputTokens: output.usage.inputTokens,
        outputTokens: output.usage.outputTokens,
        reasoningTokens: output.usage.reasoningTokens,
        totalTokens: output.usage.totalTokens,
        steps: output.steps,
        completedAt: new Date(),
      },
    });

    const job = this.runningJobs.get(jobId);
    if (job) {
      job.output = output;
      job.convoId = output.convoId;
      job.completionResolve?.(output);
    }
  }

  /** Set job error: persist to DB and reject the in-memory completion promise. */
  async setJobError(jobId: string, error: Error): Promise<void> {
    const db = await getEnhancedExtendedPrisma();
    await db.agentJob.update({
      where: { id: jobId },
      data: { state: 'failed', error: error.message, completedAt: new Date() },
    });

    const job = this.runningJobs.get(jobId);
    if (job) {
      job.error = error.message;
      job.completionReject?.(error);
    }
  }

  /** Mark Inngest completion event as fired in DB. */
  async markCompletionEventFired(jobId: string): Promise<void> {
    const db = await getEnhancedExtendedPrisma();
    await db.agentJob.update({
      where: { id: jobId },
      data: { completionEventFired: true },
    });
  }

  /** Abort a running job. */
  async abortJob(jobId: string): Promise<boolean> {
    const job = this.runningJobs.get(jobId);
    if (!job) return false;
    if (job.state !== 'running' && job.state !== 'pending') return false;
    job.abortController.abort();
    job.state = 'aborted';
    await this.setJobState(jobId, 'aborted');
    return true;
  }

  /** Remove completed jobs from the in-memory cache (DB records persist for audit). */
  evictFromMemory(jobId: string): void {
    this.runningJobs.delete(jobId);
  }

  /** Get all running jobs (for monitoring/health). */
  getRunningJobs(): SubAgentJobRecord[] {
    return Array.from(this.runningJobs.values());
  }

  /** Clean up in-memory cache for completed jobs older than maxAgeMs. */
  cleanupMemory(maxAgeMs: number = 30 * 60 * 1000): void {
    const cutoff = Date.now() - maxAgeMs;
    for (const [id, job] of this.runningJobs) {
      if (job.completedAt && job.completedAt.getTime() < cutoff) {
        this.runningJobs.delete(id);
      }
    }
  }
}

3.2 Key design: DB as source of truth, memory for real-time control

  • DB (AgentJob table): Persists all job data — survives restarts, provides audit trail, powers checkAgent for stale/completed jobs.
  • In-memory (runningJobs Map): Holds the AbortController and completion promise — these cannot be persisted. Only running/pending jobs are in memory.
  • getJob() checks memory first, then falls back to DB — checkAgent can query any job, even if the server restarted.
  • evictFromMemory() removes completed jobs from the cache without deleting the DB record.

3.3 Startup recovery: fire unfired Inngest events

On BotCraft startup, SubAgentManager should check for completed async jobs where completionEventFired = false and fire their events. This handles the case where the server restarts between sub-agent completion and event dispatch:

async recoverUnfiredEvents(): Promise<void> {
  const db = await getEnhancedExtendedPrisma();
  const unfiredJobs = await db.agentJob.findMany({
    where: {
      completionEventFired: false,
      NOT: { completionEventName: null },
      state: { in: ['completed', 'failed', 'aborted'] },
    },
  });
  for (const job of unfiredJobs) {
    try {
      const { sendInngestEvent } = await import('@dakoda/queue/server/sendInngestEvent');
      await sendInngestEvent({
        name: job.completionEventName!,
        data: {
          ...(job.completionEventData as Record<string, unknown>),
          jobId: job.id,
          convoId: job.convoId,
          result: job.result,
          state: job.state,
          steps: job.steps,
          usage: {
            inputTokens: job.inputTokens,
            outputTokens: job.outputTokens,
            reasoningTokens: job.reasoningTokens,
            totalTokens: job.totalTokens,
          },
        },
      });
      await this.markCompletionEventFired(job.id);
    } catch (err: any) {
      console.error(`Recovery: failed to fire event for job ${job.id}:`, err.message);
    }
  }
}

3.4 Verify

  • createJob persists to DB and creates in-memory record
  • getJob returns running jobs from memory, completed jobs from DB
  • setJobState updates both DB and memory
  • abortJob fires AbortController and updates DB state
  • Server restart preserves completed job data
  • recoverUnfiredEvents fires pending Inngest events on startup

Task 4 — Build SubAgentJob (Inference Runner)

The core logic that resolves a Bot’s configuration, creates a conversation in the DB, and runs a sub-agent inference via streamText(). This is the in-process equivalent of what an external MCP server would do over HTTP.

4.1 Create SubAgentJob

Create packages/@erikdakoda/inference/sub-agent/SubAgentJob.ts:

This module exports a single function runSubAgent() that:

  1. Resolves the Bot configuration from the DB
  2. Creates a Convo record in the DB
  3. Applies overrides (model, system prompt, temperature, etc.)
  4. Collects tools (native AI tools + MCP tools if McpServerManager is available)
  5. Calls streamText() with the sub-agent’s configuration
  6. Consumes the stream to completion
  7. Persists the sub-agent’s messages to the DB
  8. Returns the final result

Key implementation details:

import { streamText, consumeStream, type LanguageModel } from 'ai';
import { getEnhancedExtendedPrisma } from '@dakoda/database/server/getEnhancedExtendedPrisma';
import { getLanguageModel } from '@dakoda/bot/server/getLanguageModel';
import { computeBotSystemPrompt } from '@dakoda/bot/server/computeBotSystemPrompt';
import getModelParameters from '@dakoda/bot/server/getModelParameters';
import { getAiTools, getSelectedAiTools } from '@dakoda/ai-tool/aiToolRegistry';
import { getNewConvo } from '@dakoda/convo/shared/getNewConvo';
import { getNewAiMessage, convertAiMessageMetadata } from '@dakoda/convo/shared/getNewAiMessage';
import { generateId } from '@dakoda/utils/crypto';
import { SubAgentManager } from './SubAgentManager';
import type {
  SubAgentBotRef,
  SubAgentOverrides,
  SubAgentJobRecord,
  SpawnAgentSyncOutput,
} from './types';
import type { AiModelName, IntegrationName } from '@dakoda/bot/shared/aiModels';
import type { AiMessageForUi } from '@dakoda/convo/types';

export interface RunSubAgentParams {
  jobId: string;
  botRef: SubAgentBotRef;
  message: string;
  overrides?: SubAgentOverrides;
  /** The user whose context the sub-agent runs under. Defaults to parent user if runAsUserId was not provided. */
  userId: string;
  /** The parent conversation ID (for linking/tracking). */
  parentConvoId: string;
  /** Inngest event to fire on completion (async mode only). */
  completionEvent?: InngestCompletionEvent;
}

export async function runSubAgent(params: RunSubAgentParams): Promise<SpawnAgentSyncOutput> {
  const manager = SubAgentManager.getInstance();
  const db = await getEnhancedExtendedPrisma();
  const job = manager.getJob(params.jobId);
  if (!job) throw new Error(`Job ${params.jobId} not found`);

  manager.setJobState(params.jobId, 'running');

  try {
    // ── 1. Resolve Bot ──
    const bot = await resolveBot(db, params.botRef);

    // ── 2. Create Convo ──
    const user = await db.user.findUniqueOrThrow({ where: { id: params.userId } });
    const convoInput = getNewConvo(user, bot);
    const convo = await db.convo.create({ data: convoInput });
    manager.getJob(params.jobId)!.convoId = convo.id;

    // ── 3. Save initial user message ──
    const userMessage: AiMessageForUi = {
      id: generateId('msg'),
      role: 'user',
      parts: [{ type: 'text', text: params.message }],
      createdAt: new Date(),
    };
    const aiMessageInput = getNewAiMessage(userMessage, {
      id: convo.id,
      ownerId: params.userId,
      spaceId: null,
      serviceTier: convo.serviceTier,
    });
    await db.aiMessage.create({ data: aiMessageInput });

    // ── 4. Resolve model + parameters ──
    const { integration, modelName } = resolveModel(bot, params.overrides);
    const model = getLanguageModel(
      integration as IntegrationName,
      modelName as AiModelName,
      'responses',
      { posthogDistinctId: params.userId, posthogTraceId: params.jobId },
    ) as LanguageModel;

    const mergedConvo = { ...convo, ...resolveOverrides(bot, params.overrides) };
    const modelParameters = getModelParameters(mergedConvo);

    // ── 5. Resolve system prompt ──
    let systemPrompt = await computeBotSystemPrompt(bot);
    if (params.overrides?.systemPromptOverride) {
      systemPrompt = params.overrides.systemPromptOverride;
    }
    if (params.overrides?.systemPromptAppend) {
      systemPrompt += '\n\n' + params.overrides.systemPromptAppend;
    }

    // ── 6. Collect tools ──
    const tools = resolveTools(params.overrides?.toolFilter);

    // ── 7. Run inference ──
    const result = streamText({
      model,
      system: systemPrompt,
      messages: [{ role: 'user', content: params.message }],
      tools,
      maxSteps: params.overrides?.maxSteps ?? 25,
      ...modelParameters,
      abortSignal: job.abortController.signal,
    });

    // ── 8. Consume stream to completion ──
    const fullResult = await result.consumeStream();
    const messages = await result.responseMessages;
    const usage = result.usage;
    const steps = result.steps.length;

    // ── 9. Extract final assistant message ──
    const finalAssistantMessage = messages.filter(m => m.role === 'assistant').pop();
    const resultText = finalAssistantMessage
      ? extractTextFromMessage(finalAssistantMessage)
      : '(no response)';

    // ── 10. Persist sub-agent messages to DB ──
    const assistantUiMessage: AiMessageForUi = {
      id: generateId('msg'),
      role: 'assistant',
      parts: finalAssistantMessage?.content
        ? Array.isArray(finalAssistantMessage.content)
          ? finalAssistantMessage.content
          : [{ type: 'text', text: String(finalAssistantMessage.content) }]
        : [],
      createdAt: new Date(),
      inputTokens: usage.promptTokens,
      outputTokens: usage.completionTokens,
      totalTokens: usage.totalTokens,
    };
    convertAiMessageMetadata(assistantUiMessage);
    const assistantAiMessageInput = getNewAiMessage(assistantUiMessage, {
      id: convo.id,
      ownerId: params.userId,
      spaceId: null,
      serviceTier: convo.serviceTier,
    });
    await db.aiMessage.create({ data: assistantAiMessageInput });

    // ── 11. Build output ──
    const output: SpawnAgentSyncOutput = {
      mode: 'sync',
      jobId: params.jobId,
      convoId: convo.id,
      result: resultText,
      messages: [userMessage, assistantUiMessage],
      usage: {
        inputTokens: usage.promptTokens,
        outputTokens: usage.completionTokens,
        reasoningTokens: (usage as any).outputTokenDetails?.reasoningTokens ?? 0,
        totalTokens: usage.totalTokens,
      },
      steps,
      state: 'completed',
    };

    manager.setJobOutput(params.jobId, output);
    manager.setJobState(params.jobId, 'completed');

    // ── 12. Fire Inngest completion event (async mode only) ──
    if (params.completionEvent) {
      try {
        const { sendInngestEvent } = await import('@dakoda/queue/server/sendInngestEvent');
        await sendInngestEvent({
          name: params.completionEvent.name,
          data: {
            ...params.completionEvent.data,
            jobId: params.jobId,
            convoId: convo.id,
            result: resultText,
            state: output.state,
            steps,
            usage: output.usage,
          },
        });
      } catch (err: any) {
        // Log but don't fail — the sub-agent completed successfully;
        // the event dispatch is a best-effort side effect.
        console.error(`Failed to send Inngest completion event "${params.completionEvent.name}":`, err.message);
      }
    }

    return output;

  } catch (err: any) {
    if (err.name === 'AbortError') {
      manager.setJobState(params.jobId, 'aborted');
    } else {
      manager.setJobState(params.jobId, 'failed', err.message);
    }

    // Fire Inngest event even on failure/abort so workflows can handle it
    if (params.completionEvent) {
      try {
        const { sendInngestEvent } = await import('@dakoda/queue/server/sendInngestEvent');
        await sendInngestEvent({
          name: params.completionEvent.name,
          data: {
            ...params.completionEvent.data,
            jobId: params.jobId,
            convoId: '',
            result: '',
            state: err.name === 'AbortError' ? 'aborted' : 'failed',
            error: err.message,
          },
        });
      } catch {
        // Best-effort
      }
    }

    throw err;
  }
}

4.2 Helper: resolveBot

async function resolveBot(db: any, botRef: SubAgentBotRef) {
  if ('botId' in botRef) {
    return db.bot.findUniqueOrThrow({ where: { id: botRef.botId }, include: { childPrompts: true } });
  }
  return db.bot.findFirstOrThrow({
    where: { group: botRef.botGroup, handle: botRef.botHandle },
    include: { childPrompts: true },
  });
}

4.3 Helper: resolveModel

function resolveModel(bot: any, overrides?: SubAgentOverrides) {
  if (overrides?.modelOverride) {
    const [integration, modelName] = overrides.modelOverride.split(':');
    if (!integration || !modelName) {
      throw new Error(`Invalid modelOverride format: "${overrides.modelOverride}". Expected "integration:modelName" e.g. "openai:gpt-5.4-nano".`);
    }
    return { integration, modelName };
  }
  return { integration: bot.integration, modelName: bot.modelName };
}

4.4 Helper: resolveOverrides

Merges override parameters into the convo-like object for getModelParameters:

function resolveOverrides(bot: any, overrides?: SubAgentOverrides) {
  return {
    integration: bot.integration,
    modelName: bot.modelName,
    ownerId: bot.ownerId,
    temperature: overrides?.temperature ?? bot.temperature,
    reasoningEffort: overrides?.reasoningEffort ?? bot.reasoningEffort,
    verbosity: bot.verbosity,
    serviceTier: bot.serviceTier,
    ...overrides?.modelOverride ? (() => {
      const [integration, modelName] = overrides.modelOverride!.split(':');
      return { integration, modelName };
    })() : {},
  };
}

4.5 Helper: resolveTools

function resolveTools(toolFilter?: string[]): Record<string, any> {
  const allTools = getAiTools();
  if (!toolFilter || toolFilter.length === 0) {
    return allTools;
  }
  return getSelectedAiTools(toolFilter);
}

4.6 Helper: extractTextFromMessage

function extractTextFromMessage(message: any): string {
  if (typeof message.content === 'string') return message.content;
  if (Array.isArray(message.content)) {
    return message.content
      .filter((part: any) => part.type === 'text')
      .map((part: any) => part.text)
      .join('\n');
  }
  return String(message.content ?? '');
}

4.7 MCP tool integration (conditional on Plan 1)

When McpServerManager from Plan 1 is available, resolveTools should also include MCP tools bound to the sub-agent’s Bot:

// Only if McpServerManager is available (Plan 1 dependency)
import { McpServerManager } from '@erikdakoda/mcp-lifecycle';

async function resolveTools(botId: string, toolFilter?: string[]): Promise<Record<string, any>> {
  const nativeTools = getAiTools();
  let mcpTools: Record<string, any> = {};

  try {
    const mcpManager = McpServerManager.getInstance();
    if (mcpManager) {
      mcpTools = await mcpManager.getToolsForBot(botId);
    }
  } catch {
    // McpServerManager not available (Plan 1 not yet deployed)
  }

  const allTools = { ...nativeTools, ...mcpTools };

  if (!toolFilter || toolFilter.length === 0) {
    return allTools;
  }

  return Object.fromEntries(
    Object.entries(allTools).filter(([name]) => toolFilter.includes(name))
  );
}

For the initial implementation (before Plan 1 is complete), use the simpler version without MCP tools.

4.8 Verify

  • runSubAgent() can create a convo, run inference, and return results
  • Abort signal correctly cancels inference
  • Messages are persisted to DB
  • Error states are properly tracked in SubAgentManager

Task 5 — Build the spawnAgent AI Tool

Define the spawnAgent tool schema and wire its execute handler to runSubAgent().

6.1 Create tool definition

Create packages/@erikdakoda/ai-tool/ai-tools/spawnAgent.ts:

import { tool, zodSchema } from 'ai';
import { z } from 'zod';
import type { AiTool } from '@dakoda/ai-tool/types';

export const spawnAgentInputSchema = z.object({
  bot: z.union([
    z.object({ botId: z.string().describe('Direct Bot ID to spawn as') }),
    z.object({
      botGroup: z.string().describe('Bot group (e.g., "forge")'),
      botHandle: z.string().describe('Bot handle within group (e.g., "task-runner")'),
    }),
  ]).describe('The Bot to spawn as. Identified by ID or group+handle.'),

  message: z.string().describe('The initial user message to send to the sub-agent.'),

  mode: z.enum(['sync', 'async']).default('sync')
    .describe('sync: block until sub-agent completes. async: return job ID immediately.'),

  overrides: z.object({
    modelOverride: z.string().optional()
      .describe('Override model. Format: "integration:modelName" e.g. "openai:gpt-5.4-nano".'),
    systemPromptOverride: z.string().optional()
      .describe('Replace the Bot\'s system prompt entirely.'),
    systemPromptAppend: z.string().optional()
      .describe('Append extra instructions to the Bot\'s system prompt.'),
    temperature: z.number().min(0).max(2).optional()
      .describe('Override temperature.'),
    reasoningEffort: z.enum(['minimal', 'low', 'medium', 'high']).optional()
      .describe('Override reasoning effort.'),
    maxSteps: z.number().min(1).max(100).default(25)
      .describe('Max tool-use steps the sub-agent may take.'),
    toolFilter: z.array(z.string()).optional()
      .describe('Whitelist of tool names. Empty = all available tools.'),
  }).optional(),

  completionEvent: z.object({
    name: z.string().describe('Inngest event name to fire on completion (e.g., "sub-agent/completed"). Only valid in async mode.'),
    data: z.record(z.unknown()).optional()
      .describe('Optional additional data to merge into the event payload alongside the job result.'),
  }).optional()
    .describe('An Inngest event to trigger when the sub-agent completes. Enables downstream Inngest functions to continue a workflow. Only valid in async mode.'),

  runAsUserId: z.string().optional()
    .describe('User ID whose context the sub-agent should run under. If provided, the sub-agent creates the Convo and messages under this user instead of the current user. This enables user sessions to trigger background agent sessions running under a different user\'s account. If omitted, defaults to the current user.'),
});

export const spawnAgentOutputSchema = z.union([
  z.object({
    mode: z.literal('sync'),
    jobId: z.string(),
    convoId: z.string(),
    result: z.string(),
    messages: z.array(z.any()),
    usage: z.object({
      inputTokens: z.number(),
      outputTokens: z.number(),
      reasoningTokens: z.number(),
      totalTokens: z.number(),
    }),
    steps: z.number(),
    state: z.enum(['completed', 'failed', 'aborted']),
    error: z.string().optional(),
  }),
  z.object({
    mode: z.literal('async'),
    jobId: z.string(),
    convoId: z.string(),
    state: z.literal('running'),
  }),
]);

export type SpawnAgentInput = z.infer<typeof spawnAgentInputSchema>;
export type SpawnAgentOutput = z.infer<typeof spawnAgentOutputSchema>;

export const spawnAgent: AiTool<unknown, SpawnAgentInput, SpawnAgentOutput> = {
  id: 'spawnAgent',
  group: 'sub-agent',
  userSelect: false,
  tool: tool({
    description:
      'Spawn a sub-agent that runs as a specified Bot with its own conversation. ' +
      'The sub-agent gets its own model, system prompt, and tools, and can execute multi-step workflows. ' +
      'In sync mode (default), blocks until the sub-agent completes and returns the result. ' +
      'In async mode, returns a job ID immediately; use checkAgent to poll for results. ' +
      'Optionally specify completionEvent to trigger an Inngest workflow on completion (async only). ' +
      'Optionally specify runAsUserId to run the sub-agent under a different user\'s context.',
    inputSchema: zodSchema(spawnAgentInputSchema),
    outputSchema: zodSchema(spawnAgentOutputSchema),
  }),
};

6.2 Create server-side tool registration

Create packages/@erikdakoda/ai-tool/ai-server-tools/spawnAgent.ts:

import { spawnAgent } from '../ai-tools/spawnAgent';
import { registerAiTool } from '@dakoda/ai-tool/aiToolRegistry';
import { runSubAgent } from '@dakoda/inference/sub-agent/SubAgentJob';
import { SubAgentManager } from '@dakoda/inference/sub-agent/SubAgentManager';
import { generateId } from '@dakoda/utils/crypto';

spawnAgent.tool.execute = async (input, { messages, abortSignal }) => {
  const manager = SubAgentManager.getInstance();
  const jobId = generateId('job');

  // Extract parent convo context from the execution context.
  // The parent's authenticated user ID is passed through a module-level context
  // set by ChatPostHandler before the inference call (see Task 7).
  const context = getSpawnContext();

  // ── Resolve effective user ──
  // If runAsUserId is provided, the sub-agent runs under that user's context.
  // Authorization: the caller must be the same user, or the context must allow delegation.
  const effectiveUserId = input.runAsUserId ?? context.userId;
  if (input.runAsUserId && input.runAsUserId !== context.userId) {
  // Verify the caller is authorized to spawn as this user.
  // Authorization: allow if same user, admin, or in delegateUserIds list.
  const isDelegate = context.delegateUserIds?.includes(input.runAsUserId) ?? false;
  if (!isDelegate) {
    const db = await getEnhancedExtendedPrisma();
    const caller = await db.user.findUnique({ where: { id: context.userId } });
    if (!caller?.admin) {
      return {
        mode: 'sync' as const,
        jobId: '',
        convoId: '',
        result: '',
        messages: [],
        usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 },
        steps: 0,
        state: 'failed' as const,
        error: `Not authorized to spawn sub-agent as user ${input.runAsUserId}.`,
      };
    }
  }
  }

  // ── Validate completionEvent is only used with async mode ──
  if (input.completionEvent && input.mode !== 'async') {
    return {
      mode: 'sync' as const,
      jobId: '',
      convoId: '',
      result: '',
      messages: [],
      usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 },
      steps: 0,
      state: 'failed' as const,
      error: 'completionEvent is only valid in async mode.',
    };
  }

  const job = manager.createJob({
    jobId,
    convoId: '', // will be set by runSubAgent after convo creation
    botId: '',
    parentConvoId: context.parentConvoId,
    mode: input.mode ?? 'sync',
    completionEvent: input.completionEvent,
    effectiveUserId,
  });

  const runParams = {
    jobId,
    botRef: input.bot,
    message: input.message,
    overrides: input.overrides,
    userId: effectiveUserId,
    parentConvoId: context.parentConvoId,
    completionEvent: input.completionEvent,
  };

  if (input.mode === 'async') {
    // Fire and forget — run in background
    runSubAgent(runParams).catch((err) => {
      // Error is already tracked in SubAgentManager
    });

    return {
      mode: 'async' as const,
      jobId,
      convoId: job.convoId || '',
      state: 'running' as const,
    };
  }

  // Sync mode: block until sub-agent completes
  try {
    return await runSubAgent(runParams);
  } catch (err: any) {
    return {
      mode: 'sync' as const,
      jobId,
      convoId: job.convoId || '',
      result: '',
      messages: [],
      usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 },
      steps: 0,
      state: 'failed' as const,
      error: err.message,
    };
  }
};

registerAiTool(spawnAgent);

6.3 Verify

  • Tool definition compiles
  • spawnAgent appears in aiToolRegistry after import
  • Input schema validates both botId and group+handle forms
  • Sync mode blocks and returns result
  • Async mode returns immediately with job ID

Task 6 — Build the checkAgent AI Tool

Define the checkAgent tool that queries the SubAgentManager for job status and results.

5.1 Create tool definition

Create packages/@erikdakoda/ai-tool/ai-tools/checkAgent.ts:

import { tool, zodSchema } from 'ai';
import { z } from 'zod';
import type { AiTool } from '@dakoda/ai-tool/types';

export const checkAgentInputSchema = z.object({
  jobId: z.string().describe('The job ID returned by spawnAgent (async mode).'),
  includePartial: z.boolean().default(false)
    .describe('If true and job is still running, return partial output (messages so far).'),
});

export const checkAgentOutputSchema = z.object({
  jobId: z.string(),
  state: z.enum(['pending', 'running', 'completed', 'failed', 'aborted']),
  result: z.string().optional(),
  messages: z.array(z.any()).optional(),
  usage: z.object({
    inputTokens: z.number(),
    outputTokens: z.number(),
    reasoningTokens: z.number(),
    totalTokens: z.number(),
  }).optional(),
  steps: z.number().optional(),
  error: z.string().optional(),
});

export type CheckAgentInput = z.infer<typeof checkAgentInputSchema>;
export type CheckAgentOutput = z.infer<typeof checkAgentOutputSchema>;

export const checkAgent: AiTool<unknown, CheckAgentInput, CheckAgentOutput> = {
  id: 'checkAgent',
  group: 'sub-agent',
  userSelect: false,
  tool: tool({
    description:
      'Check the status and retrieve results from a previously spawned sub-agent. ' +
      'Use this after spawnAgent in async mode to poll for completion.',
    inputSchema: zodSchema(checkAgentInputSchema),
    outputSchema: zodSchema(checkAgentOutputSchema),
  }),
};

5.2 Create server-side tool registration

Create packages/@erikdakoda/ai-tool/ai-server-tools/checkAgent.ts:

import { checkAgent } from '../ai-tools/checkAgent';
import { registerAiTool } from '@dakoda/ai-tool/aiToolRegistry';
import { SubAgentManager } from '@dakoda/inference/sub-agent/SubAgentManager';

checkAgent.tool.execute = async (input) => {
  const manager = SubAgentManager.getInstance();
  const job = manager.getJob(input.jobId);

  if (!job) {
    return {
      jobId: input.jobId,
      state: 'failed' as const,
      error: `Job ${input.jobId} not found. It may have expired or never existed.`,
    };
  }

  const output: any = {
    jobId: job.jobId,
    state: job.state,
  };

  if (job.state === 'completed' && job.output) {
    output.result = job.output.result;
    output.messages = job.output.messages;
    output.usage = job.output.usage;
    output.steps = job.output.steps;
  }

  if (job.state === 'failed') {
    output.error = job.error;
  }

  if (input.includePartial && job.state === 'running') {
    // Note: partial results require additional tracking in SubAgentJob.
    // For the initial implementation, partial results are not supported.
    // A future enhancement can stream partial messages from the running inference.
    output.result = '(sub-agent is still running; partial results not yet available)';
  }

  return output;
};

registerAiTool(checkAgent);

5.3 Verify

  • checkAgent returns correct status for completed, running, failed, and unknown jobs
  • Output matches the schema

Task 7 — Build the abortAgent AI Tool

Define the abortAgent tool that cancels a running sub-agent.

6.1 Create tool definition

Create packages/@erikdakoda/ai-tool/ai-tools/abortAgent.ts:

import { tool, zodSchema } from 'ai';
import { z } from 'zod';
import type { AiTool } from '@dakoda/ai-tool/types';

export const abortAgentInputSchema = z.object({
  jobId: z.string().describe('The job ID of the running sub-agent to cancel.'),
});

export const abortAgentOutputSchema = z.object({
  jobId: z.string(),
  state: z.literal('aborted'),
  partialResult: z.string().optional(),
});

export type AbortAgentInput = z.infer<typeof abortAgentInputSchema>;
export type AbortAgentOutput = z.infer<typeof abortAgentOutputSchema>;

export const abortAgent: AiTool<unknown, AbortAgentInput, AbortAgentOutput> = {
  id: 'abortAgent',
  group: 'sub-agent',
  userSelect: false,
  tool: tool({
    description:
      'Abort a running sub-agent inference. The sub-agent will stop at the next opportunity. ' +
      'Any partial results or tool calls in progress will be discarded.',
    inputSchema: zodSchema(abortAgentInputSchema),
    outputSchema: zodSchema(abortAgentOutputSchema),
  }),
};

6.2 Create server-side tool registration

Create packages/@erikdakoda/ai-tool/ai-server-tools/abortAgent.ts:

import { abortAgent } from '../ai-tools/abortAgent';
import { registerAiTool } from '@dakoda/ai-tool/aiToolRegistry';
import { SubAgentManager } from '@dakoda/inference/sub-agent/SubAgentManager';

abortAgent.tool.execute = async (input) => {
  const manager = SubAgentManager.getInstance();
  const success = manager.abortJob(input.jobId);

  if (!success) {
    const job = manager.getJob(input.jobId);
    return {
      jobId: input.jobId,
      state: 'aborted' as const,
      partialResult: job?.state === 'aborted'
        ? '(job was already aborted)'
        : `(job not found or in state: ${job?.state ?? 'unknown'})`,
    };
  }

  return {
    jobId: input.jobId,
    state: 'aborted' as const,
  };
};

registerAiTool(abortAgent);

6.3 Verify

  • Aborting a running job cancels its streamText() call
  • Aborting a completed/unknown job returns gracefully
  • The AbortController signal propagates correctly to streamText()

Task 8 — Implement Spawn Context Passing

The spawn tools need access to the authenticated user ID and parent conversation ID from the parent’s ChatPostHandler invocation. Since tool execution runs within the streamText() call, we need a mechanism to pass this context.

8.1 Problem

spawnAgent.tool.execute is called by Vercel AI SDK during streamText(). It does not receive the authenticated session or conversation ID as parameters. These are only available in ChatPostHandler.

8.2 Solution: AsyncLocalStorage context

Use Node.js AsyncLocalStorage to pass context from ChatPostHandler into tool execution, without modifying the tool signature:

Create packages/@erikdakoda/inference/sub-agent/spawnContext.ts:

import { AsyncLocalStorage } from 'node:async_hooks';

export interface SpawnContext {
  userId: string;
  parentConvoId: string;
  parentConvoOwnerId: string;
  parentConvoSpaceId: string | null;
}

const spawnContextStore = new AsyncLocalStorage<SpawnContext>();

export function setSpawnContext(context: SpawnContext): void {
  // This is called inside the streamText() call — the store is already active
  // We need to set it BEFORE streamText is called
  spawnContextStore.enterWith(context);
}

export function getSpawnContext(): SpawnContext {
  const context = spawnContextStore.getStore();
  if (!context) {
    throw new Error(
      'SpawnContext not available. spawnAgent can only be used within a ChatPostHandler request.'
    );
  }
  return context;
}

export function runWithSpawnContext<R>(context: SpawnContext, fn: () => R): R {
  return spawnContextStore.run(context, fn);
}

8.3 Modify ChatPostHandler

In ChatPostHandler.ts, wrap the streamText() call with the spawn context:

import { runWithSpawnContext } from '@dakoda/inference/sub-agent/spawnContext';

// ... inside ChatPostHandler, before streamText() ...

const spawnContext = {
  userId: session.user.id,
  parentConvoId: convo.id,
  parentConvoOwnerId: convo.ownerId,
  parentConvoSpaceId: convo.spaceId,
  delegateUserIds: [], // populated from user's delegate allowlist if applicable
  spawnDepth: 0,
};

const result = runWithSpawnContext(spawnContext, () =>
  streamText({
    model,
    system: systemPrompt,
    messages: modelMessages,
    tools: getAiTools(),
    ...modelParameters,
    abortSignal: request.signal,
  })
);

Important: runWithSpawnContext must wrap the streamText() CALL, not just its result. The streamText() function starts executing synchronously and returns a stream object — the context must be active during this synchronous phase so that any tool calls that happen during the stream’s execution can access it.

However, streamText() returns a stream object immediately and processes asynchronously. The AsyncLocalStorage.run() context is available to async continuations within the call. Since Vercel AI SDK’s tool execution runs as async callbacks within the stream pipeline, the AsyncLocalStorage context should propagate correctly.

8.4 Sub-agent’s own context

When a sub-agent is spawned in async mode, the runSubAgent() function runs outside the parent’s AsyncLocalStorage context. This is correct — the sub-agent creates its own SpawnContext if it needs to spawn further sub-agents (nested spawning).

For nested spawning, runSubAgent() should set up its own spawn context before calling streamText() for the sub-agent:

// Inside runSubAgent, when calling streamText for the sub-agent:
const subAgentContext = {
  userId: params.userId,
  parentConvoId: convo.id,
  parentConvoOwnerId: params.userId,
  parentConvoSpaceId: null,
};

const result = runWithSpawnContext(subAgentContext, () =>
  streamText({
    model,
    system: systemPrompt,
    messages: [{ role: 'user', content: params.message }],
    tools,
    maxSteps: params.overrides?.maxSteps ?? 25,
    ...modelParameters,
    abortSignal: job.abortController.signal,
  })
);

This enables nested sub-agent spawning (a sub-agent spawning its own sub-agents).

8.5 Verify

  • getSpawnContext() returns the correct context within a tool execution
  • getSpawnContext() throws when called outside a ChatPostHandler request
  • Nested spawning works (sub-agent can spawn its own sub-agents)

Task 9 — Prevent Recursive Spawn Loops

Sub-agents should not be able to spawn themselves (directly or indirectly), as this could create infinite loops. Implement depth limiting.

9.1 Add depth tracking to SpawnContext

Update spawnContext.ts:

export interface SpawnContext {
  userId: string;
  parentConvoId: string;
  parentConvoOwnerId: string;
  parentConvoSpaceId: string | null;
  /** User IDs that the current context user is authorized to delegate to (runAsUserId). Empty = no delegation allowed except self. */
  delegateUserIds?: string[];
  /** Current spawn depth (0 = top-level, 1 = first sub-agent, etc.) */
  spawnDepth: number;
}

/** Maximum nesting depth for sub-agent spawning. */
export const MAX_SPAWN_DEPTH = 3;

9.2 Enforce depth limit in spawnAgent execute

In the spawnAgent.tool.execute handler:

const context = getSpawnContext();

if (context.spawnDepth >= MAX_SPAWN_DEPTH) {
  return {
    mode: 'sync' as const,
    jobId: '',
    convoId: '',
    result: `Cannot spawn sub-agent: maximum nesting depth (${MAX_SPAWN_DEPTH}) reached.`,
    messages: [],
    usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, totalTokens: 0 },
    steps: 0,
    state: 'failed' as const,
    error: `Maximum spawn depth (${MAX_SPAWN_DEPTH}) exceeded.`,
  };
}

9.3 Increment depth for sub-agent context

In runSubAgent(), when creating the sub-agent’s spawn context:

const subAgentContext = {
  userId: params.userId,
  parentConvoId: convo.id,
  parentConvoOwnerId: params.userId,
  parentConvoSpaceId: null,
  delegateUserIds: [],
  spawnDepth: (getSpawnContext().spawnDepth ?? 0) + 1,
};

9.4 Prevent self-referential spawn

Add a check that the sub-agent is not spawning as the same Bot that is already running (which would create an infinite loop even with depth limits, since each step could re-spawn):

// In spawnAgent execute handler:
const context = getSpawnContext();

// The parent's current bot info is not directly available in the context.
// Instead, we rely on the depth limit to prevent unbounded recursion.
// Self-referential spawning within depth limits is allowed but unusual.

Since the depth limit caps the total nesting, self-referential spawning is bounded and not a critical concern.

8.5 Verify

  • Spawning beyond MAX_SPAWN_DEPTH returns an error
  • Depth increments correctly for nested spawns
  • Depth 0 (top-level) allows spawning depth 1, etc.

Task 10 — Register Tools in BotCraft

Wire the three spawn tools into BotCraft’s aiServerTools.ts so they are available at inference time.

10.1 Add spawn tool imports

Update apps/bot-craft/src/aiServerTools.ts:

import '@dakoda/ai-tool/ai-server-tools';
import '@dakoda/user/ai-server-tools';
import '@erikdakoda/memory/ai-server-tools';
import '@dakoda/ai-tool/ai-server-tools/spawnAgent';
import '@dakoda/ai-tool/ai-server-tools/checkAgent';
import '@dakoda/ai-tool/ai-server-tools/abortAgent';

10.2 Verify

  • All three tools appear in aiToolRegistry after BotCraft starts
  • getAiToolNames() includes spawnAgent, checkAgent, abortAgent

Task 11 — Unit and Integration Tests

Write tests that verify the complete sub-agent lifecycle.

11.1 Unit tests for SubAgentManager

Create packages/@erikdakoda/inference/sub-agent/SubAgentManager.unit.test.ts:

Test cases:

  1. createJob creates a record and it’s retrievable via getJob
  2. setJobState updates the state correctly
  3. setJobOutput stores output and resolves the completion promise
  4. abortJob sets state to aborted and fires the AbortController
  5. cleanup removes old completed jobs

11.2 Unit tests for spawn context

Create packages/@erikdakoda/inference/sub-agent/spawnContext.unit.test.ts:

Test cases:

  1. runWithSpawnContext makes context available via getSpawnContext
  2. getSpawnContext throws when called outside a context
  3. Context is available in async callbacks within runWithSpawnContext
  4. Nested contexts work (inner context overrides outer)

11.3 Integration test for spawn flow

Create packages/@erikdakoda/inference/sub-agent/SubAgentJob.integration.test.ts:

Test cases (may require a test DB or mock):

  1. Sync spawn: spawnAgent with mode: 'sync' blocks and returns result
  2. Async spawn: spawnAgent with mode: 'async' returns job ID immediately
  3. Check: checkAgent returns correct status for running/completed/failed jobs
  4. Abort: abortAgent cancels a running sub-agent
  5. Depth limit: spawning beyond MAX_SPAWN_DEPTH returns error
  6. Bot resolution: both botId and group+handle forms resolve correctly
  7. Bot resolution: both botId and group+handle forms resolve correctly
  8. Model override: modelOverride changes the model used by the sub-agent
  9. System prompt override: systemPromptOverride and systemPromptAppend work
  10. Inngest completion event: completionEvent fires on completion (async mode)
  11. runAsUserId: sub-agent runs under specified user’s context
  12. runAsUserId authorization: non-admin non-delegate users cannot spawn as another user

11.4 Verify

  • All tests pass
  • Test coverage ≥ 80% for new code

Task 12 — Add Spawn Metrics to Health Endpoint

Expose sub-agent job stats in BotCraft’s health endpoint for monitoring.

12.1 Extend health endpoint

In apps/bot-craft/src/app/api/health/route.ts (or create if it doesn’t exist):

import { SubAgentManager } from '@dakoda/inference/sub-agent/SubAgentManager';

// In the GET handler:
const subAgentManager = SubAgentManager.getInstance();
const subAgentStats = {
  activeJobs: subAgentManager.getAllJobs().filter(j => j.state === 'running').length,
  totalJobs: subAgentManager.getAllJobs().length,
  pendingJobs: subAgentManager.getAllJobs().filter(j => j.state === 'pending').length,
};

12.2 Verify

  • /api/health returns sub-agent job counts

Task 13 — Documentation and Final Wiring

Add JSDoc, README, and inline documentation for the sub-agent system.

13.1 Add package README

Create packages/@erikdakoda/inference/sub-agent/README.md documenting:

  • Architecture overview
  • Tool descriptions (spawnAgent, checkAgent, abortAgent)
  • Sync vs. async mode
  • Bot resolution
  • Overrides
  • Depth limiting
  • Spawn context passing
  • Inngest workflow continuation via completionEvent
  • User delegation via runAsUserId

13.2 Add inline JSDoc to all public functions and types

13.3 Add sub-agent section to BotCraft’s main README

13.4 Verify

  • README is accurate and complete
  • All public APIs have JSDoc

Dependency Order

Task 1 (AgentJob model) ─────────────────────────┐

Task 2 (Types) ──────────────────────────────────┤

Task 3 (SubAgentManager) ────────────────────────┤

Task 4 (SubAgentJob / runSubAgent) ──────────────┤
                   │                             ↓
Task 5 (spawnAgent tool) ───────────────────────┤
Task 6 (checkAgent tool) ───────────────────────┤
Task 7 (abortAgent tool) ───────────────────────┤
                   │                             ↓
Task 8 (Spawn context passing) ─────────────────┤
                   │                             ↓
Task 9 (Depth limiting) ────────────────────────┤
                   │                             ↓
Task 10 (BotCraft registration) ────────────────┤
                   │                             ↓
Task 11 (Tests) ─────────────────────────────────┤
                   │                             ↓
Task 12 (Health endpoint) ───────────────────────┤
                   │                             ↓
Task 13 (Documentation) ────────────────────────┘

Task 1 (AgentJob model) must come first — everything depends on the DB table. Tasks 5, 6, 7 can be done in parallel after Task 4 is complete. Tasks 8 and 9 should be done before Task 10. Task 11 depends on all previous tasks. Tasks 12 and 13 are final.

Dependency on Plan 1: This Plan is implementable without Plan 1. Without Plan 1’s McpServerManager, sub-agents use only native AI tools (via getAiTools()). When Plan 1 is complete, Task 4’s resolveTools can be enhanced to include MCP tools — this is a minor enhancement, not a blocker.