Skip to Content
DocumentationGuidesCreating Workflows

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 / MethodDescription
this.repositorySave and query documents via this.repository.save(DocClass, content, options?)
this.ctx.argsThe validated workflow input arguments
this.ctx.contextExecution context (userId, workspaceId, etc.)
this.ctx.runtimeRuntime 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 @Initial method 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: userMessage

The 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: userMessage

Form 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.ts

Registry References

Last updated on