Skip to Content

Sub-Workflows

Sub-workflows let you compose complex automations from smaller, reusable workflow building blocks. A parent workflow can launch one or more child workflows via .run(), pause until they complete, and receive results through a callback transition.

Injecting a Sub-Workflow

import { CallbackSchema, QueueResult } from '@loopstack/common'; constructor(private readonly subWorkflow: SubWorkflow) { super(); }

Running a Sub-Workflow

@Transition({ to: 'sub_started' }) async start(state: MyState): Promise<MyState> { await this.subWorkflow.run( { prompt: 'Hello' }, // Args passed to the sub-workflow { callback: { transition: 'onSubComplete' } }, // Method to call when done ); return state; }

The parent’s run view automatically renders the child sub-workflow inline by default — there is no extra documentStore.save(LinkDocument, …) step.

Controlling How the Child Appears: show

The show option on RunOptions controls how the child sub-workflow is rendered inside the parent’s run view:

showWhat the parent seesUse for
'inline' (default)The child is embedded as an inline iframe; the user can interact with it in place.HITL prompts, OAuth flows, agents whose progress you want visible.
'link'A status link card with the child’s label and live status — opens the child in a separate window when clicked.Long-running autonomous children the parent just tracks.
'hidden'Nothing is shown.Background fan-out where surfacing each child would be noise.
await this.askUser.run(args, { callback: { transition: 'answered' }, show: 'inline', // (default) embed the child UI in the parent's view label: 'Waiting for user answer', // optional — defaults to the child workflow's name }); await this.longJob.run(args, { callback: { transition: 'done' }, show: 'link', // status card, opens child in a separate window }); await this.background.run(args, { show: 'hidden', // no card at all });

The link card’s status is read live from the child workflow’s actual state — it transitions from pending to success or failure automatically as the child runs.

Receiving the Callback

The sub-workflow’s final transition return value is passed as payload.data:

const SubWorkflowCallbackSchema = CallbackSchema.extend({ data: z.object({ message: z.string() }), }); @Transition({ from: 'sub_started', to: 'sub_done', wait: true, schema: SubWorkflowCallbackSchema, }) async onSubComplete( state: MyState, payload: { workflowId: string; status: string; data: { message: string } }, ): Promise<MyState> { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Sub-workflow said: ${payload.data.message}`, }); return state; }

Sub-Workflow Output

The sub-workflow defines its output as the return value of its final transition:

@Workflow({ widget: __dirname + '/sub.ui.yaml' }) export class SubWorkflow extends BaseWorkflow { @Transition({ to: 'end' }) async start(): Promise<{ message: string }> { return { message: 'Hi mom!' }; } }

Complete Example

@Workflow({ widget: __dirname + '/parent.ui.yaml' }) export class ParentWorkflow extends BaseWorkflow { constructor(private readonly subWorkflow: SubWorkflow) { super(); } @Transition({ to: 'sub_started' }) async runWorkflow(state: Record<string, unknown>): Promise<Record<string, unknown>> { await this.subWorkflow.run( {}, { callback: { transition: 'subWorkflowCallback' }, show: 'link', label: 'Sub-Workflow' }, ); return state; } @Transition({ from: 'sub_started', to: 'end', wait: true, schema: CallbackSchema.extend({ data: z.object({ message: z.string() }) }), }) async subWorkflowCallback( state: Record<string, unknown>, payload: { workflowId: string; status: string; data: { message: string } }, ): Promise<unknown> { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Message from sub-workflow: ${payload.data.message}`, }); return {}; } }

Registering Sub-Workflows

Both workflows must be registered in the module:

@Module({ providers: [ParentWorkflow, SubWorkflow], exports: [ParentWorkflow, SubWorkflow], }) export class MyModule {}

Wrapping as a Task Tool

A task tool is a BaseTool that launches a sub-workflow and returns pending. The framework calls complete() when the sub-workflow finishes. This lets agents decide when to run sub-workflows.

@Tool({ name: 'run_tests', description: 'Run tests in the specified directory.', schema: z.object({ testDirectory: z.string().describe('Directory containing the test files to run.'), }), }) export class RunTestsTask extends BaseTool { constructor(private readonly testRunner: TestRunnerWorkflow) { super(); } protected async handle( args: { testDirectory: string }, ctx: RunContext, options?: ToolCallOptions, ): Promise<ToolResult> { const result = await this.testRunner.run( { testDirectory: args.testDirectory }, { callback: options?.callback, show: 'inline', label: 'Running tests...' }, ); return { data: { workflowId: result.workflowId }, pending: { workflowId: result.workflowId }, }; } async complete(result: Record<string, unknown>): Promise<ToolResult> { const data = result as { data?: { passed: boolean; output: string } }; return { data: data.data ?? result }; } }

Key parts:

  • pending: { workflowId } tells the framework this tool is async — the parent workflow waits for a callback
  • callback: options?.callback passes the parent’s callback config to the sub-workflow
  • show decides how the child appears in the parent’s run view ('inline' by default)
  • complete() is called when the sub-workflow finishes — transform results and return the tool’s final value here

Nested Agents

The sub-workflow can be an AgentWorkflow itself, enabling multi-agent architectures. See Agent Workflows for the full pattern.

Registry References

Last updated on