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:
show | What the parent sees | Use 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 callbackcallback: options?.callbackpasses the parent’s callback config to the sub-workflowshowdecides 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
- run-sub-workflow-example — Parent workflow calling a sub-workflow with callbacks and output passing
- @loopstack/code-agent — ExploreTask wrapping AgentWorkflow as a task tool