Creating Workflows
A workflow is a state machine defined as a TypeScript class. It extends BaseWorkflow and uses decorators to define transitions between states.
Chat Example
A simple chat workflow: wait for a user message, call LLM, display the response, and loop back.
import { z } from 'zod';
import { ClaudeGenerateText, ClaudeMessageDocument } from '@loopstack/claude-module';
import { BaseWorkflow, Initial, InjectTool, Transition, Workflow } from '@loopstack/common';
@Workflow({
uiConfig: __dirname + '/chat.ui.yaml', // UI config
})
export class ChatWorkflow extends BaseWorkflow {
@InjectTool() claudeGenerateText: ClaudeGenerateText;
// 1. Entry point
@Initial({
to: 'waiting_for_user',
})
async setup() {}
// 2. Wait for user message
@Transition({
from: 'waiting_for_user',
to: 'ready',
wait: true,
schema: z.string(),
})
async userMessage(payload: string) {
await this.repository.save(ClaudeMessageDocument, { role: 'user', content: payload });
}
// 3. Call LLM and loop back
@Transition({
from: 'ready',
to: 'waiting_for_user',
})
async llmTurn() {
const result = await this.claudeGenerateText.call({
claude: { model: 'claude-sonnet-4-6' },
messagesSearchTag: 'message',
});
// Create the assistant's response
await this.repository.save(ClaudeMessageDocument, result.data!);
}
}That’s a complete workflow. The state flow is:
start → waiting_for_user → [user sends message] → ready → llmTurn → waiting_for_user (loop)The @Workflow Decorator
@Workflow({
uiConfig: __dirname + '/chat.ui.yaml', // UI-only YAML config
})uiConfig— Path to YAML file containing UI widget configuration (optional)schema— Zod schema that validates workflow input arguments (optional):
@Workflow({
uiConfig: __dirname + '/prompt.ui.yaml',
schema: z.object({
subject: z.string().default('coffee'),
}),
})BaseWorkflow
All workflows extend BaseWorkflow, which provides:
| Property / Method | Description |
|---|---|
this.repository | Save and query documents via this.repository.save(DocClass, content, options?) |
this.ctx.args | The validated workflow input arguments |
this.ctx.context | Execution context (userId, workspaceId, etc.) |
this.ctx.runtime | Runtime data (transition payloads) |
this.render(path, data?) | Render a Handlebars template file |
Transition Types
@Initial — Entry Point
Runs once when the workflow starts. Can receive validated args as a parameter.
@Initial({ to: 'ready' })
async setup(args: { subject: string }) {
// args validated against the @Workflow schema
}@Transition — State Change
Moves between states. Fires automatically unless wait: true is set.
@Transition({ from: 'ready', to: 'processed' })
async doWork() {
const result = await this.myTool.call({ query: 'hello' });
this.data = result.data;
}A method can listen on multiple source states:
@Transition({ from: 'ready', to: 'prompt_executed' })
@Transition({ from: 'tools_done', to: 'prompt_executed' })
async llmTurn() { ... }@Final — Completion
Terminal transition. The return value is the workflow’s output (passed to parent workflow callbacks).
@Final({ from: 'done' })
async finish(): Promise<{ concept: string }> {
return { concept: this.confirmedConcept! };
}State
State is stored as plain instance properties — automatically checkpointed and restored across transitions.
export class MyWorkflow extends BaseWorkflow {
counter: number = 0;
llmResult?: ClaudeGenerateTextResult;
@Transition({ from: 'ready', to: 'processed' })
async process() {
this.counter++;
const result = await this.claudeGenerateText.call({ ... });
this.llmResult = result.data;
}
}No decorator needed. Values persist even when the workflow pauses and resumes.
Injecting Tools
Tools are injected with @InjectTool() and called directly in transition methods:
@InjectTool() claudeGenerateText: ClaudeGenerateText;
@InjectTool() createChatMessage: CreateChatMessage;
@Transition({ from: 'ready', to: 'done' })
async process() {
const result = await this.claudeGenerateText.call({
claude: { model: 'claude-sonnet-4-6' },
prompt: 'Write a haiku',
});
}Documents
Use this.repository.save() to create or update documents. Reference document classes directly — no injection needed.
// Create a document
await this.repository.save(ClaudeMessageDocument, {
role: 'user',
content: 'Hello!',
});
// Update an existing document by ID
await this.repository.save(ClaudeMessageDocument,
{ role: 'assistant', content: 'Updated response' },
{ id: 'response-1' },
);
// Hidden document (not shown in UI)
await this.repository.save(ClaudeMessageDocument,
{ role: 'user', content: 'System prompt' },
{ meta: { hidden: true } },
);Templates
Use this.render() to render Handlebars template files:
const rendered = this.render(__dirname + '/templates/prompt.md', {
subject: args.subject,
});Wait Transitions
Add wait: true to pause the workflow until externally triggered — by user input, a button click, or a sub-workflow callback.
@Transition({
from: 'waiting_for_user',
to: 'ready',
wait: true,
schema: z.object({ message: z.string() }),
})
async userMessage(payload: { message: string }) {
await this.repository.save(ClaudeMessageDocument, {
role: 'user',
content: payload.message,
});
}Use schema to validate and type the incoming payload.
Guards (Conditional Routing)
When multiple transitions share the same from state, use @Guard to choose which one fires. Higher priority is checked first. A transition without a guard acts as the fallback.
@Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 })
@Guard('hasToolCalls')
async executeToolCalls() { ... }
@Final({ from: 'prompt_executed' })
async respond() { ... } // Fallback — no guard
hasToolCalls() {
return this.llmResult?.stop_reason === 'tool_use';
}Places (States)
Places are implicit — defined by from/to values in your decorators. Two special places:
start— Implicit initial place (the@Initialmethod transitions from here)end— When reached, the workflow completes
All other place names are arbitrary strings you choose.
YAML Configuration
YAML files define UI layout only — no transitions, conditions, or tool calls. They configure what widgets appear in the Studio interface.
title: 'My Workflow'
description: 'What this workflow does'
ui:
widgets:
- widget: form
enabledWhen: [waiting]
options:
properties:
name:
title: Name
actions:
- type: button
transition: userResponse
label: Submit
- widget: prompt-input
enabledWhen: [waiting_for_user]
options:
transition: userMessageThe transition values must match method names of wait: true transitions.
enabledWhen
Controls when a widget is visible based on the current workflow place:
- widget: prompt-input
enabledWhen:
- waiting_for_user # Only show at this place
options:
transition: userMessageForm Actions
Buttons that trigger wait: true transitions when clicked:
actions:
- type: button
transition: confirm # Must match the method name
label: 'Confirm'Module Registration
@Module({
imports: [LoopCoreModule, ClaudeModule],
providers: [ChatWorkflow],
exports: [ChatWorkflow],
})
export class ChatModule {}Workspace Registration
Make workflows visible in the Studio UI:
@Workspace({ config: { title: 'My Workspace' } })
export class DefaultWorkspace implements WorkspaceInterface {
@InjectWorkflow() chatWorkflow: ChatWorkflow;
}File Structure
src/
├── workflows/
│ ├── chat.workflow.ts
│ ├── chat.ui.yaml
│ └── templates/
│ └── systemMessage.md
├── chat.module.ts
└── index.tsRegistry References
- chat-example-workflow — Multi-turn chat workflow (the minimal example on this page)
- prompt-example-workflow — Simple single-turn prompt workflow
- tool-call-example-workflow — Tool calling loop with guards and conditional routing
- dynamic-routing-example-workflow — Multi-level guard-based routing
- workflow-state-example-workflow — State management with instance properties and helper methods
- run-sub-workflow-example — Sub-workflow execution with callbacks