# Loopstack Loopstack is a TypeScript workflow framework for building stateful automations, AI agents, and interactive workflows on top of NestJS. --- # Build Step-by-step guides for building with Loopstack — getting started, workflow fundamentals, AI/LLM integration, workflow patterns, and third-party integrations. --- > Source: https://loopstack.ai/llms/build/ai/agent-workflows.md --- title: Agent Workflows description: Building autonomous LLM agents that call tools in a loop. Covers the built-in AgentWorkflow module, custom agent loops with @Guard routing, error recovery, and max-iterations limits. --- # Agent Workflows Build LLM agents that call tools, handle errors, and run as sub-workflows. Use the built-in `AgentWorkflow` for the common case, or build your own loop from scratch with the same decorators. ## Using the Built-In Agent Install the agent module: ```bash npm install @loopstack/agent ``` Register tools in your module so the agent can use them: ```typescript @Module({ imports: [ClaudeModule, AgentModule], providers: [GlobTool, GrepTool, ReadTool, MyWorkflow], exports: [MyWorkflow], }) export class MyModule {} ``` Launch the agent from any workflow: ```typescript @Transition({ from: 'planning', to: 'implementing' }) async runAgent(state: MyState): Promise { await this.agent.run({ system: 'You are a code exploration agent. Summarize your findings.', tools: ['glob', 'grep', 'read'], userMessage: 'Find all API endpoints in the codebase.', }, { callback: { transition: 'agentDone' } }); return state; } ``` The agent runs a full tool-calling loop automatically: LLM turn → tool execution → loop back → until the LLM responds without tool calls. ### Agent Args | Arg | Type | Required | Description | | ------------- | ---------- | -------- | --------------------------------------------- | | `system` | `string` | yes | System prompt | | `tools` | `string[]` | yes | Tool names available to the LLM | | `userMessage` | `string` | yes | Initial user message | | `context` | `string` | no | Hidden context message (e.g. pre-loaded docs) | ### Pre-Loading Context Pass documentation or environment data as a hidden context message. The LLM sees it but it's not shown in the UI: ```typescript const docs = await this.loadFiles.call({ files: ['docs/api-reference.md', 'docs/architecture.md'], basePath: './src/assets', }); const context = this.render(__dirname + '/templates/context.md', { docs: docs.data, projectName: args.projectName, }); await this.agent.run({ system: 'You are a documentation agent.', tools: ['read', 'write', 'glob', 'grep'], userMessage: 'Generate API documentation.', context, }); ``` ## Tool Resolution When the LLM calls a tool, it's resolved from the NestJS dependency injection container by its `@Tool({ name })` value. The agent workflow only injects the three tools it always needs (`LlmGenerateTextTool`, `LlmDelegateToolCallsTool`, `LlmUpdateToolResultTool`). Domain-specific tools like `glob` or `read` are resolved from the module at runtime. This means you register tools once in the module and they're available to the agent and all other workflows. ## Error Handling Tool errors are handled automatically. When a tool call fails (schema validation or runtime error), the error is returned to the LLM as an `is_error` tool result. The LLM sees the error message and can self-correct on the next turn. The `LlmDelegateResult` includes error metadata: ```typescript interface LlmDelegateResult { allCompleted: boolean; toolResults: { type: 'tool_result'; toolCallId: string; content?: string; isError?: boolean }[]; pendingCount: number; hasErrors: boolean; errorCount: number; errors: { toolName: string; toolCallId: string; message: string }[]; } ``` ## Canceling Pending Tools If the agent is stuck at `awaiting_tools` (e.g. a sub-workflow hasn't returned), a "Cancel pending tools" button appears in the UI. This cancels all pending child workflows recursively and returns the agent to the LLM loop. ## Building a Custom Agent The built-in `AgentWorkflow` is a regular workflow. When you need custom behavior, copy it and modify directly. Here's the full loop: ```typescript import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; import type { LlmDelegateResult, LlmGenerateTextResult, LlmResultMeta } from '@loopstack/llm-provider-module'; import { LlmDelegateToolCallsTool, LlmGenerateTextTool, LlmMessageDocument, LlmUpdateToolResultTool, } from '@loopstack/llm-provider-module'; interface AgentState { llmResult?: LlmGenerateTextResult; llmMeta?: LlmResultMeta; delegateResult?: LlmDelegateResult; } @Workflow({ widget: __dirname + '/my-agent.ui.yaml', schema: z.object({ instructions: z.string() }), }) export class MyAgentWorkflow extends BaseWorkflow<{ instructions: string }, AgentState> { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, private readonly llmUpdateToolResult: LlmUpdateToolResultTool, private readonly myCustomTool: MyCustomTool, ) { super(); } @Transition({ to: 'ready' }) async setup(state: AgentState, ctx: LoopstackContext): Promise { const args = ctx.args as { instructions: string }; await this.documentStore.save(LlmMessageDocument, { role: 'user', content: args.instructions, }); return state; } @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: AgentState): Promise { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', system: 'You are a custom agent.', tools: ['my_custom_tool'], }, }, ); 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: AgentState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); const result = await this.llmDelegateToolCalls.call({ message: state.llmResult!.message, callback: { transition: 'toolResultReceived' }, }); return { ...state, delegateResult: result.data }; } @Transition({ from: 'awaiting_tools', to: 'awaiting_tools', wait: true }) async toolResultReceived(state: AgentState, payload: unknown): Promise { const result = await this.llmUpdateToolResult.call({ delegateResult: state.delegateResult!, completedTool: payload, }); return { ...state, delegateResult: result.data }; } @Transition({ from: 'awaiting_tools', to: 'ready' }) @Guard('allToolsComplete') async toolsComplete(state: AgentState): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: state.delegateResult!.toolResults.map((tr) => ({ type: 'tool_result' as const, toolCallId: tr.toolCallId, content: tr.content ?? '', isError: tr.isError ?? false, })), }); return state; } @Transition({ from: 'prompt_executed', to: 'end' }) @Guard('isEndTurn') async respond(state: AgentState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return {}; } private hasToolCalls(state: AgentState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } private allToolsComplete(state: AgentState): boolean { return state.delegateResult?.allCompleted ?? false; } private isEndTurn(state: AgentState): boolean { return state.llmResult?.message.stopReason === 'end_turn'; } } ``` ### Adding User Interaction Pause for user input between LLM turns: ```typescript // Instead of final transition, go to waiting_for_user @Transition({ from: 'prompt_executed', to: 'waiting_for_user' }) @Guard('isEndTurn') async respondToUser(state: AgentState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return state; } @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string() }) async userMessage(state: AgentState, payload: string): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: payload, }); return state; } ``` > **Tip:** The `@loopstack/agent` package ships `ChatAgentWorkflow` which implements this pattern out of the box. Use it when you need a multi-turn chat agent without customization. ### Wrapping an Agent as a Tool Make an agent callable by other agents via a task tool: ```typescript @Tool({ name: 'explore_codebase', description: 'Launch a sub-agent to explore the codebase.', schema: z.object({ instructions: z.string() }), }) export class ExploreTask extends BaseTool { constructor(private readonly agentWorkflow: AgentWorkflow) { super(); } protected async handle( args: { instructions: string }, ctx: LoopstackContext, options?: ToolCallOptions, ): Promise { const result = await this.agentWorkflow.run( { system: 'You are a codebase exploration agent.', tools: ['glob', 'grep', 'read'], userMessage: args.instructions, }, { callback: options?.callback }, ); return { data: { workflowId: result.workflowId }, pending: { workflowId: result.workflowId }, }; } async complete(result: Record): Promise { const data = result as { data?: { response?: string } }; return { data: data.data?.response ?? result }; } } ``` This enables multi-agent architectures where an orchestrator agent delegates tasks to specialized sub-agents. ## Registry References - [@loopstack/agent](https://loopstack.ai/registry/loopstack-agent) — Built-in agent workflow module - [@loopstack/code-agent](https://loopstack.ai/registry/loopstack-code-agent) — Code exploration agent (ExploreTask) built on @loopstack/agent - [delegate-error-example-workflow](https://loopstack.ai/registry/loopstack-delegate-error-example-workflow) — Example demonstrating tool error handling and recovery --- > Source: https://loopstack.ai/llms/build/ai/chat-flows.md --- title: Chat Flows description: Building multi-turn conversational workflows with LLMs using LlmMessageDocument, messagesSearchTag pattern, and wait transitions for user input. --- # Chat Flows Build multi-turn conversational workflows where users exchange messages with an LLM. Messages are persisted as documents and accumulated across turns using the `messagesSearchTag` pattern. ## Example ```typescript 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' }) export class ChatWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } @Transition({ to: 'waiting_for_user' }) async setup(state: Record): Promise> { await this.documentStore.save( LlmMessageDocument, { role: 'user', content: this.render(__dirname + '/templates/systemMessage.md') }, { meta: { hidden: true } }, ); return state; } @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string() }) async userMessage(state: Record, payload: string): Promise> { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: payload }); return state; } @Transition({ from: 'ready', to: 'waiting_for_user' }) async llmTurn(state: Record): Promise> { 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; } } ``` ## YAML Config ```yaml title: 'Chat Assistant' ui: widgets: - widget: prompt-input enabledWhen: - waiting_for_user options: transition: userMessage ``` ## How Message Accumulation Works 1. All messages are saved as `LlmMessageDocument` — automatically tagged with `message` 2. The LLM provider module collects all documents with the `message` tag as conversation history by default 3. Each new message adds to the conversation — the LLM sees the full history on every turn ## Chat Loop Flow ``` setup → waiting_for_user → [user sends message] → ready → llmTurn → waiting_for_user (loop) ``` 1. **Initial transition** — Create system message (hidden from UI) 2. Workflow enters `waiting_for_user` — UI shows the prompt-input widget 3. User sends message → `userMessage` fires, saves user message as document 4. `llmTurn` fires — calls the LLM with full message history, saves response 5. Workflow returns to `waiting_for_user` — loop continues ## Combining with Tool Calling Add tool calling to a chat flow by combining the patterns from [AI Tool Calling](/docs/build/ai/tool-calling): ```typescript import type { LlmResultMeta } from '@loopstack/llm-provider-module'; @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: ChatState): Promise { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather', 'search_database'] } }, ); 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 { ... } @Transition({ from: 'prompt_executed', to: 'waiting_for_user' }) async respond(state: ChatState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return state; } ``` ## Registry References - [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) — Multi-turn chat with Claude, system message, and prompt-input widget - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — Chat with tool calling loop --- > Source: https://loopstack.ai/llms/build/ai/llm-providers.md --- title: LLM Providers description: Using multiple LLM providers (Claude, OpenAI) through the runtime provider registry. Covers LlmProviderModule setup, provider selection per-call, and switching providers without code changes. --- # LLM Providers Loopstack supports multiple LLM providers through a runtime registry. Provider modules self-register at startup. Workflows and tools resolve providers by name — swap or use multiple providers in parallel without changing workflow code. ## Quick Start Import `LlmProviderModule` for the adapter tools and a provider module (e.g. `ClaudeModule`) to register the LLM backend: ```typescript import { ClaudeModule } from '@loopstack/claude-module'; import { LlmProviderModule } from '@loopstack/llm-provider-module'; @Module({ imports: [LoopstackModule.forRoot(), LlmProviderModule.forRoot({}), ClaudeModule], }) export class AppModule {} ``` ## Module-Level Defaults Use `LlmProviderModule.forRoot()` to set a default model for all LLM calls in your app. Use `forFeature()` to override per-module: ```typescript // app.module.ts — global default model @Module({ imports: [LoopstackModule.forRoot(), LlmProviderModule.forRoot({ model: 'claude-sonnet-4-5' }), ClaudeModule], }) export class AppModule {} ``` ```typescript // premium-feature.module.ts — this module uses a stronger model @Module({ imports: [LlmProviderModule.forFeature({ model: 'claude-opus-4-6' })], providers: [PremiumWorkflow], }) export class PremiumFeatureModule {} ``` ## Per-Call Configuration Override provider and model at individual call sites via `options.config`. Per-call config always takes priority over module defaults. ```typescript export class MyWorkflow extends BaseWorkflow { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, ) { super(); } } ``` ```typescript const result = await this.llmGenerateText.call( { prompt: 'Hello!' }, { config: { provider: 'claude', model: 'claude-opus-4-6', system: 'You are a helpful assistant.', messagesSearchTag: 'message', tools: ['get_weather'], }, }, ); ``` ### Args vs Config LLM tools separate **args** (per-request data) from **config** (provider/model/behavior settings): | Parameter | Location | Description | | ------------------- | -------- | --------------------------------------- | | `prompt` | args | Simple prompt string | | `messages` | args | Explicit message array | | `outputSchema` | args | JSON Schema (generate object only) | | `provider` | config | LLM provider name (e.g. `'claude'`) | | `model` | config | Model name (e.g. `'claude-sonnet-4-6'`) | | `system` | config | System prompt | | `messagesSearchTag` | config | Load messages from documents by tag | | `tools` | config | Tool names the LLM can call | ## Using Multiple Providers Import both modules and configure each call with its provider: ```typescript @Module({ imports: [LoopstackModule.forRoot(), ClaudeModule, OpenAiModule], }) export class AppModule {} ``` ```typescript // Use Claude for complex tasks const smartResult = await this.llmGenerateText.call( { prompt: 'Analyze this code...' }, { config: { provider: 'claude', model: 'claude-opus-4-6' } }, ); // Use OpenAI for simple tasks const fastResult = await this.llmGenerateText.call( { prompt: 'Summarize in one line...' }, { config: { provider: 'openai', model: 'gpt-4o-mini' } }, ); ``` ## Adapter Tools All LLM interactions go through adapter tools from `@loopstack/llm-provider-module`. This ensures validation, interceptors, and logging apply to every LLM call. | Tool | Purpose | | -------------------------- | --------------------------------------------- | | `LlmGenerateTextTool` | Text generation with optional tool calling | | `LlmGenerateObjectTool` | Structured output conforming to a JSON Schema | | `LlmDelegateToolCallsTool` | Execute tool calls from an LLM response | | `LlmUpdateToolResultTool` | Handle async tool completion callbacks | ## Message Documents All providers share a single `LlmMessageDocument` with normalized content. Native API responses are stored in `entity.meta.response` for provider-specific round-trips. | Document | Content Format | Widget | | -------------------- | --------------------------------------------------- | ------------- | | `LlmMessageDocument` | Normalized (`text`, `thinking`, `tool_call` blocks) | `llm-message` | ## Environment Variables | Variable | Provider | Description | | ------------------- | -------- | ---------------------- | | `ANTHROPIC_API_KEY` | Claude | API key | | `OPENAI_API_KEY` | OpenAI | API key | | `CLAUDE_MODEL` | Claude | Default model fallback | | `OPENAI_MODEL` | OpenAI | Default model fallback | ## Available Providers | Provider | Module | ID | | ---------------- | -------------------------- | ---------- | | Anthropic Claude | `@loopstack/claude-module` | `'claude'` | | OpenAI | `@loopstack/openai-module` | `'openai'` | --- > Source: https://loopstack.ai/llms/build/ai/structured-output.md --- title: AI Structured Output description: Forcing LLMs to return structured JSON data using LlmGenerateObjectTool with Zod schemas. Provider-agnostic — works with Claude, OpenAI, and other providers. --- # AI Structured Output Use `LlmGenerateObjectTool` from `@loopstack/llm-provider-module` to generate structured data conforming to a JSON Schema. Provider-agnostic — works with Claude, OpenAI, and other providers. ## Define a Document ```typescript import { z } from 'zod'; import { Document } from '@loopstack/common'; export const FileDocumentSchema = z .object({ filename: z.string(), description: z.string(), code: z.string(), }) .strict(); export type FileDocumentType = z.infer; @Document({ schema: FileDocumentSchema, widget: __dirname + '/file-document.yaml', }) export class FileDocument { filename: string; description: string; code: string; } ``` ## Workflow Example ```typescript import { toJSONSchema, z } from 'zod'; import { BaseWorkflow, DocumentEntity, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; import type { LlmGenerateObjectResult } from '@loopstack/llm-provider-module'; import { LlmGenerateObjectTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; import { FileDocument, FileDocumentSchema, FileDocumentType } from './documents/file-document'; interface StructuredOutputState { language?: string; llmResult?: DocumentEntity; } @Workflow({ schema: z.object({ language: z.enum(['python', 'javascript', 'java', 'cpp', 'ruby', 'go', 'php']).default('python'), }), }) export class PromptStructuredOutputWorkflow extends BaseWorkflow<{ language: string }, StructuredOutputState> { constructor(private readonly llmGenerateObject: LlmGenerateObjectTool) { super(); } @Transition({ to: 'ready' }) async greeting(state: StructuredOutputState, ctx: LoopstackContext): Promise { const args = ctx.args as { language: string }; await this.documentStore.save( LlmMessageDocument, { role: 'assistant', content: [{ type: 'text', text: `Creating a Hello World script in ${args.language}...` }], }, { id: 'status' }, ); return { ...state, language: args.language }; } @Transition({ from: 'ready', to: 'prompt_executed' }) async prompt(state: StructuredOutputState): Promise { const result = await this.llmGenerateObject.call( { outputSchema: toJSONSchema(FileDocumentSchema) as Record, prompt: this.render(__dirname + '/templates/prompt.md', { language: state.language }), }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); const objectResult = result.data as LlmGenerateObjectResult; const llmResult = await this.documentStore.save(FileDocument, objectResult.data as FileDocumentType, { validate: 'skip', }); return { ...state, llmResult }; } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: StructuredOutputState): Promise { await this.documentStore.save( LlmMessageDocument, { role: 'assistant', content: [{ type: 'text', text: `Generated: ${state.llmResult?.content?.description ?? ''}` }], }, { id: 'status' }, ); return {}; } } ``` ## How It Works 1. Convert your Zod schema to JSON Schema using `toJSONSchema()` 2. Pass the JSON Schema as `outputSchema` to `llmGenerateObject.call()` 3. The provider forces the LLM to return data matching the schema 4. Save the result as a typed document using `this.documentStore.save()` ## Key Parameters ```typescript await this.llmGenerateObject.call( { outputSchema: toJSONSchema(MyDocumentSchema) as Record, prompt: 'Generate structured data.', }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); ``` ## Registry References - [prompt-structured-output-example-workflow](https://loopstack.ai/registry/loopstack-prompt-structured-output-example-workflow) — Generates structured code files using the LLM provider --- > Source: https://loopstack.ai/llms/build/ai/text-generation.md --- title: AI Text Generation description: Calling LLMs for text generation using LlmGenerateTextTool. Covers setup, system prompts, message history, provider selection, prompt caching, and streaming. --- # AI Text Generation Generate text from any configured LLM provider using `LlmGenerateTextTool`. Pass prompts, system instructions, and message history — the tool handles provider routing, token counting, and optional streaming. ## Setup ```typescript import { Module } from '@nestjs/common'; import { ClaudeModule } from '@loopstack/claude-module'; @Module({ imports: [ClaudeModule], providers: [PromptWorkflow], exports: [PromptWorkflow], }) export class PromptModule {} ``` ## Example Workflow ```typescript import { z } from 'zod'; import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } 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({ 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: LoopstackContext): Promise { 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 { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return {}; } } ``` ## Call Options ```typescript await this.llmGenerateText.call( { // Option 1: Simple prompt prompt: 'Write a haiku about coffee', // Option 2: Explicit messages messages: [{ role: 'user', content: 'Write a haiku about coffee' }], }, { config: { provider: 'claude', model: 'claude-sonnet-4-6', system: 'You are a helpful assistant.', // Option 3: Collect documents by tag as conversation history messagesSearchTag: 'message', }, }, ); ``` ## Using Templates Render Handlebars templates for complex prompts (`this.render()` is available from `BaseWorkflow`): ```typescript const rendered = this.render(__dirname + '/templates/prompt.md', { subject: args.subject, }); const result = await this.llmGenerateText.call( { prompt: rendered }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); ``` ## Environment Variables | Variable | Description | | ------------------- | ----------------- | | `ANTHROPIC_API_KEY` | Anthropic API key | ## Registry References - [prompt-example-workflow](https://loopstack.ai/registry/loopstack-prompt-example-workflow) — Single-turn prompt with subject parameter and Handlebars template --- > Source: https://loopstack.ai/llms/build/ai/tool-calling.md --- title: AI Tool Calling description: Enabling LLMs to invoke workflow tools via function calling. Covers LlmDelegateToolCallsTool, tool descriptions, passing tools to LLM calls, and handling tool results. --- # AI Tool Calling Enable the LLM to call workflow tools (function calling). The LLM decides which tools to invoke, and `LlmDelegateToolCallsTool` executes them. ## Create a Tool for the LLM Tools exposed to the LLM need a `description` so the LLM knows when to use them: ```typescript import { z } from 'zod'; import { BaseTool, Tool, ToolResult } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; @Tool({ name: 'get_weather', description: 'Retrieve weather information.', schema: z.object({ location: z.string().describe('City or location name'), }), }) export class GetWeather extends BaseTool<{ location: string }, object, string> { protected async handle(_args: { location: string }, _ctx: LoopstackContext): Promise> { return Promise.resolve({ type: 'text', data: 'Mostly sunny, 14C, rain in the afternoon.' }); } } ``` ## Tool Calling Workflow ```typescript import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common'; import type { LlmDelegateResult, LlmGenerateTextResult, LlmResultMeta } from '@loopstack/llm-provider-module'; import { LlmDelegateToolCallsTool, LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; import { GetWeather } from './tools/get-weather.tool'; interface ToolCallState { llmResult?: LlmGenerateTextResult; llmMeta?: LlmResultMeta; delegateResult?: LlmDelegateResult; } @Workflow({}) export class ToolCallWorkflow extends BaseWorkflow, ToolCallState> { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, private readonly getWeather: GetWeather, ) { super(); } @Transition({ to: 'ready' }) async setup(state: ToolCallState): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: 'How is the weather in Berlin?', }); return state; } @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: ToolCallState): Promise { 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: ToolCallState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); const result = await this.llmDelegateToolCalls.call({ message: state.llmResult!.message, }); return { ...state, delegateResult: result.data }; } hasToolCalls(state: ToolCallState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } @Transition({ from: 'awaiting_tools', to: 'ready' }) @Guard('allToolsComplete') async toolsComplete(state: ToolCallState): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: state.delegateResult!.toolResults.map((tr) => ({ type: 'tool_result' as const, toolCallId: tr.toolCallId, content: tr.content ?? '', isError: tr.isError ?? false, })), }); return state; } allToolsComplete(state: ToolCallState): boolean { return state.delegateResult?.allCompleted ?? false; } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: ToolCallState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return {}; } } ``` ## How the Loop Works ``` setup → llmTurn → [hasToolCalls?] ├─ yes → executeToolCalls → toolsComplete → llmTurn (loop) └─ no → respond (done) ``` 1. `llmGenerateText` is called — the `tools` array in config lists available tools 2. If the LLM returns `stopReason: 'tool_use'`, the guard routes to `executeToolCalls` 3. `llmDelegateToolCalls` executes the requested tools and stores results 4. The loop continues back to the LLM 5. When no more tool calls are needed, the fallback transition to `end` fires ## Key Concepts - **`tools` array in config** — Lists tool names the LLM can call (must match `@Tool({ name })` values) - **`llmDelegateToolCalls`** — Executes tool calls from the LLM response message - **`message.stopReason === 'tool_use'`** — The LLM wants to call a tool - **`allCompleted`** — All delegated tool calls have finished - **`@Guard` + `priority`** — Routes between tool calling and final response ## Registry References - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — Complete tool calling loop with GetWeather tool, guard-based routing, and delegate pattern --- > Source: https://loopstack.ai/llms/build/fundamentals/documents.md --- title: Creating Documents description: How to define typed document classes with @Document() decorator, Zod validation schemas, and YAML widget configs for rendering in Loopstack Studio. --- # Creating Documents Documents are typed data objects displayed in the Loopstack Studio UI. They have a Zod schema for validation and a YAML config for rendering. ## Basic Document ```typescript import { z } from 'zod'; import { Document } from '@loopstack/common'; export const NotesSchema = z.object({ text: z.string(), }); @Document({ schema: NotesSchema, widget: __dirname + '/notes.ui.yaml', }) export class NotesDocument { text: string; } ``` ```yaml # notes.ui.yaml type: document ui: widgets: - widget: form options: properties: text: title: Notes widget: textarea rows: 8 ``` ## The `@Document` Decorator ```typescript @Document({ schema: NotesSchema, // Zod schema for validation widget: __dirname + '/notes.ui.yaml', // Path to UI YAML config }) ``` - **`schema`** — Zod schema that validates document content - **`widget`** — Path to YAML file defining how the document renders in the UI ## Saving Documents Use `this.documentStore.save()` inside workflow transition methods. Reference document classes directly — no injection needed. `documentStore` is auto-injected on `BaseWorkflow` and `BaseTool`. ```typescript // Create a new document await this.documentStore.save(NotesDocument, { text: 'Hello!' }); // Create/update with a specific ID await this.documentStore.save(NotesDocument, { text: 'Updated content' }, { id: 'notes-1' }); // With meta options await this.documentStore.save(NotesDocument, { text: 'Hidden note' }, { id: 'hidden', meta: { hidden: true } }); ``` ### Save Options | Option | Type | Description | | ------------- | --------- | ----------------------------------------------- | | `id` | `string` | Custom ID — use for updating existing documents | | `meta.hidden` | `boolean` | Hide from the UI | ## Built-in Document Types These are available without creating custom documents: | Document | Source | Key Fields | | -------------------- | -------------------------------- | ---------------------------------------------------- | | `LlmMessageDocument` | `@loopstack/llm-provider-module` | `role`, `content` | | `LinkDocument` | `@loopstack/common` | `label`, `workflowId`, `status`, `embed`, `expanded` | | `MessageDocument` | `@loopstack/common` | `role`, `content` | | `MarkdownDocument` | `@loopstack/common` | `markdown` | | `PlainDocument` | `@loopstack/common` | `text` | | `ErrorDocument` | `@loopstack/common` | `error` | ```typescript import { LinkDocument, MarkdownDocument } from '@loopstack/common'; import { LlmMessageDocument } from '@loopstack/llm-provider-module'; await this.documentStore.save(LlmMessageDocument, { role: 'assistant', content: 'Hello! How can I help?', }); await this.documentStore.save(MarkdownDocument, { markdown: '# Report\n- Item 1\n- Item 2', }); ``` ## YAML UI Configuration ### Form Widget The `form` widget renders document fields as an editable form: ```yaml type: document ui: widgets: - widget: form options: order: [name, description, items] properties: name: title: Name description: title: Description widget: textarea items: title: Items collapsed: true items: title: Item actions: - type: button transition: submit label: 'Submit' ``` ### Available Widget Types Use these in `options.properties..widget`: | Widget | Description | | ----------- | ------------------------------------ | | `text` | Single-line text input (default) | | `textarea` | Multi-line text area | | `select` | Dropdown select | | `radio` | Radio button group | | `checkbox` | Checkbox | | `switch` | Toggle switch | | `slider` | Numeric slider | | `code-view` | Code editor with syntax highlighting | ### Property Options | Option | Type | Description | | ------------- | --------- | ---------------------------------- | | `title` | `string` | Display label | | `widget` | `string` | Widget type | | `placeholder` | `string` | Placeholder text | | `rows` | `number` | Visible rows (textarea) | | `readonly` | `boolean` | Read-only field | | `hidden` | `boolean` | Hide the field | | `disabled` | `boolean` | Disable interaction | | `collapsed` | `boolean` | Collapse arrays/objects by default | | `items` | `object` | UI config for array items | ### Document Actions Buttons that trigger `wait: true` transitions in the workflow: ```yaml actions: - type: button transition: confirm # Must match the method name label: 'Confirm' ``` ### Tags Categorize documents for filtering and searching: ```yaml type: document tags: - message - important ``` Tags are used by LLM tools with `messagesSearchTag` config to collect documents as conversation history. ## Structured Output Example Documents work with `LlmGenerateObjectTool` for AI-generated structured data: ```typescript export const FileDocumentSchema = z .object({ filename: z.string(), description: z.string(), code: z.string(), }) .strict(); @Document({ schema: FileDocumentSchema, widget: __dirname + '/file-document.yaml', }) export class FileDocument { filename: string; description: string; code: string; } ``` ```yaml # file-document.yaml type: document ui: widgets: - widget: form options: order: [filename, description, code] properties: filename: title: File Name readonly: true description: title: Description readonly: true code: title: Code widget: code-view ``` Used in a workflow: ```typescript const result = await this.llmGenerateObject.call( { outputSchema: toJSONSchema(FileDocumentSchema) as Record, prompt: 'Generate a Hello World script in Python', }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); ``` ## Registry References - [prompt-structured-output-example-workflow](https://loopstack.ai/registry/loopstack-prompt-structured-output-example-workflow) — FileDocument with code-view widget for AI-generated code - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-meeting-notes-example-workflow) — MeetingNotesDocument and OptimizedNotesDocument with form widgets and action buttons - [test-ui-documents-example-workflow](https://loopstack.ai/registry/loopstack-test-ui-documents-example-workflow) — Demonstrates all core UI document types: MessageDocument, ErrorDocument, MarkdownDocument, PlainDocument --- > Source: https://loopstack.ai/llms/build/fundamentals/modules.md --- title: Modules & Workspaces description: How to organize workflows, tools, and services into NestJS modules. Covers module structure, workspace configuration, and provider registration. --- # Modules & Workspaces Loopstack uses NestJS modules to organize your application. Workflows and tools are registered as standard NestJS providers. ## Modules A module groups related workflows, tools, and services together. ### Basic Module ```typescript import { Module } from '@nestjs/common'; import { ClaudeModule } from '@loopstack/claude-module'; import { MyTool } from './tools/my.tool'; import { MyWorkflow } from './workflows/my.workflow'; @Module({ imports: [ClaudeModule], providers: [MyWorkflow, MyTool], exports: [MyWorkflow, MyTool], }) export class MyFeatureModule {} ``` ### Key Rules - **`LoopCoreModule` is global** — registered once by `LoopstackModule.forRoot()`, do not import it in feature modules - **Import feature modules** like `ClaudeModule` for AI, `SandboxToolModule` for Docker sandboxes, etc. - **Documents are NOT providers** — they are plain DTOs and don't need registration - **Export workflows and tools** that other modules might need ### Registering in AppModule Add your module to the main `AppModule`: ```typescript import { Module } from '@nestjs/common'; import { LoopstackModule } from '@loopstack/loopstack-module'; import { MyFeatureModule } from './my-feature/my-feature.module'; @Module({ imports: [LoopstackModule.forRoot(), MyFeatureModule], }) export class AppModule {} ``` ### Multi-Module Example For larger applications, split functionality across modules: ```typescript // analytics.module.ts @Module({ imports: [ClaudeModule], providers: [AnalyticsWorkflow, DataFetchTool], exports: [AnalyticsWorkflow], }) export class AnalyticsModule {} // notifications.module.ts @Module({ providers: [NotificationWorkflow, EmailTool], exports: [NotificationWorkflow], }) export class NotificationsModule {} // app.module.ts @Module({ imports: [LoopstackModule.forRoot(), AnalyticsModule, NotificationsModule], }) export class AppModule {} ``` ## Module Configuration (`forRoot` / `forFeature`) Many Loopstack modules support `forRoot()` and `forFeature()` for configuring defaults. This follows the standard NestJS dynamic module pattern. - **`forRoot(config)`** — sets **global defaults** for the module. Call once in your root `AppModule`. - **`forFeature(config)`** — **overrides defaults** for a specific feature module. Tools in that module use the override instead of the global. ```typescript // app.module.ts — global default: all LLM calls use claude-sonnet-4-6 @Module({ imports: [ LoopstackModule.forRoot(), LlmProviderModule.forRoot({ model: 'claude-sonnet-4-6' }), ClaudeModule, MyFeatureModule, ], }) export class AppModule {} // my-feature.module.ts — this module's LLM calls use claude-opus-4-6 instead @Module({ imports: [LlmProviderModule.forFeature({ model: 'claude-opus-4-6' })], providers: [MyWorkflow], }) export class MyFeatureModule {} ``` Modules that support this pattern include `LlmProviderModule`, `RemoteClientModule`, `SecretsModule`, and others. Per-call config (via `options.config`) always takes priority over module defaults. ## Dependency Injection Workflows and tools use standard NestJS constructor injection: ```typescript @Workflow({ widget: __dirname + '/chat.ui.yaml', }) export class ChatWorkflow extends BaseWorkflow { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly myTool: MyCustomTool, ) { super(); } } ``` Sub-workflows are also injected via constructor: ```typescript export class ParentWorkflow extends BaseWorkflow { constructor(private readonly subWorkflow: SubWorkflow) { super(); } } ``` ## Using in Loopstack Studio Once registered: 1. Open Loopstack Studio at `http://localhost:5173` 2. Your workspace appears in the sidebar 3. Click a workflow to create a new run 4. Fill in the input form and start the workflow ## Registry References - [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) — Example module with ClaudeModule import - [custom-tool-example-module](https://loopstack.ai/registry/loopstack-custom-tool-example-module) — Module with custom tools, services, and workflow providers - [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-run-sub-workflow-example) — Module registering both parent and sub-workflow providers --- > Source: https://loopstack.ai/llms/build/fundamentals/tools.md --- title: Creating Tools description: How to define custom tools with BaseTool, Zod argument schemas, @Tool() decorator, handle() method signature, tool configuration, and dependency injection into workflows. --- # Creating Tools Tools are reusable TypeScript classes that encapsulate a single action — calling an API, querying a database, transforming data, or any other side effect. Define a tool once with a Zod schema for its arguments, then inject it into any workflow or expose it to LLMs for autonomous tool calling. ## Basic Tool ```typescript import { z } from 'zod'; import { BaseTool, Tool, ToolResult } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; @Tool({ name: 'search', description: 'Short description of what this tool does.', schema: z .object({ query: z.string().describe('Search query'), limit: z.number().default(10).describe('Max results'), }) .strict(), }) export class SearchTool extends BaseTool<{ query: string; limit: number }, object, string> { protected async handle(args: { query: string; limit: number }, ctx: LoopstackContext): Promise> { return { data: `Found results for: ${args.query}` }; } } ``` ## The `@Tool` Decorator ```typescript @Tool({ name: 'my_tool', // Snake_case name used as identifier description: 'User-facing description.', // Also seen by LLMs for function calling schema: InputSchema, // Zod schema for input validation configSchema: ConfigSchema, // Optional: Zod schema for tool config }) ``` - **`name`** — Unique identifier for the tool (used in LLM wire format) - **`description`** — Human-readable description (shown to LLMs for tool-use) - **`schema`** — Zod schema that validates arguments before `handle()` is invoked - **`configSchema`** — Optional Zod schema for config (provided via `options.config`) ## The `handle()` Method The abstract method you implement. It receives validated arguments, the execution context, and optional config: ```typescript protected async handle( args: TArgs, ctx: LoopstackContext, options?: ToolCallOptions, ): Promise> { // Your logic here return { data: result }; } ``` The public `call()` method is the entry point — it routes through validation before calling `handle()`. ## ToolResult ```typescript type ToolResult = { type?: 'text' | 'image' | 'file'; data?: TData; error?: string; metadata?: Record; }; ``` Return patterns: ```typescript return { data: 42 }; // Simple value return { data: { name: 'result', items: [...] } }; // Typed data return { error: 'Something went wrong' }; // Error return { type: 'text', data: 'Mostly sunny, 14C.' }; // Typed output return { data: result, metadata: { tokensUsed: 150 } }; // With metadata ``` ## Dependency Injection Use standard NestJS `@Inject()` or constructor injection: ```typescript import { Inject } from '@nestjs/common'; @Tool({ name: 'math_sum', description: 'Calculates the sum of two numbers.', schema: z.object({ a: z.number(), b: z.number() }).strict(), }) export class MathSumTool extends BaseTool<{ a: number; b: number }, object, number> { constructor(private readonly mathService: MathService) { super(); } protected async handle(args: { a: number; b: number }, ctx: LoopstackContext): Promise> { return { data: this.mathService.sum(args.a, args.b) }; } } ``` ## Tools for LLM Function Calling When a tool is exposed to the LLM, the `description` and `schema` tell the LLM what the tool does and what arguments it accepts: ```typescript @Tool({ name: 'get_weather', description: 'Retrieve weather information for a location.', schema: z.object({ location: z.string().describe('City or location name'), }), }) export class GetWeather extends BaseTool<{ location: string }, object, string> { protected async handle(args: { location: string }, ctx: LoopstackContext): Promise> { return Promise.resolve({ type: 'text', data: 'Mostly sunny, 14C.' }); } } ``` In the workflow, list the tool name in the `tools` config: ```typescript constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly getWeather: GetWeather, ) { super(); } const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather'] } }, ); ``` ## Using Tools in Workflows ```typescript constructor(private readonly myTool: SearchTool) { super(); } @Transition({ from: 'ready', to: 'done' }) async process(state: MyState): Promise { const result = await this.myTool.call({ query: 'hello', limit: 5 }); return { ...state, searchResults: result.data }; } ``` ## Module Registration ```typescript @Module({ providers: [SearchTool, MathService], exports: [SearchTool], }) export class MyToolModule {} ``` Then import the module in the workflow's parent module. ## File Structure ``` src/ ├── tools/ │ ├── search.tool.ts │ ├── math-sum.tool.ts │ └── index.ts # Re-exports all tools ├── services/ │ └── math.service.ts ├── my-feature.module.ts └── index.ts ``` ## Registry References - [custom-tool-example-module](https://loopstack.ai/registry/loopstack-custom-tool-example-module) — MathSumTool with injected service, stateful CounterTool, and workflow demonstrating tool usage - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — GetWeather tool exposed to the LLM for function calling --- > **Using an AI coding agent?** See [Skill: Create a Custom Tool](/docs/skills/create-custom-tool) for a dense checklist and syntax reference optimized for code generation. --- > Source: https://loopstack.ai/llms/build/fundamentals/workflows.md --- title: Creating Workflows description: How to define workflow state machines using BaseWorkflow, @Workflow() decorator, @Transition() decorator, state typing, wait transitions, and guards. Includes full chat workflow example. --- # Creating Workflows A workflow is a state machine defined as a TypeScript class. Define transitions between named states, add guards for conditional routing, and use wait transitions to pause for user input or external events. ## Chat Example A simple chat workflow: wait for a user message, call LLM, display the response, and loop back. ```typescript 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): Promise> { return state; } // 2. Wait for user message @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string(), }) async userMessage(state: Record, payload: string): Promise> { 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): Promise> { 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 ```typescript @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): ```typescript @Workflow({ widget: __dirname + '/prompt.ui.yaml', schema: z.object({ subject: z.string().default('coffee'), }), }) ``` ## `BaseWorkflow` All workflows extend `BaseWorkflow`, which provides: | Property / Method | Description | | -------------------- | ----------------------------------------------------------------------------------- | | `this.documentStore` | Save and query documents via `this.documentStore.save(DocClass, content, options?)` | | `this.render` | Render Handlebars templates via `this.render(templatePath, data?)` | Context is passed as a parameter to transition methods via `ctx: LoopstackContext`: | Context Property | Description | | ----------------- | ------------------------- | | `ctx.userId` | User ID | | `ctx.workspaceId` | Workspace ID | | `ctx.workflowId` | Current workflow run ID | | `ctx.args` | Validated input arguments | ## Transition Types ### Initial Transition — Entry Point Runs once when the workflow starts. Uses `@Transition` with no `from` (defaults to `'start'`): ```typescript @Transition({ to: 'ready' }) async setup(state: MyState, ctx: LoopstackContext): Promise { const args = ctx.args as { subject: string }; return state; } ``` ### Standard Transition — State Change Moves between states. Fires automatically unless `wait: true` is set. ```typescript @Transition({ from: 'ready', to: 'processed' }) async doWork(state: MyState): Promise { const result = await this.myTool.call({ query: 'hello' }); return { ...state, data: result.data }; } ``` A method can listen on **multiple source states**: ```typescript @Transition({ from: 'ready', to: 'prompt_executed' }) @Transition({ from: 'tools_done', to: 'prompt_executed' }) async llmTurn(state: MyState): Promise { ... } ``` ### Final Transition — Completion Uses `@Transition` with `to: 'end'`. The return value is the workflow's output (passed to parent workflow callbacks). ```typescript @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: ```typescript interface MyState { counter: number; llmResult?: LlmGenerateTextResult; } export class MyWorkflow extends BaseWorkflow, MyState> { @Transition({ from: 'ready', to: 'processed' }) async process(state: MyState): Promise { 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: ```typescript constructor( private readonly llmGenerateText: LlmGenerateTextTool, ) { super(); } @Transition({ from: 'ready', to: 'done' }) async process(state: MyState): Promise { 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. ```typescript // 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: ```typescript 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. ```typescript @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.object({ message: z.string() }), }) async userMessage(state: MyState, payload: { message: string }): Promise { 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. ```typescript @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: MyState): Promise { ... } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: MyState): Promise { ... } // 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. ```yaml 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: ```yaml - 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: ```yaml actions: - type: button transition: confirm # Must match the method name label: 'Confirm' ``` ## Module Registration ```typescript @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 - [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) — Multi-turn chat workflow (the minimal example on this page) - [prompt-example-workflow](https://loopstack.ai/registry/loopstack-prompt-example-workflow) — Simple single-turn prompt workflow - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — Tool calling loop with guards and conditional routing - [dynamic-routing-example-workflow](https://loopstack.ai/registry/loopstack-dynamic-routing-example-workflow) — Multi-level guard-based routing - [workflow-state-example-workflow](https://loopstack.ai/registry/loopstack-workflow-state-example-workflow) — State management with typed state interface - [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-run-sub-workflow-example) — Sub-workflow execution with callbacks --- > **Using an AI coding agent?** See [Skill: Create a Custom Workflow](/docs/skills/create-custom-workflow) for a dense checklist and syntax reference optimized for code generation. --- > Source: https://loopstack.ai/llms/build/getting-started.md --- title: Getting Started description: Step-by-step setup guide — install prerequisites, scaffold a NestJS app, add LoopstackModule, configure Docker Compose for PostgreSQL and Redis, and run your first workflow. --- # Getting Started Get Loopstack running locally in a few minutes. ## Prerequisites - Node.js 18.0+ - Docker - NestJS CLI (`npm install -g @nestjs/cli`) ## 1. Create Your App Scaffold a standard NestJS project and install the Loopstack module: ```shell nest new my-app cd my-app npm install @loopstack/loopstack-module ``` ## 2. Start Infrastructure Start the Docker environment including PostgreSQL, Redis, and Loopstack Studio: ```shell docker compose -f node_modules/@loopstack/loopstack-module/docker-compose.yml up -d ``` Studio will be available at [http://localhost:5173](http://localhost:5173). If you don't need Studio or want to run it from source: ```shell docker compose -f node_modules/@loopstack/loopstack-module/docker-compose.infra.yml up -d ``` ## 3. Configure Add `LoopstackModule` to the imports in `src/app.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { LoopstackModule } from '@loopstack/loopstack-module'; @Module({ imports: [LoopstackModule.forRoot()], }) export class AppModule {} ``` Add YAML asset bundling to `nest-cli.json` so workflow UI configs are included in the build: ```json { "compilerOptions": { "assets": ["**/*.yaml"] } } ``` ## 4. Run ```shell npm run start:dev ``` Your backend is now running at [http://localhost:3000](http://localhost:3000) and Studio is available at [http://localhost:5173](http://localhost:5173). ## 5. Hello World Create a simple workflow that calls an LLM to greet you by name. First install the Claude and LLM provider modules: ```shell npm install @loopstack/claude-module @loopstack/llm-provider-module ``` Create `src/hello/hello.workflow.ts`: ```typescript import { z } from 'zod'; import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; import { LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; const InputSchema = z.object({ name: z.string().default('World'), }); type InputArgs = z.infer; @Workflow({ title: 'Hello World', description: 'A simple workflow that greets you by name using an LLM.', schema: InputSchema, }) export class HelloWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } @Transition({ to: 'end' }) async greet(_state: unknown, ctx: LoopstackContext) { const args = ctx.args as InputArgs; const result = await this.llmGenerateText.call({ prompt: `Say hello to ${args.name} in a fun way in one sentence.`, }); await this.documentStore.save(LlmMessageDocument, result.data!.message); } } ``` Create `src/hello/hello.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { ClaudeModule } from '@loopstack/claude-module'; import { StudioApp } from '@loopstack/common'; import { LlmProviderModule } from '@loopstack/llm-provider-module'; import { HelloWorkflow } from './hello.workflow'; @StudioApp({ title: 'Hello World App', workflows: [HelloWorkflow], }) @Module({ imports: [ClaudeModule, LlmProviderModule.forFeature({ model: 'claude-sonnet-4-5' })], providers: [HelloWorkflow], }) export class HelloModule {} ``` Register it in `src/app.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { LoopstackModule } from '@loopstack/loopstack-module'; import { HelloModule } from './hello/hello.module'; @Module({ imports: [LoopstackModule.forRoot(), HelloModule], }) export class AppModule {} ``` Set your Anthropic API key in `.env`: ``` ANTHROPIC_API_KEY=sk-ant-... ``` Restart the dev server. Open Studio at [http://localhost:5173](http://localhost:5173) — you'll see the **Hello World App**. Start a new run, enter your name, and the LLM will greet you. ## Next steps - [Core Concepts](/docs/learn/core-concepts) — understand workflows, tools, documents, and providers - [Creating Workflows](/docs/build/fundamentals/workflows) — transitions, guards, state, and wait patterns - [AI Text Generation](/docs/build/ai/text-generation) — add LLM calls to your workflows --- > Source: https://loopstack.ai/llms/build/integrations/oauth.md --- title: OAuth Authentication description: Integrating OAuth 2.0 authentication using @loopstack/oauth-module. Covers setup with Google Workspace provider, token management, and accessing OAuth-protected APIs from workflows. --- # OAuth Authentication Add OAuth 2.0 authentication to your workflows using the provider-agnostic `@loopstack/oauth-module`. Register providers like Google Workspace or GitHub, and access OAuth-protected APIs from any workflow or tool. ## Setup ```typescript import { Module } from '@nestjs/common'; import { GoogleWorkspaceModule } from '@loopstack/google-workspace-module'; @Module({ imports: [GoogleWorkspaceModule], providers: [MyWorkflow], exports: [MyWorkflow], }) export class MyModule {} ``` ## OAuth as Sub-Workflow The simplest approach: launch the built-in `OAuthWorkflow` when authentication is needed. ```typescript import { BaseWorkflow, CallbackSchema, Guard, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; import { LinkDocument, MarkdownDocument } from '@loopstack/common'; import { OAuthWorkflow } from '@loopstack/oauth-module'; interface CalendarState { events?: CalendarEvent[]; requiresAuthentication?: boolean; } @Workflow({ widget: __dirname + '/calendar.ui.yaml' }) export class CalendarWorkflow extends BaseWorkflow<{ calendarId: string }, CalendarState> { constructor( private readonly calendarFetchEvents: CalendarFetchEventsTool, private readonly oAuth: OAuthWorkflow, ) { super(); } @Transition({ to: 'calendar_fetched' }) async fetchEvents(state: CalendarState, ctx: LoopstackContext): Promise { const args = ctx.args as { calendarId: string }; const result = await this.calendarFetchEvents.call({ calendarId: args.calendarId, }); return { ...state, requiresAuthentication: result.data!.error === 'unauthorized', events: result.data!.events, }; } // If unauthorized -> launch OAuth sub-workflow @Transition({ from: 'calendar_fetched', to: 'awaiting_auth', priority: 10 }) @Guard('needsAuth') async authRequired(state: CalendarState): Promise { const result = await this.oAuth.run( { provider: 'google', scopes: ['https://www.googleapis.com/auth/calendar.readonly'] }, { callback: { transition: 'authCompleted' } }, ); await this.documentStore.save( LinkDocument, { label: 'Google authentication required', workflowId: result.workflowId, embed: true, expanded: true, }, { id: `link_${result.workflowId}` }, ); return state; } needsAuth(state: CalendarState): boolean { return !!state.requiresAuthentication; } // After auth -> retry from start @Transition({ from: 'awaiting_auth', to: 'start', wait: true, schema: CallbackSchema }) async authCompleted(state: CalendarState, payload: { workflowId: string }): Promise { await this.documentStore.save( LinkDocument, { status: 'success', label: 'Google authentication completed', workflowId: payload.workflowId, }, { id: `link_${payload.workflowId}` }, ); return state; } // Success -> display results @Transition({ from: 'calendar_fetched', to: 'end' }) async displayResults(state: CalendarState): Promise { await this.documentStore.save(MarkdownDocument, { markdown: this.render(__dirname + '/templates/summary.md', { events: state.events }), }); return {}; } } ``` ## Using Tokens in Custom Tools ```typescript import { z } from 'zod'; import { BaseTool, Tool, ToolResult } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; import { OAuthTokenStore } from '@loopstack/oauth-module'; @Tool({ name: 'calendar_fetch_events', description: 'Fetches Google Calendar events.', schema: z.object({ calendarId: z.string().default('primary') }).strict(), }) export class CalendarFetchEventsTool extends BaseTool { constructor(private readonly tokenStore: OAuthTokenStore) { super(); } protected async handle(args: { calendarId: string }, ctx: LoopstackContext): Promise { const accessToken = await this.tokenStore.getValidAccessToken(ctx.userId, 'google'); if (!accessToken) { return { data: { error: 'unauthorized' } }; } const response = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${args.calendarId}/events`, { headers: { Authorization: `Bearer ${accessToken}` }, }); return { data: await response.json() }; } } ``` ## Creating a Custom OAuth Provider See [Creating OAuth Providers](/docs/extend/oauth-providers) for how to implement `OAuthProviderInterface` and register a custom provider. ## Environment Variables ``` GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... GOOGLE_OAUTH_REDIRECT_URI=... ``` ## Token Lifecycle 1. `OAuthWorkflow` generates auth URL and shows it to the user 2. User completes OAuth in browser 3. Token is exchanged and stored per user per provider 4. `OAuthTokenStore.getValidAccessToken()` auto-refreshes expired tokens 5. Tools return `{ error: 'unauthorized' }` if no token exists 6. Workflow guard detects the error and launches OAuth sub-workflow ## Registry References - [google-oauth-example](https://loopstack.ai/registry/loopstack-google-oauth-example) — Google Calendar fetch with OAuth sub-workflow, custom calendar tool, and Google Workspace agent with tool calling - [github-oauth-example](https://loopstack.ai/registry/loopstack-github-oauth-example) — GitHub OAuth integration with repos overview and GitHub agent with 25+ tools --- > Source: https://loopstack.ai/llms/build/integrations/programmatic-execution.md --- title: Programmatic Workflow Execution description: Starting and managing workflows from code using WorkflowRunner. Covers triggering from API requests, webhooks, cron jobs, batch processing, and internal events. --- # Running Workflows Programmatically Start and manage workflows programmatically from your NestJS application — in response to API requests, webhook events, cron jobs, or internal application logic — without going through the Studio UI. ## Overview Use the `WorkflowRunner` to execute workflows in response to: - External API requests - Webhook events - Scheduled cron jobs - Internal application events - Batch processing tasks ## Basic Example ### Create a Controller ```typescript import { Body, Controller, Post } from '@nestjs/common'; import { WorkflowRunner } from '@loopstack/core'; import { MyWorkflow } from './workflows/my.workflow'; @Controller() export class AppController { constructor(private readonly workflowRunner: WorkflowRunner) {} @Post('run-my-workflow') async runMyWorkflow(@Body() payload: any) { const userId = '...'; // define a user id to run the workflow const result = await this.workflowRunner.run(MyWorkflow, payload, { appName: 'default', userId, }); return { message: 'Workflow run is queued.', workflowId: result.workflowId }; } } ``` ### WorkflowRunner Methods ```typescript // Async (queued via BullMQ) await this.workflowRunner.run( workflow, // Workflow class reference args, // Data passed as workflow args (type-safe) { appName, // App name for workspace resolution userId, // User ID for execution context }, ); // Sync (inline execution, awaits result) await this.workflowRunner.runSync( workflow, // Workflow class reference args, // Data passed as workflow args (type-safe) { appName, // App name for workspace resolution userId, // User ID for execution context stateless, // Optional: skip persistence (default: false) }, ); ``` ## Advanced Examples ### Webhook Handler Trigger a workflow when receiving a webhook: ```typescript import { Body, Controller, Headers, Post } from '@nestjs/common'; import { WorkflowRunner } from '@loopstack/core'; @Controller('webhooks') export class WebhookController { constructor(private readonly workflowRunner: WorkflowRunner) {} @Post('stripe') async handleStripeWebhook(@Body() webhookData: any, @Headers('stripe-signature') signature: string) { await this.workflowRunner.run( ProcessWebhookWorkflow, { source: 'stripe', event: webhookData, receivedAt: new Date().toISOString(), }, { appName: 'main', userId: webhookData.userId, }, ); return { received: true }; } } ``` ### Scheduled Task Execute a workflow on a schedule using NestJS's `@Cron` decorator: ```typescript import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { WorkflowRunner } from '@loopstack/core'; @Injectable() export class TaskScheduler { constructor(private readonly workflowRunner: WorkflowRunner) {} @Cron(CronExpression.EVERY_DAY_AT_9AM) async generateDailyReports() { const users = await this.getUsersWithReportsEnabled(); for (const user of users) { await this.workflowRunner.run( DailyReportWorkflow, { reportDate: new Date().toISOString(), reportType: 'daily', }, { appName: 'reports', userId: user.id, }, ); } } } ``` ### Batch Processing Process multiple items by triggering workflows in parallel: ```typescript import { Injectable } from '@nestjs/common'; import { WorkflowRunner } from '@loopstack/core'; @Injectable() export class OrderProcessingService { constructor(private readonly workflowRunner: WorkflowRunner) {} async processPendingOrders() { const pendingOrders = await this.getPendingOrders(); const promises = pendingOrders.map((order) => this.workflowRunner.run( ProcessOrderWorkflow, { orderId: order.id, orderData: order, }, { appName: 'orders', userId: order.userId, }, ), ); await Promise.all(promises); return { processed: pendingOrders.length }; } } ``` --- > Source: https://loopstack.ai/llms/build/integrations/sandbox.md --- title: Sandbox Execution description: Executing untrusted code in Docker containers using @loopstack/sandbox-tool and @loopstack/sandbox-filesystem. Setup, file I/O inside sandboxes, and cleanup. --- # Sandbox Execution Run untrusted or AI-generated code safely in isolated Docker containers. The sandbox packages provide tools for creating disposable execution environments with filesystem access, letting workflows execute arbitrary code without risking the host system. ## Setup ```typescript import { Module } from '@nestjs/common'; import { SandboxFilesystemModule } from '@loopstack/sandbox-filesystem'; @Module({ imports: [SandboxFilesystemModule], providers: [SandboxWorkflow], exports: [SandboxWorkflow], }) export class SandboxModule {} ``` ## Example Workflow ```typescript import { z } from 'zod'; import { BaseWorkflow, ToolResult, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; import { SandboxCreateDirectory, SandboxDelete, SandboxReadFile, SandboxWriteFile, } from '@loopstack/sandbox-filesystem'; import { SandboxDestroy, SandboxInit } from '@loopstack/sandbox-tool'; interface SandboxState { containerId?: string; } @Workflow({ widget: __dirname + '/sandbox.ui.yaml', schema: z.object({ outputDir: z.string().default(process.cwd() + '/out') }), }) export class SandboxWorkflow extends BaseWorkflow<{ outputDir: string }, SandboxState> { constructor( private readonly sandboxInit: SandboxInit, private readonly sandboxDestroy: SandboxDestroy, private readonly sandboxWriteFile: SandboxWriteFile, private readonly sandboxReadFile: SandboxReadFile, private readonly sandboxCreateDirectory: SandboxCreateDirectory, ) { super(); } @Transition({ to: 'sandbox_ready' }) async initSandbox(state: SandboxState, ctx: LoopstackContext): Promise { const args = ctx.args as { outputDir: string }; const result: ToolResult = await this.sandboxInit.call({ containerId: 'my-sandbox', imageName: 'node:18', containerName: 'sandbox-container', projectOutPath: args.outputDir, rootPath: 'workspace', }); return { ...state, containerId: result.data.containerId }; } @Transition({ from: 'sandbox_ready', to: 'file_written' }) async writeFile(state: SandboxState): Promise { await this.sandboxCreateDirectory.call({ containerId: state.containerId!, path: '/workspace/src', recursive: true, }); await this.sandboxWriteFile.call({ containerId: state.containerId!, path: '/workspace/src/hello.js', content: "console.log('Hello from sandbox!');", encoding: 'utf8', createParentDirs: true, }); return state; } @Transition({ from: 'file_written', to: 'file_read' }) async readFile(state: SandboxState): Promise { await this.sandboxReadFile.call({ containerId: state.containerId!, path: '/workspace/src/hello.js', encoding: 'utf8', }); return state; } @Transition({ from: 'file_read', to: 'end' }) async destroySandbox(state: SandboxState): Promise { await this.sandboxDestroy.call({ containerId: state.containerId!, removeContainer: true, }); return {}; } } ``` ## Available Tools ### Container Lifecycle | Tool | Args | Description | | ---------------- | ----------------------------------------------------------------- | -------------------------- | | `sandboxInit` | `containerId, imageName, containerName, projectOutPath, rootPath` | Create and start container | | `sandboxDestroy` | `containerId, removeContainer` | Stop/remove container | ### Filesystem Operations | Tool | Args | Description | | ------------------------ | ---------------------------------------------------------- | ---------------------- | | `sandboxWriteFile` | `containerId, path, content, encoding?, createParentDirs?` | Write file | | `sandboxReadFile` | `containerId, path, encoding?` | Read file content | | `sandboxListDirectory` | `containerId, path, recursive?` | List directory entries | | `sandboxCreateDirectory` | `containerId, path, recursive?` | Create directory | | `sandboxDelete` | `containerId, path, recursive?, force?` | Delete file/directory | | `sandboxExists` | `containerId, path` | Check if path exists | | `sandboxFileInfo` | `containerId, path` | Get file metadata | ### Command Execution | Tool | Args | Description | | ---------------- | ----------------------------------------------------------------------- | ------------------------ | | `sandboxCommand` | `containerId, executable, args?, workingDirectory?, envVars?, timeout?` | Run command in container | ## Security - Path traversal detection and prevention - Shell argument escaping - Isolated Docker containers with volume mounting - Configurable timeouts on command execution ## Registry References - [sandbox-example-workflow](https://loopstack.ai/registry/loopstack-sandbox-example-workflow) — Full sandbox lifecycle: init, create directory, write/read files, list directory, check existence, get file info, delete, and destroy --- > Source: https://loopstack.ai/llms/build/integrations/secrets.md --- title: Secrets Management description: Requesting, storing, and retrieving secrets (API keys, tokens) at runtime using RequestSecretsTool, RequestSecretsTask, and GetSecretKeysTool from @loopstack/secrets-module. --- # Secrets Management Loopstack provides built-in tools for requesting and retrieving secrets (API keys, tokens, etc.) from users at runtime. ## Overview Secrets are requested from the user via `RequestSecretsTool` and stored securely. They can later be retrieved with `GetSecretKeysTool` to verify availability. ## Available Tools | Tool | Source | Description | | ----------------------- | --------------------------- | -------------------------------------------------------- | | `RequestSecretsTool` | `@loopstack/secrets-module` | Request secrets from the user via a UI prompt | | `RequestSecretsTask` | `@loopstack/secrets-module` | Agent-friendly task that launches a secrets sub-workflow | | `GetSecretKeysTool` | `@loopstack/secrets-module` | List stored secret keys and their availability | | `SecretRequestDocument` | `@loopstack/secrets-module` | Document displaying the secret input form | ## Example Workflow ```typescript import { BaseWorkflow, ToolResult, Transition, Workflow } from '@loopstack/common'; import { MarkdownDocument } from '@loopstack/common'; import { GetSecretKeysTool, RequestSecretsTool, SecretRequestDocument } from '@loopstack/secrets-module'; interface SecretsState { secretKeys?: Array<{ key: string; hasValue: boolean }>; } @Workflow({ widget: __dirname + '/secrets-example.ui.yaml' }) export class SecretsExampleWorkflow extends BaseWorkflow, SecretsState> { constructor( private readonly requestSecrets: RequestSecretsTool, private readonly getSecretKeys: GetSecretKeysTool, ) { super(); } @Transition({ to: 'requesting_secrets' }) async requestSecretsFromUser(state: SecretsState): Promise { await this.requestSecrets.call({ variables: [{ key: 'EXAMPLE_API_KEY' }, { key: 'EXAMPLE_SECRET' }], }); await this.documentStore.save(SecretRequestDocument, { variables: [{ key: 'EXAMPLE_API_KEY' }, { key: 'EXAMPLE_SECRET' }], }); return state; } @Transition({ from: 'requesting_secrets', to: 'verifying', wait: true }) async secretsSubmitted(state: SecretsState): Promise { const result: ToolResult> = await this.getSecretKeys.call({}); return { ...state, secretKeys: result.data }; } @Transition({ from: 'verifying', to: 'end' }) async showResult(state: SecretsState): Promise { await this.documentStore.save(MarkdownDocument, { markdown: this.render(__dirname + '/templates/secretsVerified.md', { secretKeys: state.secretKeys, }), }); return {}; } } ``` ## How It Works 1. **Request** — `RequestSecretsTool` tells the framework which secrets are needed 2. **Display** — `SecretRequestDocument` shows a secure input form in the UI 3. **Wait** — The workflow pauses (`wait: true`) until the user submits the secrets 4. **Verify** — `GetSecretKeysTool` checks which secrets are now stored 5. **Use** — Secrets are available as environment variables in subsequent tool calls ## Template Example ```markdown # Secrets Verification {{#each secretKeys}} - **{{this.key}}**: {{#if this.hasValue}}Stored{{else}}Missing{{/if}} {{/each}} ``` ## Registry References - [secrets-example-workflow](https://loopstack.ai/registry/loopstack-secrets-example-workflow) — Request secrets from user, verify storage, and display results with both direct workflow and agent-based approaches --- > Source: https://loopstack.ai/llms/build/patterns/dynamic-routing.md --- title: Dynamic Routing description: Conditional workflow routing using @Guard decorators and priority-based transition selection when multiple transitions share the same source state. --- # Dynamic Routing Route workflows conditionally using `@Guard` decorators and `priority` to control which transition fires when multiple transitions share the same source state. ## Basic Guard ```typescript @Transition({ from: 'check', to: 'high', priority: 10 }) @Guard('isHigh') async routeHigh(state: MyState): Promise { return state; } @Transition({ from: 'check', to: 'low' }) async routeLow(state: MyState): Promise { return state; } // Fallback — no guard isHigh(state: MyState): boolean { return state.value > 100; } ``` **How it works:** 1. Transitions with higher `priority` are checked first 2. The `@Guard` references a method that returns a boolean 3. First transition whose guard returns `true` fires 4. A transition without `@Guard` acts as the fallback ## Multi-Level Routing Chain routing decisions with cascading forks: ```typescript import { z } from 'zod'; import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; import { MessageDocument } from '@loopstack/common'; interface RoutingState { value?: number; } @Workflow({ schema: z.object({ value: z.number().default(150) }).strict(), }) export class DynamicRoutingExampleWorkflow extends BaseWorkflow<{ value: number }, RoutingState> { @Transition({ to: 'prepared' }) async createMockData(state: RoutingState, ctx: LoopstackContext): Promise { const args = ctx.args as { value: number }; await this.documentStore.save(MessageDocument, { role: 'assistant', content: `Analysing value = ${args.value}`, }); return { ...state, value: args.value }; } // First fork: value > 100? @Transition({ from: 'prepared', to: 'placeA', priority: 10 }) @Guard('isAbove100') async routeToPlaceA(state: RoutingState): Promise { return state; } @Transition({ from: 'prepared', to: 'placeB' }) async routeToPlaceB(state: RoutingState): Promise { return state; } // Fallback: value <= 100 isAbove100(state: RoutingState): boolean { return (state.value ?? 0) > 100; } // Second fork: value > 200? @Transition({ from: 'placeA', to: 'placeC', priority: 10 }) @Guard('isAbove200') async routeToPlaceC(state: RoutingState): Promise { return state; } @Transition({ from: 'placeA', to: 'placeD' }) async routeToPlaceD(state: RoutingState): Promise { return state; } // Fallback: 100 < value <= 200 isAbove200(state: RoutingState): boolean { return (state.value ?? 0) > 200; } // Terminal transitions @Transition({ from: 'placeB', to: 'end' }) async showMessagePlaceB(state: RoutingState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', content: 'Value is less or equal 100' }); return {}; } @Transition({ from: 'placeC', to: 'end' }) async showMessagePlaceC(state: RoutingState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', content: 'Value is greater than 200' }); return {}; } @Transition({ from: 'placeD', to: 'end' }) async showMessagePlaceD(state: RoutingState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', content: 'Value is less or equal 200, but greater than 100', }); return {}; } } ``` ## Routing Flow ``` prepared → [value > 100?] ├─ yes → placeA → [value > 200?] │ ├─ yes → placeC (done) │ └─ no → placeD (done) └─ no → placeB (done) ``` ## Common Patterns ### Tool Call Routing Route based on LLM response (see [AI Tool Calling](/features/ai-tool-calling)): ```typescript @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: MyState): Promise { ... } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: MyState): Promise { ... } // Fallback: no tool calls hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } ``` ### Error-Based Routing Route based on a tool's error response: ```typescript @Transition({ from: 'fetched', to: 'auth_needed', priority: 10 }) @Guard('needsAuth') async startAuth(state: MyState): Promise { ... } @Transition({ from: 'fetched', to: 'end' }) async displayResults(state: MyState): Promise { ... } needsAuth(state: MyState): boolean { return state.fetchResult?.error === 'unauthorized'; } ``` ## Guard Method Rules - Guard methods must return a **boolean** (or truthy/falsy value) - They receive `state` as their first parameter - They should be **synchronous** — no async guards - Use descriptive names: `hasToolCalls`, `isAbove100`, `needsAuth` ## Registry References - [dynamic-routing-example-workflow](https://loopstack.ai/registry/loopstack-dynamic-routing-example-workflow) — Multi-level guard-based routing with cascading forks - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — Guard-based routing for LLM tool call detection --- > Source: https://loopstack.ai/llms/build/patterns/error-handling.md --- title: Error Handling, Retry & Timeout description: Recovering from transition errors with auto-retry and exponential backoff, custom error state transitions, manual retry via Studio UI, and transition timeouts. --- # Error Handling, Retry & Timeout When a transition throws an error, the framework rolls back all changes and gives you three ways to recover: auto-retry with backoff, transition to a custom error state, or manual retry via the UI. ## Retry Modes ### Auto-Retry Automatically re-run a failed transition with exponential backoff: ```typescript @Transition({ from: 'fetching', to: 'done', retry: 3 }) async fetchData() { await this.httpClient.call({ url: 'https://api.example.com/data' }); } ``` If `fetchData` throws, the framework retries up to 3 times with exponential delays (1s, 2s, 4s). The workflow stays at the `fetching` place during retries. ### Custom Error Place Route to a dedicated error state when a transition fails: ```typescript @Transition({ from: 'processing', to: 'done', retry: { place: 'error_processing' } }) async processData() { await this.processor.call({ data: this.rawData }); } @Transition({ from: 'error_processing', to: 'done', wait: true }) async handleProcessingError() { // Recovery logic — user clicks a "Recover" button to trigger this await this.documentStore.save(MessageDocument, { role: 'assistant', content: 'Processing failed. Retrying with fallback strategy.', }); } ``` The workflow transitions to `error_processing` where you define recovery logic via `wait: true` transitions (buttons in the UI). ### Manual Retry (Default) When no `retry` config is specified, the workflow stays at the current place and shows a "Retry" button: ```typescript @Transition({ from: 'sending', to: 'sent' }) async sendEmail() { await this.email.call({ to: 'user@example.com', body: this.content }); } ``` If it fails, the user sees the error message and can retry manually. No auto-retry, no error place — just pause and let the user decide. ### Hybrid: Auto-Retry + Error Place Combine auto-retry with a fallback error state: ```typescript @Transition({ from: 'deploying', to: 'deployed', retry: { attempts: 2, place: 'deploy_failed' }, }) async deploy() { await this.deployer.call({ target: 'production' }); } @Transition({ from: 'deploy_failed', to: 'deployed', wait: true }) async retryDeploy() { // Manual recovery after auto-retries exhausted } ``` Retries twice automatically. If both fail, transitions to `deploy_failed` for manual intervention. ## Retry Configuration ```typescript retry: number // Shorthand: number of auto-retry attempts retry: { attempts?: number, // Auto-retry count (0 = skip auto-retry, -1 = manual only) delay?: number, // Base delay in ms (default: 1000) backoff?: 'fixed' | 'exponential', // Backoff strategy (default: 'exponential') maxDelay?: number, // Backoff cap in ms (default: 30000) place?: string, // Custom error place when retries exhausted } ``` **Backoff calculation (exponential):** `delay * 2^(attempt - 1)`, capped at `maxDelay`. | Attempt | Delay (default config) | | ------- | ---------------------- | | 1 | 1,000ms | | 2 | 2,000ms | | 3 | 4,000ms | | 4 | 8,000ms | | 5 | 16,000ms | | 6+ | 30,000ms (capped) | ## Timeout Every transition has a default timeout of **5 minutes** (300,000ms). If a transition takes longer, it's interrupted with `Error: Transition '...' timed out after ...ms` and flows through the normal retry logic. You can override the default globally with the `DEFAULT_TRANSITION_TIMEOUT` environment variable (in ms), or per-transition: ```typescript @Transition({ from: 'analyzing', to: 'analyzed', timeout: 5000 }) async analyzeData() { await this.analyzer.call({ dataset: this.data }); } ``` To disable the timeout for a specific transition, set `timeout: 0`: ```typescript @Transition({ from: 'processing', to: 'done', timeout: 0 }) async longRunningTask() { // No timeout — runs until completion } ``` You can combine timeout with retry: ```typescript @Transition({ from: 'analyzing', to: 'analyzed', timeout: 5000, retry: 2, }) async analyzeData() { await this.analyzer.call({ dataset: this.data }); } ``` Times out after 5s, retries up to 2 times, then falls to manual retry. ## What Gets Rolled Back When a transition fails, the framework rolls back: - **Documents** created during the transition (restored from snapshot) - **Database changes** within the transition's transaction - **Workflow state** stays at the pre-transition place What is **not** rolled back: - An `ErrorDocument` is saved after rollback as an audit trail - The error message is stored in workflow metadata - Workflow instance variables (`this.someField`) persist across retries — useful for attempt counters ## ErrorDocument Every failed transition creates an `ErrorDocument` with the error message: ```typescript // Automatically created by the framework: { className: 'ErrorDocument', content: { error: 'Connection refused: api.example.com' } } ``` Multiple `ErrorDocument`s accumulate if retries fail repeatedly — giving a full audit trail of each attempt. ## Registry References - [error-retry-example-workflow](https://loopstack.ai/registry/loopstack-error-retry-example-workflow) — Demonstrates all five retry modes: auto-retry, manual retry, custom error place, timeout, and hybrid --- > Source: https://loopstack.ai/llms/build/patterns/human-in-the-loop.md --- title: Human-in-the-Loop description: Pausing workflows for user input, review, or confirmation using wait:true transitions, document UI actions, payload schemas, and approval patterns. --- # Human-in-the-Loop Pause workflows for user input, review, or confirmation using `wait: true` transitions and document UI actions. ## Wait Transition Pattern A transition with `wait: true` pauses the workflow until externally triggered by user interaction: ```typescript @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.object({ message: z.string() }), }) async userMessage(state: Record, payload: { message: string }): Promise> { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: payload.message, }); return state; } ``` ## Document Action Buttons Documents can include buttons that trigger `wait: true` transitions: ```yaml # Document YAML type: document ui: widgets: - widget: form options: properties: text: title: Text widget: textarea actions: - type: button transition: userResponse # Must match the method name label: 'Submit' ``` When the user clicks **Submit**, the workflow's `userResponse` method fires with the document's current content as the payload. ## Chat Input Widget For conversational UIs, use the `prompt-input` widget: ```yaml ui: widgets: - widget: prompt-input enabledWhen: - waiting_for_user options: transition: userMessage ``` ```typescript @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string(), }) async userMessage(state: Record, payload: string): Promise> { await this.documentStore.save(LlmMessageDocument, { role: 'user', content: payload, }); return state; } ``` ## Confirmation Pattern Show AI-generated content for user review before proceeding: ```typescript import { z } from 'zod'; import { toJSONSchema } from 'zod'; import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; import { LlmGenerateObjectTool } from '@loopstack/llm-provider-module'; interface MeetingNotesState { meetingNotes?: z.infer; } @Workflow({ widget: __dirname + '/meeting-notes.ui.yaml', schema: z.object({ inputText: z.string().default('...') }), }) export class MeetingNotesWorkflow extends BaseWorkflow<{ inputText: string }, MeetingNotesState> { constructor(private readonly llmGenerateObject: LlmGenerateObjectTool) { super(); } @Transition({ to: 'waiting_for_response' }) async createForm(state: MeetingNotesState, ctx: LoopstackContext): Promise { const args = ctx.args as { inputText: string }; await this.documentStore.save(MeetingNotesDocument, { text: args.inputText }, { id: 'input' }); return state; } // Wait for user to edit and submit @Transition({ from: 'waiting_for_response', to: 'response_received', wait: true, schema: MeetingNotesDocumentSchema }) async userResponse( state: MeetingNotesState, payload: z.infer, ): Promise { const result = await this.documentStore.save(MeetingNotesDocument, payload, { id: 'input' }); return { ...state, meetingNotes: result.content as z.infer }; } // AI generates structured output @Transition({ from: 'response_received', to: 'notes_optimized' }) async optimizeNotes(state: MeetingNotesState): Promise { const result = await this.llmGenerateObject.call( { outputSchema: toJSONSchema(OptimizedMeetingNotesDocumentSchema) as Record, prompt: `Structure these notes: ${state.meetingNotes?.text}`, }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); const objectResult = result.data as LlmGenerateObjectResult; await this.documentStore.save( OptimizedNotesDocument, objectResult.data as z.infer, { id: 'final', validate: 'skip' }, ); return state; } // Wait for user to confirm @Transition({ from: 'notes_optimized', to: 'end', wait: true, schema: OptimizedMeetingNotesDocumentSchema }) async confirm( state: MeetingNotesState, payload: z.infer, ): Promise { await this.documentStore.save(OptimizedNotesDocument, payload, { id: 'final' }); return {}; } } ``` ## `enabledWhen` — Conditional Widgets Show/hide widgets based on the current workflow place: ```yaml ui: widgets: - widget: form enabledWhen: - review - editing options: properties: summary: title: Summary widget: textarea actions: - type: button transition: confirm label: 'Confirm' ``` The widget only appears when the workflow is at the `review` or `editing` place. ## Registry References - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-meeting-notes-example-workflow) — Full human-in-the-loop workflow with editable form, AI optimization, and user confirmation - [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) — Chat input pattern with prompt-input widget --- > Source: https://loopstack.ai/llms/build/patterns/state-management.md --- title: State Management description: Defining, reading, and updating typed workflow state. Covers state interfaces, BaseWorkflow generics, state persistence across transitions, and state access patterns. --- # State Management Workflow state is managed through a typed state interface and passed as the first parameter to transition methods. State is returned from each transition and automatically persisted across transitions. ## Defining State Define a state interface and pass it as a generic to `BaseWorkflow`: ```typescript interface MyState { counter: number; llmResult?: LlmGenerateTextResult; items: string[]; } export class MyWorkflow extends BaseWorkflow, MyState> { // ... } ``` ## Writing State Return updated state from transition methods: ```typescript @Transition({ from: 'ready', to: 'processed' }) async process(state: MyState): Promise { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); return { ...state, llmResult: result.data, counter: (state.counter ?? 0) + 1, items: [...(state.items ?? []), 'new item'], }; } ``` ## Reading State Access state in any transition or guard method: ```typescript @Transition({ from: 'processed', to: 'end' }) async display(state: MyState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', content: `Processed ${state.counter} items. Result: ${state.llmResult?.content}`, }); return {}; } hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } ``` ## Persistence Across Pauses State survives when a workflow pauses at a `wait: true` transition and resumes later: ```typescript @Transition({ to: 'waiting' }) async setup(state: MyState): Promise { return { ...state, counter: 42 }; // Set before pause } @Transition({ from: 'waiting', to: 'end', wait: true }) async onResume(state: MyState): Promise { // state.counter is still 42 return {}; } ``` ## Accessing Workflow Args Input arguments are available via `ctx.args`: ```typescript @Workflow({ schema: z.object({ value: z.number().default(150) }), }) export class MyWorkflow extends BaseWorkflow<{ value: number }, MyState> { @Transition({ to: 'ready' }) async setup(state: MyState, ctx: LoopstackContext): Promise { const args = ctx.args as { value: number }; console.log(args.value); // 150 return state; } } ``` ## Helper Methods Use regular private methods for reusable logic — no special decorator needed: ```typescript export class MyWorkflow extends BaseWorkflow, MyState> { @Transition({ from: 'data_created', to: 'end' }) async showResults(state: MyState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', content: this.formatMessage(state.message!), }); return {}; } private formatMessage(text: string): string { return text.toUpperCase(); } } ``` ## Registry References - [workflow-state-example-workflow](https://loopstack.ai/registry/loopstack-workflow-state-example-workflow) — Stores state in typed state interface, accesses in transitions, uses helper methods - [accessing-tool-results-example-workflow](https://loopstack.ai/registry/loopstack-accessing-tool-results-example-workflow) — Storing and accessing tool results via workflow state --- > Source: https://loopstack.ai/llms/build/patterns/sub-workflows.md --- title: Sub-Workflows description: Running workflows inside other workflows via .run(), callback transitions, passing arguments to child workflows, and receiving sub-workflow results. --- # 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 ```typescript import { CallbackSchema, QueueResult } from '@loopstack/common'; constructor(private readonly subWorkflow: SubWorkflow) { super(); } ``` ## Running a Sub-Workflow ```typescript @Transition({ to: 'sub_started' }) async start(state: MyState): Promise { const result: QueueResult = await this.subWorkflow.run( { prompt: 'Hello' }, // Args passed to the sub-workflow { callback: { transition: 'onSubComplete' } }, // Method to call when done ); // Track with a link document await this.documentStore.save(LinkDocument, { label: 'Running sub-workflow...', workflowId: result.workflowId, }, { id: `link_${result.workflowId}` }); return state; } ``` ## Receiving the Callback The sub-workflow's final transition return value is passed as `payload.data`: ```typescript 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 { // Update the link document await this.documentStore.save(LinkDocument, { label: 'Sub-Workflow', status: 'success', workflowId: payload.workflowId, }, { id: `link_${payload.workflowId}` }); await this.documentStore.save(MessageDocument, { role: 'assistant', content: `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: ```typescript @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 ```typescript @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): Promise> { const result: QueueResult = await this.subWorkflow.run({}, { callback: { transition: 'subWorkflowCallback' } }); await this.documentStore.save( LinkDocument, { label: 'Executing Sub-Workflow...', workflowId: result.workflowId, }, { id: `link_${result.workflowId}` }, ); return state; } @Transition({ from: 'sub_started', to: 'end', wait: true, schema: CallbackSchema.extend({ data: z.object({ message: z.string() }) }), }) async subWorkflowCallback( state: Record, payload: { workflowId: string; status: string; data: { message: string } }, ): Promise { await this.documentStore.save( LinkDocument, { label: 'Sub-Workflow', status: 'success', workflowId: payload.workflowId, }, { id: `link_${payload.workflowId}` }, ); await this.documentStore.save(MessageDocument, { role: 'assistant', content: `Message from sub-workflow: ${payload.data.message}`, }); return {}; } } ``` ## Registering Sub-Workflows Both workflows must be registered in the module: ```typescript @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. ```typescript @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: LoopstackContext, options?: ToolCallOptions, ): Promise { const result = await this.testRunner.run({ testDirectory: args.testDirectory }, { callback: options?.callback }); await this.documentStore.save( LinkDocument, { status: 'pending', label: 'Running tests...', workflowId: result.workflowId, embed: true }, { id: `test_link_${result.workflowId}` }, ); return { data: { workflowId: result.workflowId }, pending: { workflowId: result.workflowId }, }; } async complete(result: Record): Promise { const data = result as { workflowId?: string; data?: { passed: boolean; output: string } }; await this.documentStore.save( LinkDocument, { status: data.data?.passed ? 'success' : 'failure', label: 'Tests complete', workflowId: data.workflowId! }, { id: `test_link_${data.workflowId}` }, ); return { data: data.data ?? result }; } } ``` Key parts: - **`pending: { workflowId }`** tells the framework this tool is async — the parent workflow waits for a callback - **`callback: options?.callback`** passes the parent's callback config to the sub-workflow - **`complete()`** is called when the sub-workflow finishes — transform results and update UI documents here - **`LinkDocument`** gives visual feedback while the sub-workflow runs ## Nested Agents The sub-workflow can be an `AgentWorkflow` itself, enabling multi-agent architectures. See [Agent Workflows](/docs/build/ai/agent-workflows) for the full pattern. ## Registry References - [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-run-sub-workflow-example) — Parent workflow calling a sub-workflow with callbacks, LinkDocument tracking, and output passing - [@loopstack/code-agent](https://loopstack.ai/registry/loopstack-code-agent) — ExploreTask wrapping AgentWorkflow as a task tool --- > Source: https://loopstack.ai/llms/build/patterns/templates.md --- title: Template Expressions description: Rendering dynamic text content with Handlebars templates via this.render(). Covers template syntax, variable interpolation, helpers, and use in prompts. --- # Template Expressions Use Handlebars templates to render dynamic text content in workflows. Call `this.render()` from any `BaseWorkflow` to interpolate state values, format prompts, and generate dynamic output. ## Setup ```typescript import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; @Workflow({}) export class MyWorkflow extends BaseWorkflow { // this.render() is available from BaseWorkflow — no injection needed } ``` ## Usage ```typescript const rendered = this.render(__dirname + '/templates/prompt.md', { subject: args.subject, items: state.items, }); ``` Template file (`templates/prompt.md`): ```markdown Write a haiku about {{subject}}. {{#each items}} - {{this.name}} {{/each}} ``` ## Passing Data Pass any data as the second argument to `this.render()`: ```typescript // Workflow args (from ctx parameter) const args = ctx.args as { subject: string }; this.render(templatePath, { subject: args.subject }); // Workflow state (from state parameter) this.render(templatePath, { items: state.items, count: state.counter }); // Mixed data this.render(templatePath, { prompt: args.prompt, history: state.conversationHistory, timestamp: new Date().toISOString(), }); ``` ## Handlebars Syntax ### Variables ```markdown Hello {{name}} Nested: {{user.profile.email}} Array element: {{items.[0]}} ``` ### Conditionals ```markdown {{#if isActive}}Welcome back!{{else}}Please log in{{/if}} {{#unless isBlocked}}Access granted{{/unless}} ``` ### Iteration ```markdown {{#each items}} - {{this.name}}: {{this.value}} {{else}} No items found. {{/each}} ``` ### Context Scoping ```markdown {{#with user}}{{name}} ({{email}}){{/with}} ``` ## Multi-line Template Example ```markdown # Events This Week {{#each events}} - **{{this.summary}}**: {{this.start}} – {{this.end}} {{/each}} {{#unless events}} No events found. {{/unless}} ``` ## When to Use Templates | Scenario | Approach | | ----------------------------------- | --------------------------------- | | LLM prompts with variables | `this.render(templatePath, data)` | | Simple string interpolation | Template literals in TypeScript | | Complex multi-line content | Handlebars template file | | Prompts with iteration/conditionals | Handlebars with `#each`, `#if` | ## YAML UI Config YAML widget configuration uses `transition` values that reference method names and `enabledWhen` for conditional visibility. These are not template expressions — they are static configuration: ```yaml ui: widgets: - widget: prompt-input enabledWhen: [waiting_for_user] options: transition: userMessage ``` ## Registry References - [prompt-example-workflow](https://loopstack.ai/registry/loopstack-prompt-example-workflow) — Uses `this.render()` for Handlebars prompt templates - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-meeting-notes-example-workflow) — Uses templates for structured note rendering --- > Source: https://loopstack.ai/llms/build/troubleshooting.md --- title: Troubleshooting description: Solutions to common Loopstack setup and runtime issues — YAML assets missing at runtime, Studio not connecting to the backend, and wait transitions that never fire. --- # Troubleshooting ## YAML widget file not found at runtime **Symptom:** Your workflow starts but throws an error like `ENOENT: no such file or directory` referencing a `.yaml` file, or Studio shows no UI widgets. **Cause:** NestJS's TypeScript compiler strips non-TS files during build. YAML files are not copied to `dist/` unless explicitly configured. **Fix:** Add a YAML assets rule to `nest-cli.json`: ```json { "compilerOptions": { "assets": ["**/*.yaml"] } } ``` Then restart the dev server (`npm run start:dev`) — NestJS watches and copies asset files on change. **Also check:** The path passed to `widget:` or `this.render()` uses `__dirname`, which resolves to the compiled file's location in `dist/`. Make sure you're using: ```typescript @Document({ widget: __dirname + '/my-document.yaml', }) ``` Not a hardcoded path like `'src/my-feature/my-document.yaml'`. --- ## Studio shows blank or can't reach the backend **Symptom:** Opening `http://localhost:5173` shows an empty screen, a connection error, or no workflows/runs appear. **Cause:** Studio is a static web app that connects to your NestJS backend via the `VITE_API_URL` environment variable. If it's not set, it defaults to `http://localhost:3000`. If your backend is on a different port or host, Studio can't find it. **Fix:** Set `VITE_API_URL` in your `.env` file before starting the Docker Compose stack: ```dotenv VITE_API_URL=http://localhost:3000 ``` If you changed the NestJS default port (e.g. via `app.listen(8080)`), update `VITE_API_URL` to match. After changing `.env`, restart the stack: ```shell docker compose -f node_modules/@loopstack/loopstack-module/docker-compose.yml down docker compose -f node_modules/@loopstack/loopstack-module/docker-compose.yml up -d ``` --- ## `wait: true` transition never fires when clicking a button **Symptom:** You click a button in Studio and nothing happens — the workflow stays paused and doesn't advance. **Cause:** The `transition:` value in your YAML widget config must exactly match the **method name** of the `wait: true` transition in your workflow class. If there's any mismatch (typo, different casing), Studio sends the trigger but the engine can't find the transition. **Fix:** Make sure the names match exactly. In your document YAML: ```yaml actions: - type: button transition: confirm # ← must match the method name label: Confirm ``` In your workflow: ```typescript @Transition({ from: 'reviewing', to: 'end', wait: true }) async confirm(state: MyState, payload: unknown): Promise { // ↑ must match the transition: value above } ``` The same applies to `prompt-input` widgets: ```yaml widget: prompt-input options: transition: userMessage # ← must match the method name ``` --- # Extend How to add custom LLM providers and OAuth providers to the Loopstack registry. --- > Source: https://loopstack.ai/llms/extend/llm-providers.md --- title: Creating Custom LLM Providers description: Implementing a new LLM provider by extending LlmProviderInterface and registering with LlmProviderRegistry. Covers the provider architecture, required methods, and module setup. --- # Creating LLM Providers Add a new LLM provider to Loopstack by implementing `LlmProviderInterface` and registering it with the `LlmProviderRegistry`. ## Architecture ``` @loopstack/llm-provider-module ← contracts, registry, adapter tools, helpers ↑ ↑ ↑ claude-module openai-module your-module ``` - **`@loopstack/llm-provider-module`** — shared interfaces, `LlmProviderRegistry`, adapter tools (`LlmGenerateTextTool`, `LlmGenerateObjectTool`, `LlmDelegateToolCallsTool`, `LlmUpdateToolResultTool`), shared helpers, and `LlmMessageDocument` - **Provider modules** (e.g. `@loopstack/claude-module`, `@loopstack/openai-module`) — implement `LlmProviderInterface`, self-register at module init - Adapter tools route to the correct provider at runtime based on the `provider` config value ## Implement `LlmProviderInterface` ```typescript import { Injectable, OnModuleInit } from '@nestjs/common'; import type { LlmContext, LlmGenerateObjectArgs, LlmGenerateObjectResult, LlmGenerateTextArgs, LlmGenerateTextResult, LlmNormalizedMessage, LlmProviderInterface, LlmUsage, } from '@loopstack/llm-provider-module'; import { LlmProviderRegistry } from '@loopstack/llm-provider-module'; @Injectable() export class OllamaLlmProvider implements LlmProviderInterface, OnModuleInit { readonly providerId = 'ollama'; constructor(private readonly registry: LlmProviderRegistry) {} onModuleInit(): void { this.registry.register(this); } async generateText(args: LlmGenerateTextArgs, ctx: LlmContext): Promise { // 1. Resolve messages from ctx.documents (or use args.messages / args.prompt) // 2. Call your LLM API // 3. Normalize the response to LlmNormalizedMessage format // 4. Return { message, response } const nativeResponse = await this.callOllamaApi(args, ctx); return { message: this.normalizeResponse(nativeResponse), response: nativeResponse, // preserve native response for round-trips }; } async generateObject(args: LlmGenerateObjectArgs, ctx: LlmContext): Promise { // Similar to generateText, but force structured output // Use args.outputSchema to constrain the response const nativeResponse = await this.callOllamaStructuredApi(args, ctx); return { data: nativeResponse.parsedOutput, response: nativeResponse, }; } extractUsage(response: unknown): LlmUsage | undefined { // Extract token usage from the native API response const r = response as { usage?: { prompt_tokens: number; completion_tokens: number } }; if (!r.usage) return undefined; return { inputTokens: r.usage.prompt_tokens, outputTokens: r.usage.completion_tokens, }; } toProviderMessage(content: LlmNormalizedMessage): unknown { // Convert normalized content back to provider-specific message format // Used by resolveMessages() for API round-trips return { role: content.role, content: typeof content.content === 'string' ? content.content : content.content.map((block) => this.convertBlock(block)), }; } } ``` ## The Interface ```typescript interface LlmProviderInterface> { /** Unique provider identifier (e.g. 'ollama'). Used in config. */ readonly providerId: string; /** Invoke the LLM and return a normalized response. */ generateText(args: LlmGenerateTextArgs, ctx: LlmContext): Promise; /** Generate a structured object conforming to a JSON Schema. */ generateObject(args: LlmGenerateObjectArgs, ctx: LlmContext): Promise; /** Extract usage stats from the native API response. */ extractUsage(response: unknown): LlmUsage | undefined; /** Convert normalized content to provider-specific message format. */ toProviderMessage(content: LlmNormalizedMessage): unknown; } ``` ### Method responsibilities | Method | Purpose | | ------------------- | ------------------------------------------------------------------------------------- | | `generateText` | Call the LLM API, return normalized `LlmNormalizedMessage` + native response | | `generateObject` | Same but force structured output matching `args.outputSchema` | | `extractUsage` | Parse token usage from native response (for logging/quota) | | `toProviderMessage` | Convert normalized messages back to provider format (for message history round-trips) | ### What you DON'T implement Tool delegation (`delegateToolCalls`, `updateToolResult`) is handled by the shared `LlmDelegateService` and `LlmToolsHelperService` — they work identically for all providers. You only need to implement the LLM call itself. ## `LlmContext` The context passed to provider methods: ```typescript interface LlmContext { /** Runtime documents for the current workflow execution (used for message history). */ documents: DocumentEntity[]; } ``` Use `ctx.documents` with `args.messagesSearchTag` to resolve message history from saved documents. ## `LlmGenerateTextArgs` The args your `generateText` method receives: | Field | Type | Description | | ------------------- | -------------------- | --------------------------------------------------------- | | `system` | `string?` | System prompt | | `messages` | `LlmMessage[]?` | Explicit messages (alternative to document-based history) | | `prompt` | `string?` | Simple prompt string | | `messagesSearchTag` | `string?` | Tag to filter documents as message history | | `tools` | `LlmResolvedTool[]?` | Tool definitions the LLM can call | | `model` | `string?` | Model name | | `providerConfig` | `TProviderConfig?` | Provider-specific config (temperature, maxTokens, etc.) | | `onStream` | `LlmStreamHandler?` | Optional streaming callback | | `streamMessageId` | `string?` | Message ID for correlating stream events | ## Normalized message format All providers must normalize their responses to `LlmNormalizedMessage`: ```typescript interface LlmNormalizedMessage { id?: string; role: 'user' | 'assistant'; content: string | LlmContentBlock[]; stopReason?: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence'; } ``` Content blocks are a union of: - `{ type: 'text', text: string }` — text output - `{ type: 'thinking', text: string }` — reasoning/thinking output - `{ type: 'tool_call', id: string, name: string, args: Record }` — tool call ## Create the module ```typescript import { Module } from '@nestjs/common'; import { OllamaLlmProvider } from './ollama-llm-provider'; import { OllamaClientService } from './services/ollama-client.service'; @Module({ providers: [OllamaClientService, OllamaLlmProvider], exports: [OllamaClientService, OllamaLlmProvider], }) export class OllamaModule {} ``` ## Usage Users import your module — no other changes needed: ```typescript @Module({ imports: [LoopstackModule.forRoot(), OllamaModule], }) export class AppModule {} ``` Then use it via config: ```typescript const result = await this.llmGenerateText.call( { prompt: 'Hello' }, { config: { provider: 'ollama', model: 'llama3' } }, ); ``` ## Streaming support If your provider supports streaming, use the `args.onStream` callback: ```typescript async generateText(args: LlmGenerateTextArgs, ctx: LlmContext): Promise { const stream = this.client.stream(/* ... */); if (args.onStream) { const messageId = args.streamMessageId ?? crypto.randomUUID(); await args.onStream({ type: 'start', messageId }); for await (const chunk of stream) { await args.onStream({ type: 'text_delta', messageId, delta: chunk.text }); } const finalMessage = this.normalizeResponse(stream.finalResponse); await args.onStream({ type: 'done', messageId, message: finalMessage }); } // Always return the complete final response regardless of streaming return { message: finalMessage, response: stream.finalResponse }; } ``` ## Key types reference | Type | Description | | ------------------------- | ------------------------------------------------------------------------------------------------------------ | | `LlmProviderInterface` | Contract for provider implementations | | `LlmProviderRegistry` | Runtime registry — `register()`, `get()`, `has()` | | `LlmGenerateTextArgs` | Input for text generation | | `LlmGenerateTextResult` | Response: `{ message, response }` | | `LlmGenerateObjectArgs` | Input for structured output (includes `outputSchema`) | | `LlmGenerateObjectResult` | Response: `{ data, response }` | | `LlmNormalizedMessage` | Normalized message: `role`, `content`, `stopReason` | | `LlmContentBlock` | Content block union: `text`, `thinking`, `tool_call`, `tool_result`, `server_tool_use`, `server_tool_result` | | `LlmStopReason` | `'end_turn'` \| `'tool_use'` \| `'max_tokens'` \| `'stop_sequence'` | | `LlmToolCall` | Normalized tool call: `id`, `name`, `args` | | `LlmContext` | Execution context with `documents` | | `LlmUsage` | Token usage: `inputTokens`, `outputTokens`, optional cache/reasoning | | `LlmResultMeta` | Metadata from adapter tools: `provider`, `model`, `usage` | | `LlmConfigSchema` | Shared Zod schema for model config passthrough | | `LlmStreamEvent` | Stream event union: `start`, `text_delta`, `thinking_delta`, `tool_call`, `done`, `error` | | `LlmDelegateResult` | Tool execution results: `allCompleted`, `toolResults`, `pendingCount`, `errorCount`, `hasErrors`, `errors` | --- > Source: https://loopstack.ai/llms/extend/oauth-providers.md --- title: Creating Custom OAuth Providers description: Implementing a new OAuth provider by extending OAuthProviderInterface and registering with OAuthProviderRegistry. Covers required methods, token handling, and module setup. --- # Creating OAuth Providers Add a new OAuth provider to Loopstack by implementing `OAuthProviderInterface` and registering it with the `OAuthProviderRegistry`. ## The Interface ```typescript import { Injectable, OnModuleInit } from '@nestjs/common'; import { OAuthProviderInterface, OAuthProviderRegistry, OAuthTokenSet } from '@loopstack/oauth-module'; @Injectable() export class MyOAuthProvider implements OAuthProviderInterface, OnModuleInit { readonly providerId = 'my-provider'; readonly defaultScopes = ['read', 'write']; constructor(private registry: OAuthProviderRegistry) {} onModuleInit() { this.registry.register(this); } buildAuthUrl(scopes: string[], state: string): string { const params = new URLSearchParams({ client_id: process.env.MY_CLIENT_ID!, redirect_uri: process.env.MY_REDIRECT_URI!, scope: scopes.join(' '), state, response_type: 'code', }); return `https://my-provider.com/oauth/authorize?${params}`; } async exchangeCode(code: string): Promise { // POST to token endpoint, return { accessToken, refreshToken, expiresIn, scope } } async refreshToken(refreshToken: string): Promise { // POST to refresh endpoint, return new token set } } ``` ## Method Responsibilities | Method | Purpose | | -------------- | ----------------------------------------------------------- | | `buildAuthUrl` | Construct the OAuth authorization URL for the user to visit | | `exchangeCode` | Exchange the authorization code for tokens after redirect | | `refreshToken` | Refresh an expired access token using the refresh token | ## `OAuthTokenSet` The return type for `exchangeCode` and `refreshToken`: ```typescript interface OAuthTokenSet { accessToken: string; refreshToken?: string; expiresIn: number; // seconds until expiry scope: string; } ``` ## Registration The provider self-registers via `OnModuleInit`. Once registered, it's available to the built-in `OAuthWorkflow` and `OAuthTokenStore`. ## Create the Module ```typescript import { Module } from '@nestjs/common'; import { OAuthModule } from '@loopstack/oauth-module'; import { MyOAuthProvider } from './my-oauth-provider'; @Module({ imports: [OAuthModule], providers: [MyOAuthProvider], exports: [MyOAuthProvider], }) export class MyOAuthModule {} ``` ## Usage Users import your module — no other changes needed: ```typescript @Module({ imports: [LoopstackModule.forRoot(), MyOAuthModule], }) export class AppModule {} ``` Then use it in workflows: ```typescript await this.oAuth.run( { provider: 'my-provider', scopes: ['read', 'write'] }, { callback: { transition: 'authCompleted' } }, ); ``` ## Token Lifecycle 1. `OAuthWorkflow` calls `buildAuthUrl()` and shows the URL to the user 2. User completes OAuth in browser, gets redirected back with a code 3. Framework calls `exchangeCode()` to get tokens 4. Tokens are stored per user per provider via `OAuthTokenStore` 5. `OAuthTokenStore.getValidAccessToken()` auto-calls `refreshToken()` when expired 6. Tools check for valid tokens and return `{ error: 'unauthorized' }` if missing ## Existing Providers | Provider | Module | Provider ID | | -------- | ------------------------------------ | ----------- | | Google | `@loopstack/google-workspace-module` | `'google'` | | GitHub | `@loopstack/github-module` | `'github'` | --- # Reference API and configuration reference — module options, environment variables, YAML schemas for workflows and documents, and import paths. --- > Source: https://loopstack.ai/llms/reference/configuration.md --- title: Configuration Reference description: All LoopstackModule.forRoot() options and environment variables — database, Redis, authentication, CORS, and default settings. --- # Configuration Loopstack is configured via `LoopstackModule.forRoot()` options and environment variables. Environment variables are read from a `.env` file in your project root. All settings have sensible defaults — a fresh project works out of the box with no configuration. ## `LoopstackModule.forRoot()` Options ```typescript LoopstackModule.forRoot({ enableAuth: false, // default: false (no authentication) database: { ... }, // PostgreSQL connection redis: { ... }, // Redis connection auth: { ... }, // JWT and hub auth settings cors: { ... }, // CORS configuration }) ``` ### `enableAuth` Enables authentication. When `false` (the default), a local development user is created automatically and no login is required. | Option | Env var | Default | | ------------ | ---------------- | ------- | | `enableAuth` | `LOOPSTACK_AUTH` | `false` | Set `enableAuth: true` or `LOOPSTACK_AUTH=true` to require authentication via Loopstack Hub. ### `database` PostgreSQL connection settings. All fields are optional — defaults connect to a local PostgreSQL instance. | Option | Env var | Default | | --------------------- | ------------------- | ----------- | | `database.host` | `DATABASE_HOST` | `localhost` | | `database.port` | `DATABASE_PORT` | `5432` | | `database.username` | `DATABASE_USERNAME` | `postgres` | | `database.password` | `DATABASE_PASSWORD` | `admin` | | `database.database` | `DATABASE_NAME` | `postgres` | | `database.connection` | — | — | Set `database.connection` to reuse an existing TypeORM connection by name. When set, Loopstack skips its own `TypeOrmModule.forRoot()` registration. ### `redis` Redis connection settings for BullMQ job queues. | Option | Env var | Default | | ---------------- | ---------------- | ----------- | | `redis.host` | `REDIS_HOST` | `localhost` | | `redis.port` | `REDIS_PORT` | `6379` | | `redis.password` | `REDIS_PASSWORD` | — | ### `auth` JWT and hub authentication settings. Only relevant when `enableAuth` is `true`. | Option | Env var | Default | | --------------------------- | ------------------------ | ------------------------------------------------ | | `auth.jwt.secret` | `JWT_SECRET` | `dev-secret-change-me` | | `auth.jwt.expiresIn` | `JWT_EXPIRES_IN` | `1h` | | `auth.jwt.refreshSecret` | `JWT_REFRESH_SECRET` | value of `JWT_SECRET` | | `auth.jwt.refreshExpiresIn` | `JWT_REFRESH_EXPIRES_IN` | `7d` | | `auth.clientId` | `CLIENT_ID` | `local` | | `auth.hub.issuer` | `HUB_ISSUER` | `https://hub.loopstack.ai` | | `auth.hub.jwksUri` | `HUB_JWKS_URI` | `https://hub.loopstack.ai/.well-known/jwks.json` | ### `cors` CORS configuration. Defaults to `{ origin: true, credentials: true }`. Set to `false` to disable CORS. ## Other Environment Variables These are read directly from the environment and are not part of `LoopstackModule.forRoot()`. ### General | Env var | Default | Description | | ---------------------------- | ------------- | ------------------------------------------------------- | | `NODE_ENV` | `development` | Node.js environment | | `DEFAULT_TRANSITION_TIMEOUT` | `300000` | Workflow transition timeout in milliseconds (5 minutes) | ### LLM Providers (examples) Set these when using the corresponding LLM provider modules. | Env var | Module | Description | | ------------------- | -------------------------- | ----------------- | | `ANTHROPIC_API_KEY` | `@loopstack/claude-module` | Anthropic API key | | `OPENAI_API_KEY` | `@loopstack/openai-module` | OpenAI API key | ### OAuth Providers (examples) Set these when using OAuth modules for third-party integrations. | Env var | Module | Description | | --------------------------- | ------------------------------------ | ------------------------------ | | `GITHUB_CLIENT_ID` | `@loopstack/github-module` | GitHub OAuth app client ID | | `GITHUB_CLIENT_SECRET` | `@loopstack/github-module` | GitHub OAuth app client secret | | `GITHUB_OAUTH_REDIRECT_URI` | `@loopstack/github-module` | GitHub OAuth redirect URI | | `GOOGLE_CLIENT_ID` | `@loopstack/google-workspace-module` | Google OAuth client ID | | `GOOGLE_CLIENT_SECRET` | `@loopstack/google-workspace-module` | Google OAuth client secret | | `GOOGLE_OAUTH_REDIRECT_URI` | `@loopstack/google-workspace-module` | Google OAuth redirect URI | ## Docker Compose The `@loopstack/loopstack-module` package ships with Docker Compose files that start PostgreSQL, Redis, and Studio with settings that match the defaults above — no `.env` file needed for local development. ```shell docker compose -f node_modules/@loopstack/loopstack-module/docker-compose.yml up -d ``` To customize, create a `.env` file in your project root: ```dotenv VITE_API_URL=http://localhost:3000 ``` The `VITE_API_URL` variable tells Studio where your backend is running. It defaults to `http://localhost:3000`. --- > Source: https://loopstack.ai/llms/reference/document-yaml.md --- title: Document YAML Schema description: Complete reference for document .ui.yaml files — type, description, display components, and rendering configuration for Loopstack Studio. --- # Document YAML Schema Document YAML files define how documents are rendered in the Loopstack Studio interface. ## Top-Level Properties ### `type` (optional) ```yaml type: document ``` Identifies this configuration as a document. Default: `document`. ### `description` (optional) ```yaml description: 'Contains structured meeting notes with action items' ``` ### `tags` (optional) Labels for categorizing and filtering documents: ```yaml tags: - meeting-notes - processed ``` ### `ui` Defines how the document renders in the UI. ## UI Widgets ### Form Widget ```yaml ui: widgets: - widget: form options: order: [date, summary, participants, actionItems] properties: date: title: Date summary: title: Summary widget: textarea participants: title: Participants collapsed: true items: title: Participant actionItems: title: Action Items collapsed: true items: title: Action Item actions: - type: button transition: confirm label: 'Confirm' ``` ### Form Field Properties | Property | Type | Description | | ------------- | ---------- | ---------------------------------- | | `widget` | `string` | Widget type (see below) | | `label` | `string` | Field label | | `title` | `string` | Section title | | `description` | `string` | Field description | | `placeholder` | `string` | Placeholder text | | `help` | `string` | Help text below the field | | `rows` | `number` | Visible rows (for `textarea`) | | `inline` | `boolean` | Display field inline | | `readonly` | `boolean` | Make field read-only | | `hidden` | `boolean` | Hide the field | | `disabled` | `boolean` | Disable interaction | | `collapsed` | `boolean` | Collapse arrays/objects by default | | `fixed` | `boolean` | Fixed field | | `order` | `string[]` | Display order of nested fields | | `enumOptions` | `array` | Options for select/radio widgets | | `items` | `object` | UI config for array items | | `properties` | `object` | UI config for nested object fields | ### Widget Types | Widget | Description | | ----------- | ------------------------------------ | | `text` | Single-line text input (default) | | `textarea` | Multi-line text area | | `select` | Dropdown select | | `radio` | Radio button group | | `checkbox` | Checkbox | | `switch` | Toggle switch | | `slider` | Numeric slider | | `code-view` | Code editor with syntax highlighting | ### `enumOptions` For `select` and `radio` widgets: ```yaml language: title: Language widget: select enumOptions: - label: Python value: python - label: JavaScript value: javascript ``` Or as simple strings: ```yaml enumOptions: - python - javascript - java ``` ### Actions Buttons that trigger `wait: true` transitions: ```yaml actions: - type: button transition: confirm # Must match the method name label: 'Confirm' - type: button transition: reject label: 'Reject' ``` ## Meta Properties Meta properties are set via the `options` parameter of `this.documentStore.save()`: ```typescript await this.documentStore.save(MyDocument, content, { id: 'doc-1', meta: { hidden: true, }, }); ``` | Property | Type | Description | | ---------------- | ---------- | --------------------------------------------------- | | `hidden` | `boolean` | Hide from the UI | | `mimeType` | `string` | Content MIME type | | `enableAtPlaces` | `string[]` | Places where the document is editable | | `hideAtPlaces` | `string[]` | Places where the document is hidden | | `invalidate` | `boolean` | Mark document as invalidated | | `level` | `string` | Severity level: `debug`, `info`, `warning`, `error` | | `data` | `any` | Arbitrary metadata | ### Supported MIME Types `text/plain`, `text/html`, `text/markdown`, `text/css`, `text/xml`, `application/json`, `application/javascript`, `application/typescript`, `application/yaml`, `application/xml` ## Complete Example ```yaml type: document description: 'Generated code file' tags: - code - generated ui: widgets: - widget: form options: order: [filename, description, code] properties: filename: title: File Name readonly: true description: title: Description readonly: true widget: textarea code: title: Code widget: code-view actions: - type: button transition: confirm label: 'Accept' ``` --- > Source: https://loopstack.ai/llms/reference/imports.md --- title: Import Directory description: Quick-reference for all @loopstack/* import paths — workflows, tools, documents, LLM providers, OAuth, sandbox, secrets, and agent module exports. --- # Import Directory Quick-reference for all import paths. ## `@loopstack/common` ```typescript // Workflows import { BaseWorkflow, CallbackSchema, Guard, QueueResult, Transition, Workflow } from '@loopstack/common'; import type { LoopstackContext } from '@loopstack/common'; // Tools import { BaseTool, ServerTool, Tool, ToolResult } from '@loopstack/common'; import type { ToolCallOptions } from '@loopstack/common'; // Documents import { Document, DocumentEntity } from '@loopstack/common'; // Built-in Documents import { ErrorDocument, LinkDocument, MarkdownDocument, MessageDocument, PlainDocument } from '@loopstack/common'; // Apps import { StudioApp } from '@loopstack/common'; ``` ## `@loopstack/core` ```typescript import { LoopCoreModule } from '@loopstack/core'; import { WorkflowRunner } from '@loopstack/core'; ``` ## `@loopstack/llm-provider-module` ```typescript import { LlmDelegateResult, LlmDelegateToolCallsTool, LlmGenerateObjectResult, LlmGenerateObjectTool, LlmGenerateTextResult, LlmGenerateTextTool, LlmMessageDocument, LlmProviderRegistry, LlmResultMeta, LlmUpdateToolResultTool, } from '@loopstack/llm-provider-module'; ``` ## `@loopstack/claude-module` ```typescript import { ClaudeModule } from '@loopstack/claude-module'; ``` ## `@loopstack/openai-module` ```typescript import { OpenAiModule } from '@loopstack/openai-module'; ``` ## `@loopstack/secrets-module` ```typescript import { GetSecretKeysTool, RequestSecretsTool, SecretRequestDocument } from '@loopstack/secrets-module'; ``` ## `@loopstack/sandbox-tool` / `@loopstack/sandbox-filesystem` ```typescript import { SandboxCreateDirectory, SandboxDelete, SandboxExists, SandboxFileInfo, SandboxListDirectory, SandboxReadFile, SandboxWriteFile, } from '@loopstack/sandbox-filesystem'; import { SandboxFilesystemModule } from '@loopstack/sandbox-filesystem'; import { SandboxCommand, SandboxDestroy, SandboxInit } from '@loopstack/sandbox-tool'; import { SandboxToolModule } from '@loopstack/sandbox-tool'; ``` ## `@loopstack/oauth-module` ```typescript import { OAuthProviderInterface, OAuthProviderRegistry, OAuthTokenStore } from '@loopstack/oauth-module'; import { OAuthWorkflow } from '@loopstack/oauth-module'; ``` ## `@loopstack/google-workspace-module` ```typescript import { GoogleWorkspaceModule } from '@loopstack/google-workspace-module'; ``` --- > Source: https://loopstack.ai/llms/reference/registry.md --- title: Registry description: Overview of the Loopstack Registry — discovering, installing, and using pre-built @loopstack/* npm packages for tools, feature modules, and example workflows. --- # Registry The Loopstack Registry is a collection of npm packages (`@loopstack/*`) providing pre-built tools, feature modules, and example workflows. ## Discovering Packages Browse the registry at [loopstack.ai/registry](https://loopstack.ai/registry) to find available packages. ## Installing Packages All registry packages are installed via npm: ```bash npm install @loopstack/ ``` Then import the module in your NestJS app: ```typescript import { MyFeatureModule } from '@loopstack/'; @Module({ imports: [MyFeatureModule], }) export class AppModule {} ``` If the package provides workflows or tools, register them in your module: ```typescript import { Module } from '@nestjs/common'; import { MyWorkflow } from '@loopstack/'; @Module({ imports: [MyFeatureModule], providers: [MyWorkflow], exports: [MyWorkflow], }) export class AppModule {} ``` See [Modules & Workspaces](/docs/guides/modules-and-workspaces) for details. ## Package Structure Registry packages ship a consistent layout: | Path | Description | | -------------- | ----------------------------------------------------------------------------- | | `README.md` | Usage documentation | | `SETUP.md` | Setup instructions and required config | | `dist/` | Compiled JavaScript | | `src/` | Full TypeScript source (examples and templates only — features/tools omit it) | | `package.json` | Package metadata | ## Inspect a package **Runtime API** — install into a throwaway project and read docs in `node_modules`: ```bash mkdir -p /tmp/loopstack-inspect && cd /tmp/loopstack-inspect npm init -y && npm install @loopstack/ ``` **Implementation** — use the GitHub link on the registry entry. For tools, look for `@Tool({ schema })`, the `call()` method, and `ToolResult` return values. ## Example packages | Package | Pattern | | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | | [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) | Multi-turn chat | | [prompt-example-workflow](https://loopstack.ai/registry/loopstack-prompt-example-workflow) | Single-turn prompt | | [prompt-structured-output-example-workflow](https://loopstack.ai/registry/loopstack-prompt-structured-output-example-workflow) | AI structured output | | [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) | LLM tool calling | | [custom-tool-example-module](https://loopstack.ai/registry/loopstack-custom-tool-example-module) | Custom tools with services | | [dynamic-routing-example-workflow](https://loopstack.ai/registry/loopstack-dynamic-routing-example-workflow) | Guard-based routing | | [workflow-state-example-workflow](https://loopstack.ai/registry/loopstack-workflow-state-example-workflow) | State management | | [accessing-tool-results-example-workflow](https://loopstack.ai/registry/loopstack-accessing-tool-results-example-workflow) | Tool result access | | [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-meeting-notes-example-workflow) | Human-in-the-loop | | [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-run-sub-workflow-example) | Sub-workflows | | [sandbox-example-workflow](https://loopstack.ai/registry/loopstack-sandbox-example-workflow) | Docker sandbox | | [secrets-example-workflow](https://loopstack.ai/registry/loopstack-secrets-example-workflow) | Secrets management | | [google-oauth-example](https://loopstack.ai/registry/loopstack-google-oauth-example) | Google OAuth | | [github-oauth-example](https://loopstack.ai/registry/loopstack-github-oauth-example) | GitHub OAuth | --- > Source: https://loopstack.ai/llms/reference/workflow-yaml.md --- title: Workflow YAML Schema description: Complete reference for workflow .ui.yaml files — title, description, widget layout, input forms, action buttons, and enabled-state configuration for Loopstack Studio. --- # Workflow YAML Schema ## Top-Level Properties ### `title` - **Type:** `string` - **Description:** Display name shown in the Studio UI. ```yaml title: 'Meeting Notes Optimizer' ``` ### `description` (optional) - **Type:** `string` - **Description:** Detailed explanation of the workflow's purpose. ```yaml description: 'Transforms messy meeting notes into structured format using AI' ``` ### `ui` (optional) - **Type:** UI Schema object - **Description:** Defines widgets rendered in the Studio interface. ## UI Widgets The `ui.widgets` array defines the interactive components shown to the user. ### Form Widget Renders workflow input fields as an editable form with optional action buttons. ```yaml ui: widgets: - widget: form enabledWhen: [waiting] options: order: [name, description] properties: name: title: Name description: title: Description widget: textarea actions: - type: button transition: submit label: 'Submit' ``` #### Form Options | Property | Type | Description | | ------------ | ---------- | -------------------------------------- | | `order` | `string[]` | Display order of fields | | `properties` | `object` | Map of field names to UI configuration | | `actions` | `array` | Action buttons | #### Action Properties | Property | Type | Description | | ------------ | -------- | --------------------------------------------------------- | | `type` | `string` | Action type (e.g., `button`) | | `transition` | `string` | **Method name** of the `wait: true` transition to trigger | | `label` | `string` | Button label text | | `variant` | `string` | Button variant (optional) | | `props` | `object` | Additional properties (optional) | ### Prompt-Input Widget Chat-style text input field. ```yaml ui: widgets: - widget: prompt-input enabledWhen: [waiting_for_user] options: transition: userMessage label: Send Message ``` | Property | Type | Description | | ------------ | -------- | --------------------------------------------------------- | | `transition` | `string` | **Method name** of the `wait: true` transition to trigger | | `label` | `string` | Input label text (optional) | ### `enabledWhen` Controls when a widget is interactive based on the current workflow place: ### `showWhen` Controls when a widget is visible based on the current workflow place. Unlike `enabledWhen` (which controls interactivity), `showWhen` hides the widget entirely when the workflow is not at one of the listed places: ```yaml - widget: form enabledWhen: - waiting - editing ``` The widget is only shown when the workflow is at one of the listed places. ## Complete Example ```yaml title: 'Chat Assistant' description: 'Multi-turn chat with AI' ui: widgets: - widget: form options: properties: subject: title: Subject widget: select enumOptions: - coffee - programming - nature - widget: prompt-input enabledWhen: - waiting_for_user options: transition: userMessage label: Send a message ``` ## Important Notes - The `transition` value must match the **method name** of a `wait: true` transition, not an arbitrary ID - If no `ui` section is defined, the workflow runs without any interactive widgets --- # Skills --- - [Skill: Create a Custom Tool](https://loopstack.ai/llms/skills/create-custom-tool.md): Step-by-step instructions for AI agents to scaffold a new tool — BaseTool class, @Tool decorator, Zod argument schema, handle() method, and module registration. - [Skill: Create a Custom Workflow](https://loopstack.ai/llms/skills/create-custom-workflow.md): Step-by-step instructions for AI agents to scaffold a new workflow — file structure, TypeScript class with @Workflow and @Transition decorators, YAML widget config, and module registration. - [Skill: Use Core Tools](https://loopstack.ai/llms/skills/use-core-tools.md): Reference for AI agents on using built-in tools and documents from @loopstack/core and @loopstack/common — sub-workflow execution, document store, render, HTTP client, and core document types. - [Skill: Use the Loopstack Registry](https://loopstack.ai/llms/skills/use-registry.md): Instructions for AI agents to discover, install, and integrate @loopstack/* registry packages — feature modules, tools, and example workflows. --- # Registry — Features Official Loopstack modules providing LLM integrations, OAuth, human-in-the-loop, Git/GitHub tools, secrets management, and more. --- - [Agent Module](https://loopstack.ai/llms/registry/features/agent-module.md): Generic LLM agent workflow for Loopstack — runs a standard agent loop (LLM, tool calls, loop) configured via run() args, AgentWorkflow, AgentModule, no subclassing required - [Code Agent Module](https://loopstack.ai/llms/registry/features/code-agent-module.md): AI-powered codebase exploration for Loopstack — ExploreAgentWorkflow runs glob/grep/read against a remote workspace, ExploreTask delegates exploration as a single tool call - [Git Module](https://loopstack.ai/llms/registry/features/git-module.md): Git version control tools for Loopstack workflows — GitStatusTool, GitAddTool, GitCommitTool, GitPushTool, GitPullTool, GitDiffTool, GitBranchTool, GitCheckoutTool, GitController REST API - [GitHub Integration Module](https://loopstack.ai/llms/registry/features/github-integration-module.md): End-to-end GitHub integration workflow — ConnectGitHubWorkflow handles OAuth, repo creation/linking, remote configuration, divergence resolution via HITL - [GitHub Module](https://loopstack.ai/llms/registry/features/github-module.md): GitHub OAuth provider and 25 API tools for Loopstack — repositories, issues, pull requests, actions, content/git ops, search, users/orgs, OAuthProviderInterface - [Google Workspace Module](https://loopstack.ai/llms/registry/features/google-workspace-module.md): Google OAuth provider and Workspace API tools for Loopstack — Calendar, Drive, Gmail integration, token refresh, OAuthProviderInterface, configurable scopes - [Human-in-the-Loop Module](https://loopstack.ai/llms/registry/features/hitl-module.md): HITL workflows for Loopstack — AskUserWorkflow (free-text, confirm, multiple-choice), ConfirmUserWorkflow (markdown review + confirm/deny), HitlModule, document types for UI rendering - [MCP Module](https://loopstack.ai/llms/registry/features/mcp-module.md): Remote MCP client tools for Loopstack — list and call tools on remote Model Context Protocol servers over HTTPS, Streamable HTTP or SSE, SSRF allowlist, McpListToolsTool, McpCallToolTool - [OAuth Module](https://loopstack.ai/llms/registry/features/oauth-module.md): Provider-agnostic OAuth 2.0 framework for Loopstack — OAuthWorkflow, OAuthProviderRegistry, token storage, authorization code flow, pluggable provider interface - [Quota Module](https://loopstack.ai/llms/registry/features/quota-module.md): Opt-in quota tracking and enforcement for Loopstack tool calls — QuotaInterceptor, QuotaCalculatorRegistry, Redis-backed, AI token usage and processing time calculators - [Remote Client Module](https://loopstack.ai/llms/registry/features/remote-client-module.md): HTTP client and workflow tools for Loopstack remote servers — RemoteClient service, ReadTool, WriteTool, GlobTool, GrepTool, BashTool, file/shell operations on remote workspaces - [Remote File Explorer Module](https://loopstack.ai/llms/registry/features/remote-file-explorer-module.md): REST endpoints for browsing files in Loopstack remote workspaces — RemoteFileExplorerController, directory tree and file content endpoints, proxy over RemoteClient - [Secrets Module](https://loopstack.ai/llms/registry/features/secrets-module.md): Secrets storage for Loopstack workspaces — SecretEntity, SecretService, SecretController REST API, RequestSecretTool, GetSecretTool, workspace-scoped key/value storage --- # Registry — Examples Example workflows demonstrating Loopstack patterns: chat, tool calling, agents, HITL, structured output, sub-workflows, and integrations. --- - [Accessing Tool Results Example](https://loopstack.ai/llms/registry/examples/accessing-tool-results-example-workflow.md): Example workflow showing how to store and access data across workflow transitions using typed workflow state - [Agent Example](https://loopstack.ai/llms/registry/examples/agent-example-workflow.md): Example launching AgentWorkflow as a sub-workflow with custom tools (weather_lookup, calculator) and rendering progress in Studio - [Chat Example](https://loopstack.ai/llms/registry/examples/chat-example-workflow.md): Example workflow building an interactive chat interface — system prompt setup, wait transitions, LlmGenerateTextTool, message loop, prompt-input widget - [Code Agent Example](https://loopstack.ai/llms/registry/examples/code-agent-example-workflow.md): Example launching ExploreAgentWorkflow as a sub-workflow to explore a remote workspace and surface a synthesized summary - [Custom Tool Example](https://loopstack.ai/llms/registry/examples/custom-tool-example-module.md): Example implementing custom tools in a Loopstack workflow — BaseTool subclass, @Tool decorator, Zod schema, tool registration and injection - [Delegate Error Handling Example](https://loopstack.ai/llms/registry/examples/delegate-error-example-workflow.md): Example showing how delegate tool-call loops handle failure modes — schema validation errors, runtime errors, failing sub-workflows, LLM error recovery - [Dynamic Routing Example](https://loopstack.ai/llms/registry/examples/dynamic-routing-example-workflow.md): Example workflow implementing conditional routing based on runtime values using guards and transition priorities - [Error Retry Example](https://loopstack.ai/llms/registry/examples/error-retry-example-workflow.md): Example demonstrating Loopstack retry and recovery — auto-retry, manual retry, custom error places, timeout handling, retry.place, hybrid retry patterns - [Git Commit Flow Example](https://loopstack.ai/llms/registry/examples/git-commit-flow-example-workflow.md): Example of scripted multi-tool orchestration using git-module — GitStatusTool, GitAddTool, GitCommitTool, GitLogTool in sequence, no LLM - [GitHub OAuth Example](https://loopstack.ai/llms/registry/examples/github-oauth-example.md): Example workflows using GitHub API with OAuth — structured repo overview and interactive Claude chat agent with 25 GitHub tools - [Google OAuth Example](https://loopstack.ai/llms/registry/examples/google-oauth-example.md): Example workflows using Google Workspace APIs with OAuth — Calendar, Gmail, Drive, structured calendar summary and Claude chat agent with 11 Google tools - [HITL Ask User Example](https://loopstack.ai/llms/registry/examples/hitl-ask-user-example-workflow.md): Example prompting the user for free-text input using AskUserWorkflow as a sub-workflow with callback, waiting on human input without blocking - [HITL Confirm Example](https://loopstack.ai/llms/registry/examples/hitl-confirm-example-workflow.md): Example asking the user for yes/no confirmation using ConfirmUserWorkflow as a sub-workflow, branching on confirmed/denied outcome - [LLM Multi-Provider Example](https://loopstack.ai/llms/registry/examples/llm-multi-provider-example-workflow.md): Example using multiple LLM providers (Claude and OpenAI) in the same workflow — side-by-side responses, @InjectTool with different provider configs - [MCP Linear Example](https://loopstack.ai/llms/registry/examples/mcp-linear-example-workflow.md): Example connecting a Loopstack chat agent to Linear's hosted MCP server — mcp_list_tools, mcp_call, @InjectTool config with allowedHosts - [Meeting Notes Example](https://loopstack.ai/llms/registry/examples/meeting-notes-example-workflow.md): Example building a human-in-the-loop AI workflow — interactive documents, review steps, LLM-generated meeting notes with user approval - [Module Config Example](https://loopstack.ai/llms/registry/examples/module-config-example.md): Example demonstrating configurable module patterns — forRoot + forFeature, per-module configuration isolation, shared GreeterTool - [Prompt Example](https://loopstack.ai/llms/registry/examples/prompt-example-workflow.md): Example workflow integrating an LLM using a simple prompt pattern — single-shot text generation, LlmGenerateTextTool, saving response as document - [Structured Output Example](https://loopstack.ai/llms/registry/examples/prompt-structured-output-example-workflow.md): Example workflow generating structured output from an LLM — custom document schema, Zod validation, typed LLM responses - [Remote File Explorer Example](https://loopstack.ai/llms/registry/examples/remote-file-explorer-example-workflow.md): Example browsing a remote workspace — RemoteFileExplorerController endpoints, GlobTool + ReadTool workflow pattern - [Sub-Workflow Example](https://loopstack.ai/llms/registry/examples/run-sub-workflow-example.md): Example executing child workflows from a parent workflow — workflow.run(), hierarchical workflow composition, callback transitions - [Sandbox Example](https://loopstack.ai/llms/registry/examples/sandbox-example-workflow.md): Example workflow using Docker sandbox containers for secure filesystem operations — SandboxTool, SandboxFilesystemTool, isolated code execution - [Secrets Example](https://loopstack.ai/llms/registry/examples/secrets-example-workflow.md): Example demonstrating two secrets flows — deterministic request/verify workflow and agentic LLM workflow using get_secret_keys, request_secrets_task tools - [UI Documents Example](https://loopstack.ai/llms/registry/examples/test-ui-documents-example-workflow.md): Example demonstrating Studio document rendering — MessageDocument, ErrorDocument, MarkdownDocument, PlainDocument from a single workflow - [Tool Calling Example](https://loopstack.ai/llms/registry/examples/tool-call-example-workflow.md): Example workflow enabling LLM tool calling (function calling) with custom tools — LlmGenerateTextTool, LlmDelegateToolCallsTool, tool registration - [Workflow State Example](https://loopstack.ai/llms/registry/examples/workflow-state-example-workflow.md): Example workflow managing state across transitions using instance properties — typed state, state persistence, accessing state in transitions ---