Skill: Create a Custom Workflow
For AI coding agents: This page is a dense reference checklist optimized for tools like Claude Code scaffolding Loopstack code. For the human-readable guide, see Creating Workflows.
Workflow Anatomy
A workflow is a state machine defined by two files:
- TypeScript class — extends
BaseWorkflow, decorated with@Workflow(), contains transition logic, state, guards, and tool calls - YAML config — UI-only configuration (widgets, forms, enabled states)
src/
├── workflows/
│ ├── my.workflow.ts # class definition
│ └── my.ui.yaml # UI config only
├── my-feature.module.ts
└── index.tsTypeScript Class
import { z } from 'zod';
import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common';
import type { RunContext } from '@loopstack/common';
const MyArgs = z.object({
name: z.string().default('world'),
count: z.number().default(1),
});
type MyArgs = z.infer<typeof MyArgs>;
interface MyState {
total?: number;
message?: string;
}
@Workflow({
schema: MyArgs,
widget: __dirname + '/my.ui.yaml',
})
export class MyWorkflow extends BaseWorkflow<MyArgs, MyState> {
// --- Tool & sub-workflow injection via constructor ---
constructor(
private readonly myTool: MyTool,
private readonly helperWorkflow: HelperWorkflow,
) {
super();
}
// --- Initial transition (workflow entry point, from defaults to 'start') ---
@Transition({ to: 'ready' })
async setup(state: MyState, ctx: RunContext): Promise<MyState> {
const args = ctx.args as MyArgs;
return { ...state, message: `Hello, ${args.name}!` };
}
// --- Regular transition ---
@Transition({ from: 'ready', to: 'processed' })
async process(state: MyState): Promise<MyState> {
const result = await this.myTool.call({ query: state.message! });
return { ...state, total: result.data };
}
// --- Final transition (to: 'end' completes the workflow) ---
@Transition({ from: 'processed', to: 'end' })
async finish(state: MyState): Promise<MyState> {
return state;
}
// --- Regular helper method ---
private formatMessage(text: string): string {
return text.toUpperCase();
}
}Decorators Reference
@Workflow(options?)
Class decorator. Configures the workflow.
@Workflow({
widget: __dirname + '/my.ui.yaml', // UI-only YAML config
schema: z.object({ // Input validation schema
prompt: z.string(),
}),
})widget— path to YAML file containing UI widget configurationschema— Zod schema that validates workflow input argumentsname,title,description,configSchema,stateSchema— less common; see the@Workflowreference table
extends BaseWorkflow
All workflows must extend BaseWorkflow<TArgs, TState>. It provides:
this.documentStore— auto-injected, for saving and querying documentsctx.args— the validated workflow input arguments (via thectxparameter on transitions)
Constructor Injection
Tools and sub-workflows are injected via standard NestJS constructor injection:
constructor(
private readonly llmGenerateText: LlmGenerateTextTool,
private readonly subWorkflow: SubWorkflow,
) { super(); }
// Usage:
const result = await this.llmGenerateText.call(
{ prompt: 'Hello' },
{ config: { provider: 'claude', model: 'claude-sonnet-4-6' } },
);
await this.subWorkflow.run(args, { callback: { transition: 'onComplete' } });@Transition(options)
Defines a state transition. All transitions use this single decorator.
// Initial transition (from defaults to 'start')
@Transition({ to: 'ready' })
async setup(state: MyState, ctx: RunContext): Promise<MyState> { ... }
// Regular transition
@Transition({ from: 'ready', to: 'processed' })
async doWork(state: MyState): Promise<MyState> { ... }
// Final transition (to: 'end' completes the workflow)
@Transition({ from: 'done', to: 'end' })
async finish(state: MyState): Promise<MyState> { ... }
// Wait for external callback
@Transition({
from: 'waiting',
to: 'ready',
wait: true,
schema: z.object({ message: z.string() }),
})
async onCallback(state: MyState, payload: { message: string }): Promise<MyState> { ... }
// Multiple source places
@Transition({ from: 'ready', to: 'prompt_executed' })
@Transition({ from: 'tools_done', to: 'prompt_executed' })
async llmTurn(state: MyState): Promise<MyState> { ... }Options:
from— source place (defaults to'start'if omitted — making it the initial transition)to— target place (use'end'for final transitions)wait— iftrue, workflow pauses until externally triggeredschema— Zod schema to validate the callback payload (used withwait: true)priority— evaluation order when multiple transitions share the samefrom(higher = checked first)
@Guard('methodName')
Conditional routing. The referenced method must return a boolean. Use with priority to control evaluation order.
@Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 })
@Guard('hasToolCalls')
async executeToolCalls(state: MyState): Promise<MyState> { ... }
// Fallback — no guard, lower/no priority
@Transition({ from: 'prompt_executed', to: 'end' })
async respond(state: MyState): Promise<unknown> { ... }
hasToolCalls(state: MyState): boolean {
return state.llmResult?.message.stopReason === 'tool_use';
}State Management
State is managed through a typed interface, passed as the first parameter to transitions and returned from each one. State is automatically persisted across transitions.
interface MyState {
llmResult?: LlmGenerateTextResult;
confirmedConcept?: string | null;
counter: number;
}
export class MyWorkflow extends BaseWorkflow<MyArgs, MyState> {
@Transition({ from: 'ready', to: 'processed' })
async process(state: MyState): Promise<MyState> {
const result = await this.myTool.call({ ... });
return { ...state, llmResult: result.data, counter: state.counter + 1 };
}
}Documents
Documents are referenced by class — no injection needed. Use this.documentStore.save() to create/update documents. documentStore is auto-injected on BaseWorkflow.
import { LlmMessageDocument } from '@loopstack/llm-provider-module';
// Save a new document
await this.documentStore.save(LlmMessageDocument, {
role: 'user',
text: 'Hello!',
});
// Save with options (id for updates, meta for visibility)
await this.documentStore.save(
LlmMessageDocument,
{ role: 'assistant', text: 'Hi!' },
{ id: 'greeting', meta: { hidden: true } },
);Handlebars Templates
render is available directly on BaseWorkflow (like documentStore), so workflows just use this.render(...) without any injection:
const rendered = this.render(__dirname + '/templates/prompt.md', {
subject: args.subject,
items: state.items,
});Template file (templates/prompt.md):
Write a haiku about {{subject}}.
{{#each items}}
- {{this.name}}
{{/each}}YAML Config — UI Only
YAML files contain only UI configuration. No transitions: section.
title: 'My Workflow'
description: 'What this workflow does'
ui:
widgets:
- widget: form
enabledWhen: [waiting]
options:
properties:
name:
title: 'Name'
count:
title: 'Count'
widget: slider
actions:
- type: button
transition: userResponse # Must match the method name
label: Submit
- widget: prompt-input
enabledWhen: [waiting_for_user]
options:
transition: userMessage # Must match the method name
label: Send MessageImportant: The
transitionvalue in widget options must match the method name of thewait: truetransition, not an arbitrary ID.
Places (States)
Places are implicit — defined by from/to values in transition decorators. Two special places:
start— implicit initial place (transitions with nofromdefault to'start')end— when reached (viato: 'end'), workflow completes
All other place names are arbitrary strings.
Conditional Routing / Guards
When multiple transitions share the same from place:
- Transitions with
@Guardand higherpriorityare checked first - First transition whose guard returns
truefires - A transition without
@Guardacts as the fallback
@Transition({ from: 'check', to: 'high', priority: 10 })
@Guard('isHigh')
async routeHigh(state: MyState): Promise<MyState> { return state; }
@Transition({ from: 'check', to: 'low' })
async routeLow(state: MyState): Promise<MyState> { return state; } // fallback — no guard
isHigh(state: MyState): boolean {
return state.value > 100;
}Async Callbacks (Wait Transitions)
Use wait: true to pause the workflow until an external trigger (user input, sub-workflow callback, API call).
@Transition({ from: 'responded', to: 'waiting_for_user' })
async waitForUser(state: MyState): Promise<MyState> {
return state; // Moves to waiting_for_user, where the wait transition pauses
}
@Transition({
from: 'waiting_for_user',
to: 'ready',
wait: true,
schema: z.object({ message: z.string() }),
})
async userMessage(state: MyState, payload: { message: string }): Promise<MyState> {
await this.documentStore.save(LlmMessageDocument, {
role: 'user',
text: payload.message,
});
return state;
}Best practice: add a schema to validate the callback payload and receive it as a typed method parameter.
Sub-Workflows
Inject sub-workflows via the constructor and use .run() to execute them asynchronously. The orchestrator automatically renders the child in the parent’s run view based on the show option (default 'inline').
constructor(private readonly subWorkflow: SubWorkflow) { super(); }
@Transition({ to: 'sub_started' })
async start(state: MyState): Promise<MyState> {
await this.subWorkflow.run(
{ prompt: 'Hello' }, // args
{ callback: { transition: 'onSubComplete' }, show: 'inline', label: 'Running sub-workflow...' },
);
return state;
}
@Transition({
from: 'sub_started',
to: 'sub_done',
wait: true,
schema: CallbackSchema.extend({ data: z.object({ message: z.string() }) }),
})
async onSubComplete(state: MyState, payload: { workflowId: string; status: string; data: { message: string } }): Promise<MyState> {
// payload.data contains the sub-workflow's final transition return value
return state;
}show accepts 'inline' (default — embed as iframe), 'link' (status link card opening in a separate window), or 'hidden' (no card). See Sub-Workflows for the full reference.
Workflow Output
The return value from the final transition (to: 'end') is the workflow’s output. This is automatically passed to the parent workflow’s callback when used as a sub-workflow.
@Transition({ from: 'done', to: 'end' })
async finish(state: MyState): Promise<{ concept: string }> {
return { concept: state.confirmedConcept! };
}Module Registration
Every module whose workflows should appear in Loopstack Studio must be decorated with @StudioApp. Without it, workflows are NestJS providers but are not visible or launchable from the UI.
import { StudioApp } from '@loopstack/common';
@StudioApp({
title: 'My Feature',
workflows: [MyWorkflow],
})
@Module({
imports: [ClaudeModule],
providers: [
MyWorkflow,
MyTool,
// Documents are NOT listed as providers — they are plain DTOs
],
})
export class MyFeatureModule {}Note:
@StudioAppis required alongside@Module— it does not replace it. If you are building a reusable library module (not a standalone app), omit@StudioAppand let the consuming app module declare the workflows.
Complete Examples
Example 1: Simple prompt workflow
import { z } from 'zod';
import { BaseWorkflow, Transition, Workflow } from '@loopstack/common';
import type { RunContext } from '@loopstack/common';
import type { LlmGenerateTextResult, LlmResultMeta } from '@loopstack/llm-provider-module';
import { LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module';
interface PromptState {
llmResult?: LlmGenerateTextResult;
llmMeta?: LlmResultMeta;
}
@Workflow({
widget: __dirname + '/prompt.ui.yaml',
schema: z.object({
subject: z.string().default('coffee'),
}),
})
export class PromptWorkflow extends BaseWorkflow<{ subject: string }, PromptState> {
constructor(private readonly llmGenerateText: LlmGenerateTextTool) {
super();
}
@Transition({ to: 'prompt_executed' })
async prompt(state: PromptState, ctx: RunContext): Promise<PromptState> {
const args = ctx.args as { subject: string };
const result = await this.llmGenerateText.call(
{
prompt: this.render(__dirname + '/templates/prompt.md', { subject: args.subject }),
},
{ config: { provider: 'claude', model: 'claude-sonnet-4-6' } },
);
return { llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined };
}
@Transition({ from: 'prompt_executed', to: 'end' })
async respond(state: PromptState): Promise<unknown> {
await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, {
meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider },
});
return {};
}
}Example 2: Stateful workflow with dynamic routing
interface RoutingState {
value: number;
}
@Workflow({
widget: __dirname + '/routing.ui.yaml',
schema: z.object({ value: z.number().default(150) }).strict(),
})
export class RoutingWorkflow extends BaseWorkflow<{ value: number }, RoutingState> {
@Transition({ to: 'prepared' })
async setup(state: RoutingState, ctx: RunContext): Promise<RoutingState> {
const args = ctx.args as { value: number };
return { ...state, value: args.value };
}
@Transition({ from: 'prepared', to: 'high', priority: 10 })
@Guard('isAbove200')
async routeHigh(state: RoutingState): Promise<RoutingState> {
return state;
}
@Transition({ from: 'prepared', to: 'medium', priority: 5 })
@Guard('isAbove100')
async routeMedium(state: RoutingState): Promise<RoutingState> {
return state;
}
@Transition({ from: 'prepared', to: 'low' })
async routeLow(state: RoutingState): Promise<RoutingState> {
return state;
}
isAbove200(state: RoutingState): boolean {
return state.value > 200;
}
isAbove100(state: RoutingState): boolean {
return state.value > 100;
}
@Transition({ from: 'high', to: 'end' })
async showHigh(state: RoutingState): Promise<unknown> {
return {};
}
@Transition({ from: 'medium', to: 'end' })
async showMedium(state: RoutingState): Promise<unknown> {
return {};
}
@Transition({ from: 'low', to: 'end' })
async showLow(state: RoutingState): Promise<unknown> {
return {};
}
}Example 3: Chat loop with tool calling
interface ChatState {
llmResult?: LlmGenerateTextResult;
llmMeta?: LlmResultMeta;
delegateResult?: LlmDelegateResult;
}
@Workflow({
widget: __dirname + '/chat.ui.yaml',
schema: z.object({ prompt: z.string() }),
})
export class ChatWorkflow extends BaseWorkflow<{ prompt: string }, ChatState> {
constructor(
private readonly llmGenerateText: LlmGenerateTextTool,
private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool,
private readonly getWeather: GetWeather,
) {
super();
}
@Transition({ to: 'ready' })
async setup(state: ChatState, ctx: RunContext): Promise<ChatState> {
const args = ctx.args as { prompt: string };
await this.documentStore.save(LlmMessageDocument, {
role: 'user',
text: args.prompt,
});
return state;
}
@Transition({ from: 'ready', to: 'prompt_executed' })
@Transition({ from: 'tools_done', to: 'prompt_executed' })
async llmTurn(state: ChatState): Promise<ChatState> {
const result = await this.llmGenerateText.call(
{},
{ config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather'] } },
);
return { ...state, llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined };
}
@Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 })
@Guard('hasToolCalls')
async executeToolCalls(state: ChatState): Promise<ChatState> {
const result = await this.llmDelegateToolCalls.call({
message: state.llmResult!.message,
});
return { ...state, delegateResult: result.data };
}
hasToolCalls(state: ChatState): boolean {
return state.llmResult?.message.stopReason === 'tool_use';
}
@Transition({ from: 'awaiting_tools', to: 'tools_done' })
@Guard('allToolsComplete')
async toolsComplete(state: ChatState): Promise<ChatState> {
return state;
}
allToolsComplete(state: ChatState): boolean {
return state.delegateResult?.allCompleted ?? false;
}
@Transition({ from: 'prompt_executed', to: 'end' })
async respond(state: ChatState): Promise<unknown> {
await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, {
meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider },
});
return {};
}
}Workflow Lifecycle
- Workflow starts — initial transition (from
'start') fires, args available viactx.args - Transitions fire automatically in sequence
- Tool calls execute via
await this.tool.call(args)in transition methods - State is the return value of each transition, passed to the next
- If a transition targets
'end', workflow completes and returns its state - If a
wait: truetransition is reached, workflow pauses until externally triggered @Guardmethods control routing when multiple transitions share afromplace- On error, the workflow fails (or use try/catch in method bodies)
Checklist
- Extend
BaseWorkflow<TArgs, TState>and add@Workflow({ widget, schema? }) - Inject tools and sub-workflows via constructor — call via
await this.tool.call(args) - Define
@Transition({ to })method for workflow entry (from defaults to'start') — access args viactx.args - Define
@Transition({ from, to })methods for intermediate steps - Define
@Transition({ from, to: 'end' })method for completion - Use
wait: true+schemafor callback/user-input transitions - Use
@Guard('methodName')+priorityfor conditional routing - State is passed as the first parameter and returned from each transition
- Write YAML config with UI widgets only (no transitions)
- Register workflow as provider in a NestJS
@Module() - Add
@StudioApp({ title, workflows: [MyWorkflow] })to the module so workflows appear in Studio