Skip to Content
DocumentationBuildFundamentalsCreating 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 { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import { LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; @Workflow({ widget: __dirname + '/chat.ui.yaml', // UI config }) export class ChatWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } // 1. Entry point @Transition({ to: 'waiting_for_user' }) async setup(state: Record<string, unknown>): Promise<Record<string, unknown>> { return state; } // 2. Wait for user message @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string(), }) async userMessage(state: Record<string, unknown>, payload: string): Promise<Record<string, unknown>> { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: payload }); return state; } // 3. Call LLM and loop back @Transition({ from: 'ready', to: 'waiting_for_user', }) async llmTurn(state: Record<string, unknown>): Promise<Record<string, unknown>> { const result = await this.llmGenerateText.call({}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }); await this.documentStore.save(LlmMessageDocument, result.data!.message, { meta: { response: result.data!.response, provider: (result.metadata as { provider: string })?.provider }, }); return state; } }

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({ widget: __dirname + '/chat.ui.yaml', // UI-only YAML config })
  • widget — Path to YAML file containing UI widget configuration (optional)
  • schema — Zod schema that validates workflow input arguments (optional):
@Workflow({ widget: __dirname + '/prompt.ui.yaml', schema: z.object({ subject: z.string().default('coffee'), }), })

BaseWorkflow

All workflows extend BaseWorkflow, which provides:

Property / MethodDescription
this.documentStoreSave and query documents via this.documentStore.save(DocClass, content, options?)
this.renderRender Handlebars templates via this.render(templatePath, data?)

Context is passed as a parameter to transition methods via ctx: LoopstackContext:

Context PropertyDescription
ctx.userIdUser ID
ctx.workspaceIdWorkspace ID
ctx.workflowIdCurrent workflow run ID
ctx.argsValidated input arguments

Transition Types

Initial Transition — Entry Point

Runs once when the workflow starts. Uses @Transition with no from (defaults to 'start'):

@Transition({ to: 'ready' }) async setup(state: MyState, ctx: LoopstackContext): Promise<MyState> { const args = ctx.args as { subject: string }; return state; }

Standard Transition — State Change

Moves between states. Fires automatically unless wait: true is set.

@Transition({ from: 'ready', to: 'processed' }) async doWork(state: MyState): Promise<MyState> { const result = await this.myTool.call({ query: 'hello' }); return { ...state, 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(state: MyState): Promise<MyState> { ... }

Final Transition — Completion

Uses @Transition with to: 'end'. The return value is the workflow’s output (passed to parent workflow callbacks).

@Transition({ from: 'done', to: 'end' }) async finish(state: MyState): Promise<{ concept: string }> { return { concept: state.confirmedConcept! }; }

State

State is managed through a typed state interface passed as a parameter and returned from transitions:

interface MyState { counter: number; llmResult?: LlmGenerateTextResult; } export class MyWorkflow extends BaseWorkflow<Record<string, unknown>, MyState> { @Transition({ from: 'ready', to: 'processed' }) async process(state: MyState): Promise<MyState> { return { ...state, counter: (state.counter ?? 0) + 1 }; } }

Values persist even when the workflow pauses and resumes.

Injecting Tools

Tools are injected via standard NestJS constructor injection:

constructor( private readonly llmGenerateText: LlmGenerateTextTool, ) { super(); } @Transition({ from: 'ready', to: 'done' }) async process(state: MyState): Promise<MyState> { const result = await this.llmGenerateText.call( { prompt: 'Write a haiku' }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); return { ...state, llmResult: result.data }; }

Documents

Use this.documentStore.save() to create or update documents. Reference document classes directly — no injection needed.

// Create a document await this.documentStore.save(LlmMessageDocument, { role: 'user', content: 'Hello!', }); // Update an existing document by ID await this.documentStore.save( LlmMessageDocument, { role: 'assistant', content: 'Updated response' }, { id: 'response-1' }, ); // Hidden document (not shown in UI) await this.documentStore.save( LlmMessageDocument, { role: 'user', content: 'System prompt' }, { meta: { hidden: true } }, );

Templates

render is available directly on BaseWorkflow (like documentStore). 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(state: MyState, payload: { message: string }): Promise<MyState> { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: payload.message, }); return state; }

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(state: MyState): Promise<MyState> { ... } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: MyState): Promise<unknown> { ... } // Fallback — no guard hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; }

Places (States)

Places are implicit — defined by from/to values in your decorators. Two special places:

  • start — Implicit initial place (the initial transition moves from here when from is omitted)
  • 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: [ClaudeModule], providers: [ChatWorkflow], exports: [ChatWorkflow], }) export class ChatModule {}

File Structure

src/ ├── workflows/ │ ├── chat.workflow.ts │ ├── chat.ui.yaml │ └── templates/ │ └── systemMessage.md ├── chat.module.ts └── index.ts

Registry References

Last updated on