# 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: [LlmProviderModule, ClaudeModule, AgentModule], providers: [GlobTool, GrepTool, ReadTool, MyWorkflow], exports: [MyWorkflow], }) export class MyModule {} ``` `LlmProviderModule` must be imported alongside any LLM provider module (`ClaudeModule`, `OpenAiModule`) — it registers the global provider registry the adapter tools and provider implementations depend on. See [LLM Providers](./llm-providers.md). Launch the agent from any workflow: ```typescript @Transition({ from: 'planning', to: 'implementing' }) async runAgent(state: MyState) { 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' } }); } ``` 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(join(__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 { RunContext, TransitionInput } from '@loopstack/common'; import type { LlmDelegateResult, LlmGenerateTextResult } from '@loopstack/llm-provider-module'; import { LlmDelegateToolCallsTool, LlmGenerateTextTool, LlmMessageDocument, LlmUpdateToolResultTool, } from '@loopstack/llm-provider-module'; interface AgentState { llmResult?: LlmGenerateTextResult; delegateResult?: LlmDelegateResult; } const MyAgentSchema = z.object({ instructions: z.string() }); type MyAgentArgs = z.infer; @Workflow({ widget: './my-agent.ui.yaml', schema: MyAgentSchema, }) export class MyAgentWorkflow extends BaseWorkflow { 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: RunContext) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: ctx.args.instructions, }); } @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: AgentState) { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', system: 'You are a custom agent.', tools: ['my_custom_tool'], }, }, ); this.assignState({ llmResult: result.data }); } @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: AgentState) { const result = await this.llmDelegateToolCalls.call({ message: state.llmResult!.message, callback: { transition: 'toolResultReceived' }, }); this.assignState({ delegateResult: result.data }); } @Transition({ from: 'awaiting_tools', to: 'awaiting_tools', wait: true }) async toolResultReceived(state: AgentState, input: TransitionInput) { const result = await this.llmUpdateToolResult.call({ delegateResult: state.delegateResult!, completedTool: input, }); this.assignState({ delegateResult: result.data }); } @Transition({ from: 'awaiting_tools', to: 'ready' }) @Guard('allToolsComplete') toolsComplete(state: AgentState) {} @Transition({ from: 'prompt_executed', to: 'end' }) @Guard('isDone') respond(_state: AgentState) {} private hasToolCalls(state: AgentState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } private allToolsComplete(state: AgentState): boolean { return state.delegateResult?.allCompleted ?? false; } private isDone(state: AgentState): boolean { return state.llmResult?.message.stopReason === 'end_turn'; } } ``` `LlmGenerateTextTool`, `LlmDelegateToolCallsTool`, and `LlmUpdateToolResultTool` all persist their messages automatically — the assistant turn after `llmGenerateText`, and the `tool_result` user turn when all delegated tools complete (sync or async). Pass `config: { save: false }` on any of them if you want to take over persistence. ### 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('isDone') respondToUser(state: AgentState) {} @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string() }) async userMessage(state: AgentState, input: TransitionInput) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: input.data, }); } ``` > **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: RunContext, 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-agent-examples#custom-agent) — 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, type TransitionInput, Workflow } from '@loopstack/common'; import { LlmContextDocument, LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; @Workflow({ widget: './chat.ui.yaml' }) export class ChatWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } @Transition({ to: 'waiting_for_user' }) async setup(state: Record) { await this.documentStore.save(LlmContextDocument, { role: 'user', text: this.render(join(__dirname, 'templates', 'systemMessage.md')), }); } @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string() }) async userMessage(state: Record, input: TransitionInput) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: input.data }); } @Transition({ from: 'ready', to: 'waiting_for_user' }) async llmTurn(state: Record) { await this.llmGenerateText.call({}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }); } } ``` ## 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 ## Message Resolution When `LlmGenerateTextTool.call()` runs, it resolves the conversation in this priority order: 1. **`args.prompt`** — if provided, used as a single user message. Documents are ignored. 2. **`args.messages`** — if provided (and no `prompt`), used as the full message array. Documents are ignored. 3. **Documents matching `config.messagesSearchTag`** — fallback when neither arg is set. Defaults to `'message'`. When falling back to documents, the tool: - Filters `DocumentEntity` records by `doc.tags.includes(messagesSearchTag)` - Excludes any document with `doc.isInvalidated === true` - Sorts by `doc.index` to preserve chronological order This means you can run parallel conversation threads in the same workflow by saving messages under different tags and switching `messagesSearchTag` per call: ```typescript // Save a summary-thread message await this.documentStore.save( LlmMessageDocument, { role: 'user', text: 'Summarize the discussion so far.' }, { tags: ['summary-chat'] }, ); // Call the LLM with only the summary thread as history await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', messagesSearchTag: 'summary-chat' } }, ); ``` If a message isn't appearing in the LLM context, check that it has the expected tag and is not invalidated. ## 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](./tool-calling.md): ```typescript @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: ChatState) { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather', 'search_database'] } }, ); this.assignState({ llmResult: result.data }); } @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: ChatState) { ... } @Transition({ from: 'prompt_executed', to: 'waiting_for_user' }) respond(state: ChatState) {} ``` ## Registry References - [chat-example-workflow](https://loopstack.ai/registry/loopstack-hitl-examples#prompt-input-chat) — Multi-turn chat with Claude, system message, and prompt-input widget - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-agent-examples#custom-agent) — 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, ClaudeModule], }) export class AppModule {} ``` > **`LlmProviderModule` is required, and must be imported before any provider module.** > > `LlmProviderModule` registers `LlmProviderRegistry` — the runtime registry that provider modules (`ClaudeModule`, `OpenAiModule`) inject to self-register their backends. Importing a provider module without `LlmProviderModule` fails at boot with `UnknownDependenciesException` on `LlmProviderRegistry` (and on `ClaudeLlmProvider` / `OpenAiLlmProvider`, which depend on it). > > Any of these forms registers the registry: bare `LlmProviderModule`, `LlmProviderModule.forRoot(config)`, or `LlmProviderModule.forFeature(config)` (the feature form imports the global root transitively). Pick `forRoot` when setting app-wide defaults, `forFeature` for per-module overrides, and the bare import when no defaults are needed. ## 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). For `LlmGenerateTextTool`, the input args are `prompt` and `messages`; `outputSchema` applies only to `LlmGenerateObjectTool`. See [Text Generation](./text-generation.md) for full call examples. | Parameter | Location | Description | | ------------------- | -------- | ------------------------------------------ | | `prompt` | args | Simple prompt string | | `messages` | args | Explicit message array | | `outputSchema` | args | JSON Schema (`LlmGenerateObjectTool` 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' } }, ); ``` ## Provider-Specific Configuration `config.providerConfig` is an opaque pass-through to the active provider — its shape depends on which provider handles the call. Use it for tuning behavior beyond the cross-provider config fields (system prompt, tools, etc.). Provider-specific config is per-call only. The cross-provider fields `provider` and `model` can also be set at module level via `LlmProviderModule.forRoot()` / `forFeature()` (see [Module-Level Defaults](#module-level-defaults)) — per-call config always takes priority. ```typescript await this.llmGenerateText.call( { prompt: 'Write a haiku about coffee' }, { config: { provider: 'claude', model: 'claude-sonnet-4-6', providerConfig: { maxTokens: 1024, temperature: 0.7, cache: true, }, }, }, ); ``` ### `ClaudeProviderConfig` | Field | Type | Description | | --------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `maxTokens` | `number` | Maximum tokens to generate | | `temperature` | `number` | Sampling temperature (0–1) | | `stopSequences` | `string[]` | Stop generation when any of these strings is produced | | `cache` | `boolean` | Enable Anthropic prompt caching. Places cache breakpoints on the system prompt, tool definitions, and the last message automatically — useful for multi-turn workflows where the prefix is reused. | | `envApiKey` | `string` | Env var name holding the API key (defaults to `ANTHROPIC_API_KEY`) | ### `OpenAiProviderConfig` | Field | Type | Description | | ------------------ | ---------- | --------------------------------------------------------------- | | `maxTokens` | `number` | Maximum tokens to generate | | `temperature` | `number` | Sampling temperature (0–2) | | `stopSequences` | `string[]` | Stop generation when any of these strings is produced | | `frequencyPenalty` | `number` | -2.0 to 2.0; reduces token repetition | | `presencePenalty` | `number` | -2.0 to 2.0; encourages topic diversity | | `envApiKey` | `string` | Env var name holding the API key (defaults to `OPENAI_API_KEY`) | ## 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. See [Chat Flows — Message Resolution](./chat-flows.md#message-resolution) for how documents are collected from the document store and become the LLM's conversation history. | Document | Content Format | Widget | | -------------------- | --------------------------------------------------- | ------------- | | `LlmMessageDocument` | Normalized (`text`, `thinking`, `tool_call` blocks) | `llm-message` | ### Response shape `LlmGenerateTextResult.data.message` is an `LlmNormalizedMessage` — two views of the same response: | Field | Type | Description | | ------------ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `role` | `'user' \| 'assistant'` | Message role | | `text` | `string` | Plain-text projection — concatenated `text`-type blocks. Always populated by providers. Use this when you just want a string. | | `blocks` | `LlmContentBlock[]` (optional) | Structured content blocks. Use this to inspect tool calls, thinking output, or render block-by-block. | | `stopReason` | `'end_turn' \| 'tool_use' \| 'max_tokens' \| 'stop_sequence'` | Why generation stopped | | `id` | `string` (optional) | Provider-assigned message ID | `text` is derived from `blocks` (text-type blocks joined with `\n`; `thinking` and tool blocks excluded). Both fields are populated by every provider, so you can pick whichever fits the call site without checking for `undefined`. Content blocks are one 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 - `{ type: 'tool_result', toolCallId: string, content: string, isError: boolean }` — tool result (user-side, fed back to the LLM next turn) ### Writing messages — `text` vs `blocks` Assistant messages (and the user-side `tool_result` turn) are saved automatically by `LlmGenerateTextTool` / `LlmDelegateToolCallsTool`. When you save an `LlmMessageDocument` manually — for user input, seed messages, or system primers — the same two fields are available, both optional. Provide whichever fits: ```typescript // Plain text message — most common case await this.documentStore.save(LlmMessageDocument, { role: 'user', text: 'Hello!' }); // Structured message — multi-block content await this.documentStore.save(LlmMessageDocument, { role: 'user', blocks: [{ type: 'text', text: 'See attached.' }], }); ``` You don't need to fill both. The renderer and downstream providers fall back gracefully: if only `text` is set, it's rendered as a single text bubble; if only `blocks` is set, the text projection is derived from text-type blocks on demand. See [Creating LLM Providers](../../extend/llm-providers.md) for the full interface. ## 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'` | To create a custom provider, see [Creating LLM Providers](../../extend/llm-providers.md). --- > 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: './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 { RunContext } 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; } const StructuredOutputSchema = z.object({ language: z.enum(['python', 'javascript', 'java', 'cpp', 'ruby', 'go', 'php']).default('python'), }); type StructuredOutputArgs = z.infer; @Workflow({ schema: StructuredOutputSchema, }) export class PromptStructuredOutputWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateObject: LlmGenerateObjectTool) { super(); } @Transition({ to: 'ready' }) async greeting(state: StructuredOutputState, ctx: RunContext) { await this.documentStore.save( LlmMessageDocument, { role: 'assistant', text: `Creating a Hello World script in ${ctx.args.language}...` }, { key: 'status' }, ); this.assignState({ language: ctx.args.language }); } @Transition({ from: 'ready', to: 'prompt_executed' }) async prompt(state: StructuredOutputState) { const result = await this.llmGenerateObject.call( { outputSchema: toJSONSchema(FileDocumentSchema) as Record, prompt: this.render(join(__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', }); this.assignState({ llmResult }); } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: StructuredOutputState) { await this.documentStore.save( LlmMessageDocument, { role: 'assistant', text: `Generated: ${state.llmResult?.content?.description ?? ''}` }, { key: 'status' }, ); } } ``` ## 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-llm-examples#structured-output) — 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'; import { LlmProviderModule } from '@loopstack/llm-provider-module'; @Module({ imports: [LlmProviderModule, ClaudeModule], providers: [PromptWorkflow], exports: [PromptWorkflow], }) export class PromptModule {} ``` `LlmProviderModule` registers the global provider registry that adapter tools (`LlmGenerateTextTool`, `LlmGenerateObjectTool`) and provider implementations (`ClaudeLlmProvider`) depend on. Without it, NestJS throws `UnknownDependenciesException` for `LlmProviderRegistry` at boot. See [LLM Providers](./llm-providers.md) for details and module-level defaults. ## Example Workflow ```typescript import { z } from 'zod'; import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; import { LlmGenerateTextTool } from '@loopstack/llm-provider-module'; const PromptSchema = z.object({ subject: z.string().default('coffee'), }); type PromptArgs = z.infer; @Workflow({ schema: PromptSchema, }) export class PromptWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } @Transition({ to: 'end' }) async prompt(state: Record, ctx: RunContext) { await this.llmGenerateText.call( { prompt: this.render(join(__dirname, 'templates', 'prompt.md'), { subject: ctx.args.subject }), }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); } } ``` ## Reading the Response A normalized result has two views of the assistant's reply: - `result.data.message.text` — the plain-text projection (always populated). This is what you want in 95% of cases. - `result.data.message.blocks` — the structured content blocks (`text`, `thinking`, `tool_call`, etc.). Use these when you need to inspect tool calls, thinking output, or render block-by-block. ```typescript const result = await this.llmGenerateText.call({ prompt: 'Write a haiku about coffee' }); const text = result.data.message.text; ``` Both views are derived from the same underlying response — `text` is the concatenation of all `text`-type blocks, with `thinking` and tool blocks filtered out. ## Call Options ```typescript await this.llmGenerateText.call( { // Option 1: Simple prompt prompt: 'Write a haiku about coffee', // Option 2: Explicit messages — `text` for plain content, `blocks` for structured messages: [{ role: 'user', text: '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', }, }, ); ``` ## Persisting the Response The tool saves the assistant message as an `LlmMessageDocument` automatically — no manual `documentStore.save()` is required. Two config knobs control this: - `config: { save: false }` — opt out entirely. Use this when you want to inspect, transform, or persist the response yourself (see [llm-multi-provider-example-workflow](https://loopstack.ai/registry/loopstack-llm-examples#multi-provider) for a worked case). - `config: { meta: {...} }` — merge extra metadata into the auto-saved document. Free-form payload that downstream readers can pick up off `document.meta`. To run a "silent" turn the LLM remembers but the user doesn't see, opt out of auto-save and persist the result as `LlmContextDocument` (declared `internal: true`, so Studio never sees it): ```typescript const result = await this.llmGenerateText.call( { prompt: 'Summarize the previous turn in one sentence for internal reasoning.' }, { config: { provider: 'claude', save: false } }, ); await this.documentStore.save(LlmContextDocument, { role: 'assistant', text: result.data.message.text ?? '', }); ``` Other documents (user inputs, seed system messages, structured outputs) stay manual — see [Chat Flows](./chat-flows.md) for the two halves side by side. ## Using Templates Render Handlebars templates for complex prompts (`this.render()` is available from `BaseWorkflow`): ```typescript const rendered = this.render(join(__dirname, 'templates', 'prompt.md'), { subject: ctx.args.subject, }); const result = await this.llmGenerateText.call( { prompt: rendered }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); ``` ## Streaming LLM responses stream to Studio automatically — no opt-in, no code changes. Whenever a workflow runs in a context that has a connected Studio client (`ctx.workflowId`, `ctx.userId`, and an active `ClientMessageService`), `LlmGenerateTextTool` assigns a `streamMessageId`, passes an `onStream` callback to the provider, and forwards each event to Studio as it arrives. The workflow itself still receives the complete `LlmGenerateTextResult` after the stream finishes — streaming is a side-effect for the UI, not a change to the return value. ### Lifecycle in Studio 1. Tool dispatches `llm.response.start` with a fresh `streamMessageId`. 2. Provider emits `text_delta`, `thinking_delta`, and `tool_call` events as content arrives. Studio renders an in-flight message keyed by `streamMessageId`. 3. Tool dispatches `llm.response.done` with the final normalized message; the result is returned to the workflow. 4. The tool persists an `LlmMessageDocument` for the assistant turn automatically. The saved document inherits `streamMessageId` as its `id`, so Studio **replaces** the in-flight streamed message with the final document — same ID, same slot. No duplicate bubble. Pass `config: { save: false }` if you want to handle persistence yourself. This is why you don't need to do anything special to "finalize" the stream: the document save naturally takes over from the stream because they share the same `id`. ### Custom providers If you're implementing a custom `LlmProviderInterface`, honor the `onStream` callback in `LlmGenerateTextArgs` — call it with `LlmStreamEvent`s (`start`, `text_delta`, `thinking_delta`, `tool_call`, `done`, `error`) as content arrives. Providers that don't stream can ignore it; the framework still returns the final result correctly. See [Creating LLM Providers](../../extend/llm-providers.md) for the full event union. ## Environment Variables | Variable | Description | | ------------------- | ----------------- | | `ANTHROPIC_API_KEY` | Anthropic API key | ## Registry References - [prompt-example-workflow](https://loopstack.ai/registry/loopstack-llm-examples#prompt) — 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, ToolEnvelope } from '@loopstack/common'; import type { RunContext } 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(): Promise> { return Promise.resolve({ type: 'text', data: 'Mostly sunny, 14C, rain in the afternoon.' }); } } ``` ## Tool Calling Workflow `LlmDelegateToolCallsTool` always dispatches tools with a callback so sub-workflow / HITL / async tools work safely. A `wait: true` self-loop on `awaiting_tools` catches each completion via `LlmUpdateToolResultTool`. Synchronous tools complete immediately and the loop falls through to `toolsComplete`. ```typescript import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common'; import type { LlmDelegateResult, LlmGenerateTextResult } from '@loopstack/llm-provider-module'; import { LlmDelegateToolCallsTool, LlmGenerateTextTool, LlmMessageDocument, LlmUpdateToolResultTool, } from '@loopstack/llm-provider-module'; import { GetWeather } from './tools/get-weather.tool'; interface ToolCallState { llmResult?: LlmGenerateTextResult; delegateResult?: LlmDelegateResult; } @Workflow({}) export class ToolCallWorkflow extends BaseWorkflow { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, private readonly llmUpdateToolResult: LlmUpdateToolResultTool, private readonly getWeather: GetWeather, ) { super(); } @Transition({ to: 'ready' }) async setup(state: ToolCallState) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: 'How is the weather in Berlin?', }); } @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: ToolCallState) { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather'] } }, ); this.assignState({ llmResult: result.data }); } @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: ToolCallState) { const result = await this.llmDelegateToolCalls.call({ message: state.llmResult!.message, callback: { transition: 'toolResultReceived' }, }); this.assignState({ delegateResult: result.data }); } hasToolCalls(state: ToolCallState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } @Transition({ from: 'awaiting_tools', to: 'awaiting_tools', wait: true }) async toolResultReceived(state: ToolCallState, payload: unknown) { const result = await this.llmUpdateToolResult.call({ delegateResult: state.delegateResult!, completedTool: payload, }); this.assignState({ delegateResult: result.data }); } @Transition({ from: 'awaiting_tools', to: 'ready' }) @Guard('allToolsComplete') toolsComplete(state: ToolCallState) {} allToolsComplete(state: ToolCallState): boolean { return state.delegateResult?.allCompleted ?? false; } @Transition({ from: 'prompt_executed', to: 'end' }) respond(_state: ToolCallState) {} } ``` Both `LlmGenerateTextTool` and `LlmDelegateToolCallsTool` persist their messages automatically — the assistant turn and the `tool_result` user turn appear in the document store and conversation history without any explicit `documentStore.save()` call. Pass `config: { save: false }` on either tool if you need to handle persistence yourself. ## How the Loop Works ``` setup → llmTurn → [hasToolCalls?] ├─ yes → executeToolCalls → awaiting_tools │ ├─ toolResultReceived (wait, self-loop on async completions) │ └─ toolsComplete (@Guard allToolsComplete) → 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` dispatches each tool with a callback. Synchronous tools complete immediately; async tools (sub-workflows, HITL) return `pending: true` and fire `toolResultReceived` once each 4. When `delegateResult.allCompleted` is true, the unguarded fall-through (`toolsComplete`) loops back to the LLM 5. When the LLM is done (no more tool calls), the fallback transition to `end` fires ## Key Concepts - **`tools` array in config** — Lists tool names the LLM can call. Names must match `@Tool({ name })` values. At startup, Loopstack auto-discovers every `@Tool()`-decorated provider in the module graph and indexes them by name. If a name doesn't match, you'll get an error listing all registered tools — useful for catching typos and missing module imports. - **`llmDelegateToolCalls`** — Dispatches tool calls from the LLM response message with a callback transition. Required for safety: sub-workflow / HITL / any tool that returns `pending: true` only completes once its callback fires - **`llmUpdateToolResult`** — Merges each async tool completion into the running `delegateResult`. The `wait: true` self-loop on `awaiting_tools` calls it once per completion - **`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 ## Under the Hood: How `llmDelegateToolCalls` Works `LlmDelegateToolCallsTool` is what makes a tool-calling workflow actually do work. Without it, the LLM's `tool_call` blocks would just sit in the message — nothing would be executed. The tool reads the most recent assistant message, runs every tool the LLM requested, and stores the outputs back as `tool_result` entries that the next LLM turn can read. Internally it delegates to `LlmDelegateService`, which: 1. **Resolves each tool by name** from the global tool registry (the same one built by `@Tool()` auto-discovery). 2. **Dispatches all tool calls in parallel** with `Promise.all` — the LLM is free to request multiple tools in one turn, and they run concurrently. 3. **Catches errors per tool** so one failing tool doesn't crash the others. Failures show up as `tool_result` entries with `isError: true` and are also collected on `result.errors` for inspection. 4. **Tracks pending async tools** — tools that return `{ pending: true }` (typically [HITL](./agent-workflows.md#human-in-the-loop) or sub-workflow tools) don't produce a result immediately. The result includes a `pendingCount`, and `allCompleted` stays `false` until those tools fire their completion callbacks. `LlmUpdateToolResultTool` is the companion that processes those callbacks and updates the delegate result. The `callback` arg is required — it's how async tool completions find their way back to the workflow. For all-synchronous tool sets, `allCompleted` is already true when `executeToolCalls` returns and the `wait` transition is never entered; the callback is harmless overhead. For mixed or async-only sets, the `wait` transition is what keeps the workflow from silently hanging on pending tools. ## Registry References - [agent-example-workflow](https://loopstack.ai/registry/loopstack-agent-examples#agent) — Parent workflow that delegates to `AgentWorkflow` for tool-calling with weather and calculator tools - [delegate-error-example-workflow](https://loopstack.ai/registry/loopstack-agent-examples#custom-agent) — Reference for hand-rolling the tool loop when you need custom error policy or per-turn logic --- > Source: https://loopstack.ai/llms/build/ai/usage-tracking.md --- title: AI Token Usage Tracking description: Reading LlmUsage from LLM tool results. Every LLM tool (LlmGenerateTextTool, LlmGenerateObjectTool, document generation) returns LlmResultMeta with provider, model, and a usage breakdown — inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens, reasoningTokens. --- # AI Token Usage Tracking Every LLM tool call returns token usage on `result.metadata`. The shape is the same regardless of which tool produced the result — `LlmGenerateTextTool`, `LlmGenerateObjectTool`, and any other LLM-backed tool all return `LlmResultMeta` with a `usage?: LlmUsage`. Read it to log costs, enforce budgets, or report consumption back to users. ## Result metadata shape ```typescript type LlmResultMeta = { provider: string; // e.g. 'claude', 'openai' model: string; // e.g. 'claude-sonnet-4-6' usage?: LlmUsage; }; interface LlmUsage { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number; reasoningTokens?: number; } ``` | Field | Description | | -------------------------- | ----------------------------------------------------------------- | | `inputTokens` | Prompt tokens sent to the model | | `outputTokens` | Completion tokens produced | | `cacheCreationInputTokens` | Tokens written to the prompt cache (provider-dependent) | | `cacheReadInputTokens` | Tokens served from the prompt cache | | `reasoningTokens` | Internal reasoning tokens (e.g. Claude thinking, OpenAI o-series) | `usage` is optional — providers that don't report token counts will omit it. ## Reading usage from a tool call ```typescript const result = await this.llmGenerateText.call( { prompt: 'Write a haiku about coffee' }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); const { provider, model, usage } = result.metadata; if (usage) { console.log( `${provider}/${model}:`, `in=${usage.inputTokens}`, `out=${usage.outputTokens}`, `cacheRead=${usage.cacheReadInputTokens ?? 0}`, ); } ``` The same access pattern works for `LlmGenerateObjectTool` and any other LLM-backed tool that returns `LlmResultMeta`. ## Persisting usage in workflow state If you need usage downstream (for billing, reporting, or aggregation across multiple calls), store `result.metadata` in the workflow state alongside the result: ```typescript interface PromptState { llmResult?: LlmGenerateTextResult; llmMeta?: LlmResultMeta; } @Transition({ to: 'prompt_executed' }) async prompt(state: PromptState, ctx: RunContext) { const result = await this.llmGenerateText.call( { prompt: 'Write a haiku' }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); this.assignState({ llmResult: result.data, llmMeta: result.metadata, }); } ``` ## See also - [Text Generation](./text-generation.md) — `LlmGenerateTextTool` call signature - [Structured Output](./structured-output.md) — `LlmGenerateObjectTool` call signature --- > Source: https://loopstack.ai/llms/build/best-practices.md --- title: Best Practices description: Design judgment for building with Loopstack's core building blocks — workflows (scripted vs agentic, where logic belongs, state vs result vs documents, error strategy), tools (single responsibility, schema/description as the LLM contract, idempotency), documents (a user-facing surface, not a data store), and modules / Studio apps (organize by feature, @StudioApp as a deliberate boundary). The principles every developer and agent should carry into the code. --- # Best Practices The other guides in this section show _how_ each mechanism works. This page is about _judgment_: which concept to reach for, why, and how to keep things clean as they grow. It is the knowledge every developer — and every agent — should carry into Loopstack before writing the first line. None of it is API detail; it is the professional shape of clean Loopstack code. The through-line for all four building blocks is the same: **optimize for legibility.** Someone reading your code should understand the system without running it. ## Workflows A workflow is a **state machine**, and its highest virtue is legibility. Someone scanning your transitions should understand the process without executing it. ### Think in narratives, not steps A workflow's places (states) and transitions are a story. Name them so the story reads top to bottom: ``` start → fetch_data → summarize → waiting_for_approval → publish → end ``` Good place names describe _where the workflow is_; good transition method names describe _the step being taken_ (`fetchData`, `summarize`, `publish`). When the state machine reads like prose, routing bugs become visible and onboarding is free. Avoid abstract names (`step1`, `process2`, `handlerA`) — name things for what they do, never for an invented taxonomy. ### Scripted by default; agentic only when the path is open-ended Loopstack gives you two modes for doing work: - **Scripted** — you write the transitions. The path is fixed and deterministic. - **Agentic** — an `AgentWorkflow` runs an LLM tool-calling loop and _decides_ the path at runtime. **Default to scripted.** Determinism is cheaper, faster, testable, and debuggable. Reach for an agent only when the sequence of steps genuinely cannot be known in advance — open-ended exploration, research, or tasks where the next action depends on what the previous one discovered. The best designs are usually **hybrid**: a deterministic workflow that scaffolds the run (validate input → prepare context → _contained agent step_ → validate output → publish). The agent gets autonomy only where autonomy is needed; everything around it stays predictable. An agent is a tool you place _inside_ a clear process, not a replacement for having one. ### Thin transitions, fat tools Logic has two homes, and putting it in the wrong one is the most common source of unmaintainable workflows. - **Transitions orchestrate.** They decide what happens next and move state. Keep their bodies short — read inputs, call a tool or a sub-workflow, write state, done. - **Tools do the work.** Domain logic belongs in a tool: one tool, one responsibility, a narrow Zod input schema, and a clear description. Why this split pays off: a tool is reusable across workflows, independently testable, and — because tools carry a description and schema — it doubles as an LLM function with zero extra work. Logic inlined in a transition is none of those things. ```typescript // Avoid: business logic living in the transition @Transition({ from: 'ready', to: 'done' }) async process(state: MyState) { const rows = await db.query(...); // domain logic const filtered = rows.filter(...); // domain logic this.assignState({ count: filtered.length }); } // Prefer: the transition orchestrates, the tool does the work @Transition({ from: 'ready', to: 'done' }) async process(state: MyState) { const result = await this.fetchActiveUsers.call({ since: state.since }); this.assignState({ count: result.data.length }); } ``` ### Know your three data surfaces: state, result, documents Workflows expose data in three places. Choosing the wrong one is the subtlest and most common smell. | Surface | What it is | Use it for | | ------------- | ------------------------------------------------ | ---------------------------------------------------- | | **State** | Internal working memory, persisted across pauses | Intermediate values the workflow needs to do its job | | **Result** | The workflow's _published output_ | The contract consumers and parent workflows read | | **Documents** | Typed objects rendered in Studio | Everything the _user_ should see | The rules of thumb: - **Keep state minimal.** It is scratch space, not an output channel. If a value only matters mid-run, it stays in state and never leaves. - **Design the result as a deliberate contract.** It is the public API of your workflow — what a parent receives in a callback, what a consumer depends on. Publish a clean, intentional shape with `setResult` / `assignResult`. Never dump raw internal state into the result. - **Documents are for humans.** If a value needs to be _seen_, save a document. If it needs to be _consumed by code_, it belongs in the result. They are not interchangeable. ### Extract a sub-workflow for a reason, not by reflex One readable workflow beats five fragmented ones. Split only when there is a concrete payoff: - **Reuse** — the same unit of work is needed in more than one place. - **Independent lifecycle or UI** — the unit pauses for its own input, or should render as its own panel/link in Studio. - **Fan-out** — you need to run the unit in parallel or in sequence over a list. If none of those apply, keep it inline. Premature decomposition trades one clear flow for orchestration overhead. And when you _do_ split, name the sub-workflow for what it accomplishes — never `sub-workflow-a` or `type-2-handler`. ### Pause only for genuinely external events A `wait` transition pauses the workflow until something outside it happens: a user clicks a button, a sub-workflow completes, an API calls back. That is the _only_ reason to wait. Never use waiting to poll or to "give something time." If you find yourself waiting and re-checking, the work should be a callback instead. Waiting is for handing control out and getting it back — not for busy-looping. ### Keep guards pure Guards decide routing when several transitions share a `from` place. Treat them as **pure predicates over state**: they read, they return a boolean, they do nothing else. A guard that mutates state, calls a tool, or has a side effect makes routing impossible to reason about and impossible to test in isolation. Compute the decision inputs in a transition, store them in state, and let the guard read them. The guard is the single, visible source of routing truth. ### Choose an error strategy by failure type Not all failures are equal. Match the recovery mode to the _kind_ of failure: | Failure kind | Strategy | | ------------------------------------------ | --------------------------------------------------------------------------- | | Transient (network blip, rate limit) | **Auto-retry** | | User-fixable (bad input, missing approval) | **Error place / manual retry** — surface it and let the user correct course | | Fatal (programmer error, impossible state) | **Fail loudly** — don't paper over a bug | Then internalize the rollback reality: **when a transition throws, its state mutations roll back — but external side effects do not.** A half-sent email or a half-written file stays half-done. So design transitions to be **safe to re-run**: make side effects idempotent, or structure them so a retry can't double-apply them. Surface failures the user should see as an `ErrorDocument` rather than letting them disappear into logs. ### Treat schemas as contracts Every boundary — workflow args, state, transition input — takes a Zod schema. Use them. A schema validates at runtime _and_ documents the shape for the next reader (human or LLM). Validated boundaries turn a class of silent, late failures into loud, early ones. The few lines a schema costs are repaid the first time bad data is rejected at the door instead of corrupting a run three steps later. ### Templates present; code decides Handlebars templates are for assembling text — prompts, markdown, user-facing copy. Keep _decisions_ out of them. Branching, lookups, and computed values belong in TypeScript where they can be read, typed, and tested; the template just renders the result. A template that contains business logic is logic hidden where no test will find it. ### Conventions that keep workflows clean These are project conventions; the reasons behind them are what matter: - **Transitions return nothing — mutate via setters** (`assignState` / `setState`, `assignResult` / `setResult`). Returning a value throws at runtime. This keeps state changes explicit and uniformly rollback-able. - **`async` only when the body awaits.** Don't decorate a synchronous transition with `async` — the linter strips unused `async`, and the intent stays honest. - **No abstract base classes for workflow patterns.** Compose with tools and sub-workflows instead of inheritance hierarchies; the framework is built for composition. - **Fix root causes, not symptoms.** When a workflow misbehaves, find the wrong assumption — don't add a guard or a retry to mask it. - **No compatibility shims.** When a contract changes, update every caller. Don't leave deprecated re-exports behind. ## Tools A tool is a reusable unit of logic with **two audiences**: your own code and the LLM that may call it as a function. Design for both. ### One tool, one responsibility A narrow tool is reusable, composable, and — crucially — easy for an LLM to call correctly. A tool that does five things is hard to reuse and hard for a model to use right, because the model has to guess which of its behaviors you want. If a tool's description needs the word "and" several times, it should probably be several tools. ### The description and schema are the LLM's instructions For a tool that an LLM can call, the description and Zod schema are not just documentation — they are the _only_ thing the model reads to decide **when** and **how** to call it. Write the description to state what the tool does and when to reach for it; give each field a clear, narrow type. Vague descriptions produce wrong calls. This is effort that pays back every time the model uses the tool. ### Keep tools stateless and self-contained Everything a tool needs arrives through its `args` and the `ctx` it is handed. A tool should never reach into a particular workflow's internal state — that couples it to one caller and breaks the reuse that justified making it a tool. Same inputs should mean same behavior. ### Make tools safe to re-run Tools get retried, and the rollback rules cover the calling transition's state — not a tool's external side effects. Design side effects to be idempotent so a retry can't double-apply them (don't charge the card twice, don't append the row twice). ### Return a clean result; fail with actionable errors A tool's result is a contract for both code and the LLM, so return a typed, intentional shape. And when a tool errors during agent use, the message is fed back to the model so it can self-correct — so an error should say _what_ went wrong and _how_ to fix it, not just "failed." ## Documents Documents are how a workflow talks to the **user**. They are a communication surface, not a data store. ### Documents are for humans, not for code If a value exists so that _code_ can use it, it belongs in state or result. Save a document only for what the _user_ should see in Studio. Using a document as a place to stash data your workflow will read back later is the most common misuse — it couples your logic to the UI and clutters the run. ### Reuse built-in document types before creating a custom one Markdown, chat messages, links, and errors already cover most needs. Create a custom document only when the _rendering_ genuinely differs — a form, a code editor, a structured display. A custom document is UI you have to maintain; don't add one just to hold plain data that a built-in type would render fine. ### Emit documents to keep a run legible A run a user can follow in Studio is one they can trust and debug. Save the meaningful intermediate outputs, not only the final one — but don't spam: a document the user doesn't need is noise that hides the ones they do. ## Modules & Studio Apps Modules are how you organize capability; `@StudioApp` is the user-facing boundary on top of that organization. ### Organize modules by feature, not by type Group the workflows, tools, and services of one capability into one module. Don't create a "tools module" and a "workflows module" — that scatters a single feature across the codebase. A module should read as one cohesive thing the system can do. ### Import only what you use A module's imports are its honest dependency list. Pull in a provider module (LLM, OAuth, sandbox, secrets) where the capability is actually needed and nowhere else. A lean, accurate import graph is one you can reason about; a kitchen-sink module hides what really depends on what. ### `@StudioApp` is a deliberate boundary, not a default `@StudioApp` marks a module as a launchable application in Studio. Apply it only to modules meant to be run by users — without it, workflows still exist as providers but stay out of the UI, which is often exactly right for internal building blocks. Keep each app focused: a coherent set of related workflows under one clear title, not a dumping ground. ### Register a shared tool once, at the right level Tools resolve through dependency injection at runtime, so a tool registered in a module is available to every workflow and agent in scope. Register a shared tool once where its consumers can reach it, rather than re-declaring it per workflow — that is what lets an agent and a scripted workflow use the same tool without duplication. ## Related - [Creating Workflows](./fundamentals/workflows.md) · [Tools](./fundamentals/tools.md) · [Documents](./fundamentals/documents.md) · [Modules](./fundamentals/modules.md) · [Studio App Config](./fundamentals/studio-config.md) - [State Management](./patterns/state-management.md) · [Error Handling](./patterns/error-handling.md) · [Sub-Workflows](./patterns/sub-workflows.md) · [Dynamic Routing](./patterns/dynamic-routing.md) - [Agent Workflows](./ai/agent-workflows.md) — when and how to go agentic --- > 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: './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, widget: './notes.ui.yaml', }) ``` All options are optional. | Option | Type | Default | Description | | ------------- | -------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | class name with `Document` suffix stripped, snake_cased | Explicit snake_case identifier. E.g. `AskUserDocument` → `ask_user`, `LlmMessageDocument` → `llm_message`. | | `title` | `string` | — | Human-readable display title shown in Studio UI. | | `description` | `string` | — | Human-readable description shown in Studio UI. | | `widget` | `WidgetRef \| WidgetRef[]` | — | Path(s) to YAML file(s) — or inline widget object(s) — defining how the document renders in Studio. | | `schema` | `z.ZodType` | — | Zod schema validating document content on `documentStore.save()`. | | `tags` | `string[]` | — | Default tags assigned to every instance of this document. Useful for filtering and querying. | | `meta` | `StaticDocumentMeta` | — | Static document metadata — served via the config endpoint, not persisted per instance. | | `internal` | `boolean` | `false` | When `true`, instances are persisted server-side (still readable by LLM providers) but excluded from API responses and live updates — Studio never sees them. Use for framework plumbing like LLM context. | > **Documents are plain DTOs, not NestJS providers.** Unlike `@Tool` and `@Workflow`, `@Document` does **not** apply `@Injectable()`. Don't add document classes to a module's `providers` array and don't try to inject them — reference the class directly when calling `documentStore.save(MyDocument, ...)`. ## 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 key (upsert — invalidates the previous version) await this.documentStore.save(NotesDocument, { text: 'Updated content' }, { key: 'notes-1' }); // With meta options (free-form extension data on the document row) await this.documentStore.save(NotesDocument, { text: 'Tagged note' }, { meta: { source: 'import' } }); ``` ### Saving an Instance `save()` is overloaded — instead of passing class + data, you can `create()` an instance, mutate it, then save it. Useful when you need to build up a document across several steps before persisting. ```typescript const draft = this.documentStore.create(NotesDocument, { text: 'Initial draft' }); draft.text += '\n\nAddendum.'; await this.documentStore.save(draft); // With save options await this.documentStore.save(draft, { key: 'notes-1' }); ``` `create()` returns a class instance (typed as `NotesDocument`) populated with the data; it does not persist anything. Persistence only happens on `save()`. ### Save Options | Option | Type | Description | | ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `key` | `string` | Stable upsert key — saving twice with the same `key` invalidates the previous row in place. Use for documents that update over time (status tickers, form state, transcripts). | | `validate` | `'strict' \| 'safe' \| 'skip'` | Validation mode. Default `'strict'` — throws on invalid content. `'safe'` stores partial data + error. `'skip'` bypasses validation. See [Validation](../../learn/document-store.md#validation). | | `meta` | `Record` | Free-form extension data persisted on the document row. Use for ad-hoc payload that downstream readers need. For framework concerns (hide from UI, etc.), use decorator options instead. | | `meta.invalidate` | `boolean` | When `false`, prevents the previous version with the same `key` from being invalidated. Default behavior replaces the old version. | ## Querying Documents `documentStore` exposes three read methods. All of them return only **non-invalidated** documents for the current workflow run — invalidated revisions are filtered out automatically. | Method | Returns | When to use | | --------------------- | ------------------ | -------------------------------------------------------------------- | | `findAll(MyDocument)` | `MyDocument[]` | All documents of one type, **hydrated as typed instances**. | | `findByTag('tag')` | `DocumentEntity[]` | Documents tagged with that tag (across types). Returns raw entities. | | `findAllDocuments()` | `DocumentEntity[]` | Everything in the run — useful for LLM tools, history scans. | ```typescript // Typed, type-safe — preferred const notes = this.documentStore.findAll(NotesDocument); notes.forEach((n) => console.log(n.text)); // By tag — across document types const messages = this.documentStore.findByTag('message'); // All documents in this run (raw entities) const all = this.documentStore.findAllDocuments(); ``` `findAll` re-validates and hydrates entities back into class instances (via `plainToInstance`). `findByTag` and `findAllDocuments` return raw `DocumentEntity` objects — use `entity.content` for the persisted data and `entity.documentName` to discriminate types. > Need a typed instance without persisting? Use [`documentStore.create(MyDocument, data)`](#saving-an-instance) — it validates against the Zod schema and returns a class instance with no DB write. ## Built-in Document Types These are available without creating custom documents: | Document | Source | Key Fields | | -------------------- | -------------------------------- | ------------------------------------------ | | `LlmMessageDocument` | `@loopstack/llm-provider-module` | `role`, `text`, `blocks` | | `LlmContextDocument` | `@loopstack/llm-provider-module` | `role`, `text` | | `LinkDocument` | `@loopstack/common` | `label`, `workflowId`, `embed`, `expanded` | | `MessageDocument` | `@loopstack/common` | `role`, `text` | | `MarkdownDocument` | `@loopstack/common` | `markdown` | | `PlainDocument` | `@loopstack/common` | `text` | | `ErrorDocument` | `@loopstack/common` | `error` | ### Choosing the right built-in type - **`LlmMessageDocument`** — visible assistant/user conversation turns. Extends `MessageDocument` with structured `blocks` (tool calls, thinking, tool results) and an LLM `stopReason`. Tagged `'message'`, so it's automatically collected into LLM conversation history. The LLM provider tools save these for you in normal use. - **`LlmContextDocument`** — hidden conversation context. Declared `@Document({ internal: true, tags: ['message'] })`. The LLM provider picks these up as conversation history just like `LlmMessageDocument`, but Studio doesn't show them. Use to seed system prompts, prior steps, or background info without polluting the user-facing chat. - **`MessageDocument`** — plain `{ role, text }` UI bubbles for non-LLM flows (status updates, narrative output, logging a `system` note). Tagged `'ui-message'` — **not** collected into LLM history. Use this when you want a chat-style message in Studio without polluting the LLM's context. - **`MarkdownDocument`** — formatted prose, headings, lists, links. Use when you want Studio to render rich text. - **`PlainDocument`** — unformatted text output: raw command output, log dumps, plain blob. Use when Markdown rendering would interpret characters you want shown literally. - **`LinkDocument`** — links to other workflow runs (sub-workflows, related runs). Studio renders these as cards with a live status indicator derived from the linked workflow's state. The orchestrator saves one automatically when you call `subWorkflow.run()` (see [Sub-Workflows](../patterns/sub-workflows.md)); save manually only if you need a link card outside the standard sub-workflow flow. - **`ErrorDocument`** — engine-managed; do not construct manually. Written automatically when a transition fails (see [Workflow Engine — ErrorDocument](../../learn/workflow-engine.md#errordocument)). ```typescript import { LinkDocument, MarkdownDocument, PlainDocument } from '@loopstack/common'; import { LlmMessageDocument } from '@loopstack/llm-provider-module'; await this.documentStore.save(LlmMessageDocument, { role: 'assistant', text: 'Hello! How can I help?', }); await this.documentStore.save(MarkdownDocument, { markdown: '# Report\n- Item 1\n- Item 2', }); // Raw command output — keep characters literal await this.documentStore.save(PlainDocument, { text: shellOutput }); ``` ## 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 Tags categorize documents for filtering and searching. There are two ways to set them: **Decorator default tags** — written to every instance, persisted on each row, queryable via `findByTag()`: ```typescript @Document({ schema: NotesSchema, widget: './notes.ui.yaml', tags: ['message', 'important'], }) export class NotesDocument { /* ... */ } ``` **YAML config tags** — static metadata served via the config endpoint, used by Studio and LLM tools for grouping/filtering, not persisted on individual rows: ```yaml type: document tags: - message - important ``` Decorator tags are the right choice for runtime querying — every saved instance carries them, and `this.documentStore.findByTag('message')` returns them. YAML tags are for static document-type metadata. Tags from both sources are also 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: './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-llm-examples#structured-output) — FileDocument with code-view widget for AI-generated code - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-hitl-examples#meeting-notes) — MeetingNotesDocument and OptimizedNotesDocument with form widgets and action buttons - [test-ui-documents-example-workflow](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#ui-documents) — Demonstrates all core UI document types: MessageDocument, ErrorDocument, MarkdownDocument, PlainDocument --- > **Using an AI coding agent?** See [Skill: Create a Custom Document](../../skills/create-custom-document.md) for a dense checklist and syntax reference optimized for code generation. --- > Source: https://loopstack.ai/llms/build/fundamentals/modules.md --- title: Modules & Workspaces description: How to organize workflows, tools, and services into NestJS modules. Covers @StudioApp decorator for app identity, module structure, app modules vs feature modules, workspace configuration, forRoot/forFeature patterns, and provider registration. --- # Modules & Workspaces Loopstack uses NestJS modules to organize your application. Workflows and tools are registered as standard NestJS providers. ## App Modules (`@StudioApp`) Every module whose workflows should be **visible and launchable in Loopstack Studio** must be decorated with `@StudioApp`. Without it, workflows are registered as NestJS providers but do not appear in the Studio UI. ```typescript import { Module } from '@nestjs/common'; import { ClaudeModule } from '@loopstack/claude-module'; import { StudioApp } from '@loopstack/common'; import { MyTool } from './tools/my.tool'; import { MyWorkflow } from './workflows/my.workflow'; @StudioApp({ title: 'My App', workflows: [MyWorkflow], }) @Module({ imports: [ClaudeModule], providers: [MyWorkflow, MyTool], }) export class MyAppModule {} ``` `@StudioApp` options: - **`title`** (required) — display name shown in Studio - **`workflows`** — array of workflow classes launchable from Studio - **`app`** — explicit snake_case identifier (defaults to module class name with `Module` → `App`, e.g. `HelloModule` → `hello_app`, `CodeAgentModule` → `code_agent_app`) - **`description`** — human-readable description - **`ui`** — Studio UI options for this app. See [Studio Configuration](./studio-config.md). > **Important:** `@StudioApp` is a metadata decorator — it does not replace `@Module`. Both decorators are required on the same class. ### Recommended File Layout A typical app module groups workflows, tools, services, documents, and tests like this: ``` my-app/ └── src/ ├── my-app.module.ts # @StudioApp + @Module ├── index.ts # public exports ├── workflows/ │ ├── index.ts │ ├── my.workflow.ts │ ├── my.ui.yaml # widget config for Studio │ └── __tests__/ │ └── my.workflow.spec.ts ├── tools/ │ ├── index.ts │ ├── my.tool.ts │ └── __tests__/ │ └── my.tool.spec.ts ├── documents/ │ ├── my-document.ts # @Document class │ └── my-document.yaml # UI/widget metadata ├── services/ │ └── my.service.ts └── templates/ └── prompt.md # Handlebars/JEXL templates ``` Conventions: - **`__tests__/`** folder next to source for `*.spec.ts` files - **`workflows/`, `tools/`, `documents/`, `services/`, `templates/`** as sibling folders under `src/` - **`index.ts`** re-exports public symbols (used when this module is consumed as a package) - Document classes (`*.ts`) and their widget config (`*.yaml`) sit side by side Smaller modules can collapse this — a one-workflow module often keeps everything flat under `src/`. Use this layout as the upper bound, not a requirement. See the [Registry examples](https://loopstack.ai/registry) (`custom-tool-example-module`, `meeting-notes-example-workflow`) for concrete references. ### App Modules vs Feature Modules There are two kinds of modules in a Loopstack project: - **App modules** — decorated with `@StudioApp`, define a launchable application in Studio. They list workflows in the `workflows` array. - **Feature modules** — plain `@Module` classes that provide reusable tools, services, or workflows. They are imported by app modules but don't appear in Studio on their own. Registry packages (like `ClaudeModule`, `SandboxToolModule`, or example workflows) are feature modules. When you import them, you still need a `@StudioApp` module to surface their workflows in Studio. > Feature modules are auto-discovered when reachable from a `@StudioApp` module's import graph. Some modules also opt into a Studio UI surface (panel, widget) via `forFeature()` — see [Studio Features](../../extend/features.md) for details. > **`@StudioApp` modules must not be nested.** An app module cannot import another app module — Loopstack throws at bootstrap if it detects this. Always import each app module independently from your root `AppModule`. Shared logic belongs in plain feature modules. ## Feature Modules A feature module groups related workflows, tools, and services together for reuse across app modules. ```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 app modules and shared feature modules: ```typescript // analytics.module.ts — app module (visible in Studio) @StudioApp({ title: 'Analytics', workflows: [AnalyticsWorkflow], }) @Module({ imports: [ClaudeModule], providers: [AnalyticsWorkflow, DataFetchTool], }) export class AnalyticsModule {} // shared-tools.module.ts — feature module (not visible in Studio) @Module({ providers: [EmailTool, SlackTool], exports: [EmailTool, SlackTool], }) export class SharedToolsModule {} // notifications.module.ts — app module (visible in Studio) @StudioApp({ title: 'Notifications', workflows: [NotificationWorkflow], }) @Module({ imports: [SharedToolsModule], providers: [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 {} ``` Each `forFeature()` import creates an isolated scope — tools in `MyFeatureModule` see the override, while tools in any other module continue to see the global `forRoot()` default. Multiple `forFeature()` calls in different modules coexist without interfering, each with its own resolved config. Modules that support this pattern include `LlmProviderModule`, `RemoteClientModule`, `SecretsModule`, and others. Per-call config (via `options.config`) always takes priority over module defaults. ### Creating Your Own Configurable Module Authoring a module that supports `forRoot()` / `forFeature()` requires three pieces: 1. A **config injection token** (a `Symbol`) and a `Config` interface — so providers can `@Inject()` the resolved config. 2. A **separate `@Global()` root module** that provides the default config and exports the tools. Keeping it as its own class prevents NestJS from deduplicating it with `forFeature()` imports. 3. A **wrapper module** exposing the static `forRoot()` and `forFeature()` factories that return `DynamicModule`s with the overridden config. ```typescript // my-feature.constants.ts export const MY_FEATURE_CONFIG = Symbol('MY_FEATURE_CONFIG'); export interface MyFeatureConfig { apiKey?: string; region?: string; } ``` ```typescript // my-feature.module.ts import { DynamicModule, Global, Module } from '@nestjs/common'; import { MY_FEATURE_CONFIG, MyFeatureConfig } from './my-feature.constants.js'; import { MyTool } from './my.tool.js'; const DEFAULT_CONFIG: MyFeatureConfig = {}; const TOOLS = [MyTool]; @Global() @Module({ providers: [{ provide: MY_FEATURE_CONFIG, useValue: DEFAULT_CONFIG }, ...TOOLS], exports: [MY_FEATURE_CONFIG, ...TOOLS], }) class MyFeatureRootModule {} @Module({}) export class MyFeatureModule { static forRoot(config: MyFeatureConfig): DynamicModule { return { module: MyFeatureRootModule, global: true, providers: [{ provide: MY_FEATURE_CONFIG, useValue: config }, ...TOOLS], exports: [MY_FEATURE_CONFIG, ...TOOLS], }; } static forFeature(config: MyFeatureConfig): DynamicModule { return { module: MyFeatureModule, imports: [MyFeatureRootModule], providers: [{ provide: MY_FEATURE_CONFIG, useValue: config }, ...TOOLS], exports: [...TOOLS], }; } } ``` Providers then inject the resolved config via the token: ```typescript @Tool({ name: 'my_tool' }) export class MyTool extends BaseTool { constructor(@Inject(MY_FEATURE_CONFIG) private readonly config: MyFeatureConfig) { super(); } } ``` The `forFeature()` import of `MyFeatureRootModule` ensures the global default is always available — bare `import MyFeatureModule` works even if `forRoot()` was never called. **Why `@Global()`?** The `@Global()` decorator is a [standard NestJS feature](https://docs.nestjs.com/modules#global-modules) that makes a module's exports available to every other module in the application without an explicit `imports: [...]` entry. The root module uses it so that `MY_FEATURE_CONFIG` and the tools are injectable anywhere — including in modules that never call `forRoot()` or `forFeature()`. This is also why `LoopCoreModule` is marked global by `LoopstackModule.forRoot()`. Use `@Global()` sparingly for app-wide singletons; prefer per-module imports for scoped behavior. See [module-config-example](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#module-config) for a complete runnable example, including a nested wrapper module that passes config through to the underlying configurable module. ## Dependency Injection Workflows and tools use standard NestJS constructor injection: ```typescript @Workflow({ widget: './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-hitl-examples#prompt-input-chat) — Example module with ClaudeModule import - [custom-tool-example-module](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#custom-tool) — Module with custom tools, services, and workflow providers - [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#sub-workflow) — Module registering both parent and sub-workflow providers --- > Source: https://loopstack.ai/llms/build/fundamentals/studio-config.md --- title: Studio Configuration description: Reference for the `ui` option on @StudioApp. Documents StudioUiConfig and the StudioWidgetConfig widget array used to customize an app's Studio surface. --- # Studio Configuration The `ui` option on `@StudioApp` controls how an app is rendered in Loopstack Studio. This page is the single reference for those options. More options will be added over time. ## `ui.widgets` `widgets` is an array of widget descriptors. Each entry names a widget and may pass widget-specific options. ```typescript @StudioApp({ title: 'My App', workflows: [MyWorkflow], ui: { widgets: [{ widget: 'prompt-input', options: { placeholder: 'Ask anything…' } }], }, }) @Module({ /* ... */ }) export class MyAppModule {} ``` Each entry has: - **`widget`** — the widget identifier (e.g. `prompt-input`, `button`, `form`) - **`options`** — optional, widget-specific configuration The same widget format is used in document and workflow YAML configs under `ui.widgets[]`. ## Available Features Some registry modules light up additional Studio surfaces when imported by your app — sidebars, panels, or richer document widgets. They register themselves at bootstrap and appear under `StudioAppConfig.features`, which Studio reads to decide what UI to expose. You don't have to wire anything up beyond importing the module. | Feature | Registered by | What it adds to Studio | | -------------- | --------------------------------------------------------------------------------- | ------------------------------------------------ | | `git` | `@loopstack/git-module` (`GitModule.forFeature(config)`) | Git status panel and version-control affordances | | `fileExplorer` | `@loopstack/local-file-explorer-module`, `@loopstack/remote-file-explorer-module` | File-tree browser sidebar | | `secrets` | `@loopstack/secrets-module` (`SecretsModule.forFeature(config)`) | Workspace secrets management UI | To enable a feature, import the corresponding module's `forFeature()` (or `forRoot()`) in your app's module graph. Features that aren't imported simply don't appear — Studio degrades gracefully and never assumes a feature is present. > Feature registration is an internal mechanism — module authors call `registerFeature()` to declare a feature exists. Most users only need to know which features are available and how to enable them, both shown above. --- > 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, ToolEnvelope } from '@loopstack/common'; import type { RunContext } 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: RunContext): Promise> { return { data: `Found results for: ${args.query}` }; } } ``` ## The `@Tool` Decorator ```typescript @Tool({ name: 'my_tool', description: 'User-facing description.', schema: InputSchema, }) ``` All options are optional. | Option | Type | Default | Description | | -------------- | -------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | class name (as-is) | Unique identifier used in the LLM tool-calling wire format. Always set this to a snake_case identifier (e.g. `git_status`, `math_sum`) — the class name is a fallback. | | `description` | `string` | — | Human-readable description shown to LLMs for tool-use. Critical for autonomous tool calling. | | `widget` | `WidgetRef \| WidgetRef[]` | — | Custom Studio widget(s) for rendering tool calls/results — YAML file path(s) or inline widget object(s). | | `schema` | `z.ZodType` | — | Zod schema validating tool arguments before `handle()` is invoked. | | `configSchema` | `z.ZodType` | — | Zod schema validating tool config (provided via `options.config` on `call()`). | ## 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: RunContext, options?: ToolCallOptions, ): Promise> { // Your logic here return { data: result }; } ``` The public `call()` method is the entry point — it routes through validation before calling `handle()`, then narrows the envelope: throws on `error`, throws on `pending`, returns a `ToolResult` with `data` and `metadata` non-optional. ## ToolEnvelope vs ToolResult Two related types — pick the right one for the right side of the boundary: ```typescript // What handle() returns — internal/dispatcher shape. type ToolEnvelope = { type?: 'text' | 'image' | 'file'; data?: TData; error?: string; metadata?: Record; pending?: { workflowId: string }; }; // What tool.call() returns — the narrowed success path workflow authors see. interface ToolResult { data: TData; metadata: Record; type?: 'text' | 'image' | 'file'; } ``` Return patterns inside `handle()`: ```typescript return { data: 42 }; // Simple value return { data: { name: 'result', items: [...] } }; // Typed data return { error: 'Something went wrong' }; // Recoverable failure (LLM tool loop reads this) return { type: 'text', data: 'Mostly sunny, 14C.' }; // Typed output return { data: result, metadata: { tokensUsed: 150 } }; // With metadata return { pending: { workflowId } }; // Async sub-workflow launched ``` ## 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: RunContext): 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: RunContext): 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'] } }, ); ``` ## Async Tools (sub-workflow callbacks) A tool can launch a sub-workflow from `handle()` and finish asynchronously when that sub-workflow completes. The lifecycle has two halves: 1. **`handle()`** returns `{ data, pending: { workflowId } }`. The `pending` field tells the framework "I started run `workflowId`, don't return to the LLM yet — wait for that run to finish, then call me back." 2. **`complete(result)`** runs when the sub-workflow finishes. The argument is the sub-workflow's output. The return value is the `ToolEnvelope` that's actually delivered to the LLM (or the caller). The default `complete()` on `BaseTool` passes the sub-workflow's data straight through: ```typescript async complete(result: Record): Promise { return { data: (result as { data?: unknown }).data ?? result }; } ``` Override it when you need to post-process: transform the payload, validate, or short-circuit. The HITL pattern uses this — `AskForApprovalTool.handle()` launches the sub-workflow with `show: 'inline'` so its UI appears in the parent's run view, and returns `pending`; `complete()` returns the user's decision as the typed answer. ```typescript @Tool({ name: 'ask_for_approval', description: 'Ask the user to approve.', /* … */ }) export class AskForApprovalTool extends BaseTool { protected async handle(/* … */) { const { workflowId } = await this.confirmWorkflow.run( args, { callback: { transition: 'onConfirm' }, show: 'inline', label: 'Waiting for approval...' }, ); return { data: { workflowId }, pending: { workflowId } }; } async complete(result: Record): Promise> { const { workflowId, data } = result as { workflowId: string; data: { confirmed: boolean } }; return { data: { approved: data.confirmed, workflowId } }; } } ``` Use this when a tool genuinely depends on an async outcome (HITL, long-running provisioning, external job completion). For tools that finish synchronously inside `handle()`, you don't need `complete()` at all. ## Server Tools Some LLM providers ship built-in tools that **run on the provider's side**, not in your app — examples are Anthropic's `web_search` and `code_execution`. Loopstack exposes these via the `ServerTool` base class instead of `BaseTool`. Key differences from `BaseTool`: | Aspect | `BaseTool` | `ServerTool` | | --------------- | -------------------------------------------- | ----------------------------------------- | | Execution | runs locally in your app | runs on the LLM provider's infrastructure | | Abstract method | `handle(args, ctx, options)` | `toServerToolConfig(config?)` | | Has `call()` | yes — entry point for workflow code | no — the provider invokes it | | Use it for | your own logic, API calls, internal services | provider-native built-in tools | A server tool's job is to translate the workflow author's config into the provider-native shape. The framework detects `instanceof ServerTool` when assembling the LLM request and sends the result of `toServerToolConfig()` to the provider instead of registering a callable local tool. ```typescript import { z } from 'zod'; import { ServerTool, Tool } from '@loopstack/common'; const ConfigSchema = z.object({ maxUses: z.number().int().positive().default(8), allowedDomains: z.array(z.string()).optional(), }); type Config = z.infer; @Tool({ name: 'claude_web_search_server', description: "Search the web using Claude's built-in server-side web search.", configSchema: ConfigSchema, }) export class ClaudeWebSearchServerTool extends ServerTool { toServerToolConfig(config?: Config): unknown { return { type: 'web_search_20260209', name: 'web_search', max_uses: config?.maxUses ?? 8, ...(config?.allowedDomains?.length ? { allowed_domains: config.allowedDomains } : {}), }; } } ``` List the server tool in the LLM `tools` config the same way as a regular tool — the framework picks the right code path based on the class. ```typescript await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['claude_web_search_server'], }, }, ); ``` ## Using Tools in Workflows ```typescript constructor(private readonly myTool: SearchTool) { super(); } @Transition({ from: 'ready', to: 'done' }) async process(state: MyState) { const result = await this.myTool.call({ query: 'hello', limit: 5 }); this.assignState({ 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-advanced-workflows-examples#custom-tool) — MathSumTool with injected service, stateful CounterTool, and workflow demonstrating tool usage - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-agent-examples#custom-agent) — GetWeather tool exposed to the LLM for function calling --- > **Using an AI coding agent?** See [Skill: Create a Custom Tool](../../skills/create-custom-tool.md) 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. > **Before you build much:** read [Best Practices](../best-practices.md) — when to choose scripted vs agentic flows, where logic belongs, the difference between state, result, and documents, and the conventions that keep workflows clean. ## 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, type TransitionInput, Workflow } from '@loopstack/common'; import { LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; @Workflow({ widget: './chat.ui.yaml', // UI config }) export class ChatWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } // 1. Entry point @Transition({ to: 'waiting_for_user' }) setup(state: Record) {} // 2. Wait for user message @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string(), }) async userMessage(state: Record, input: TransitionInput) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: input.data }); } // 3. Call LLM and loop back @Transition({ from: 'ready', to: 'waiting_for_user', }) async llmTurn(state: Record) { await this.llmGenerateText.call({}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }); } } ``` 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: './chat.ui.yaml', }) ``` All options are optional. | Option | Type | Default | Description | | -------------- | -------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `name` | `string` | class name with `Workflow` suffix stripped, snake_cased | Explicit snake_case identifier. E.g. `ChatWorkflow` → `chat`, `AgentExampleWorkflow` → `agent_example`. | | `title` | `string` | — | Human-readable display title shown in Studio UI. | | `description` | `string` | — | Human-readable description shown in Studio UI. | | `widget` | `WidgetRef \| WidgetRef[]` | — | Path(s) to YAML file(s) — or inline widget object(s) — defining the Studio UI surface for this workflow. | | `schema` | `z.ZodType` | — | Zod schema validating workflow input arguments. Surfaces as `ctx.args` in transitions. | | `configSchema` | `z.ZodType` | — | Zod schema validating workflow config (provided via `options.config` at start time / sub-workflow `run()`). | | `stateSchema` | `z.ZodType` | — | Zod schema validating the resulting state after every transition (see [Validating State](#validating-state-with-stateschema)). | ```typescript @Workflow({ widget: './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: RunContext`. The generic parameter types `ctx.args` — prefer it over casting: | Context Property | Description | | --------------------------- | ------------------------------------------------------------------------------------------ | | `ctx.userId` | User ID | | `ctx.workspaceId` | Workspace ID | | `ctx.workflowId` | Current workflow run ID | | `ctx.args` | Validated input arguments (typed via `RunContext`) | | `ctx.execution?.place` | Current state name. Present in workflow transitions, absent when `ctx` is passed to tools. | | `ctx.execution?.retryCount` | Retry attempt counter for the current transition (0 on first run). | ```typescript @Transition({ to: 'ready', schema: z.object({ subject: z.string() }) }) setup(state: MyState, ctx: RunContext<{ subject: string }>) { this.assignState({ subject: ctx.args.subject }); } ``` ## Transition Types ### Initial Transition — Entry Point Runs once when the workflow starts. Uses `@Transition` with no `from` (defaults to `'start'`): ```typescript @Transition({ to: 'ready' }) setup(state: MyState, ctx: RunContext<{ subject: string }>) { this.assignState({ subject: ctx.args.subject }); } ``` The `state` parameter starts as an empty object `{}` — the initial transition is the place to populate it. ### Standard Transition — State Change Moves between states. Fires automatically unless `wait: true` is set. ```typescript @Transition({ from: 'ready', to: 'processed' }) async doWork(state: MyState) { const result = await this.myTool.call({ query: 'hello' }); this.assignState({ 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) { ... } ``` ### Wait Transition — Pause for Input Add `wait: true` to pause the workflow until externally triggered — by user input, a button click, or a sub-workflow callback. Use `schema` to validate and type the incoming `data`; the transition method receives a `TransitionInput` envelope with `data` plus failure info (`hasError`, `errorMessage`, `status`). ```typescript @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.object({ message: z.string() }), }) async userMessage(state: MyState, input: TransitionInput<{ message: string }>) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: input.data.message, }); } ``` For approval gates, confirmation dialogs, and other interactive pauses built on top of `wait: true`, see [Human-in-the-Loop](../patterns/human-in-the-loop.md). ### Final Transition — Completion Uses `@Transition` with `to: 'end'`. The workflow's output is published via `this.assignResult(...)` or `this.setResult(...)` — that value is what parent workflow callbacks and `WorkflowRunner` callers receive. ```typescript @Transition({ from: 'done', to: 'end' }) finish(state: MyState) { this.setResult({ concept: state.confirmedConcept! }); } ``` ## Guarding Transitions When multiple transitions share the same `from` state, attach `@Guard('methodName')` to pick which one fires: - Higher `priority` is checked first. Transitions without `priority` are evaluated last, in declaration order. - A guard is a boolean method on the workflow that receives the current `state` and returns `true` to allow the transition. - A transition without a guard always passes — use it as the fallback. ```typescript @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: MyState) { const result = await this.llmDelegateToolCalls.call({ message: state.llmResult!.message, callback: { transition: 'toolResultReceived' }, }); this.assignState({ delegateResult: result.data }); } @Transition({ from: 'prompt_executed', to: 'end' }) respond(_state: MyState) {} hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } ``` From `prompt_executed`, `executeToolCalls` fires whenever the LLM requested tools; otherwise `respond` (unguarded) ends the workflow. ## State Transitions return nothing — mutate state via `this.assignState(...)`. Use `async` when the body awaits. State is read from the `state` parameter and written through setters on `BaseWorkflow`: | Setter | Effect | | ---------------------------- | --------------------------------------------------------------------------------------------------------- | | `this.assignState(partial)` | Shallow-merge `partial` into the current state. The most common form. | | `this.setState(full)` | Replace the state object outright. | | `this.assignResult(partial)` | Shallow-merge `partial` into the workflow's published `result` (visible to callers and parent callbacks). | | `this.setResult(full)` | Replace the workflow's published `result` outright. | ```typescript interface MyState { counter: number; llmResult?: LlmGenerateTextResult; } export class MyWorkflow extends BaseWorkflow { @Transition({ from: 'ready', to: 'processed' }) process(state: MyState) { this.assignState({ counter: (state.counter ?? 0) + 1 }); } } ``` Values persist even when the workflow pauses and resumes. Returning a value from a transition is a runtime error — every write must go through the setters. ### Validating State with `stateSchema` Add a Zod schema via `@Workflow({ stateSchema })` to enforce the state shape at runtime. The processor validates the resulting state after every transition (before persistence). If validation fails the transition errors out — bugs that corrupt state are caught at the point they occur instead of leaking into later transitions or checkpoints. ```typescript const MyStateSchema = z.object({ counter: z.number().int().nonnegative(), llmResult: z.unknown().optional(), }); @Workflow({ stateSchema: MyStateSchema, }) export class MyWorkflow extends BaseWorkflow { @Transition({ from: 'ready', to: 'processed' }) process(state: z.infer) { this.assignState({ counter: (state.counter ?? 0) + 1 }); } } ``` Use this when the state shape is critical and you want fail-fast diagnostics. Skip it for prototyping or when the state is loose by design. ## 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) { const result = await this.llmGenerateText.call( { prompt: 'Write a haiku' }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); this.assignState({ 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', text: 'Hello!', }); // Update an existing document by key (upsert in place) await this.documentStore.save( LlmMessageDocument, { role: 'assistant', text: 'Updated response' }, { key: 'response-1' }, ); // Hidden context (not shown in UI — LLM still sees it as conversation history) await this.documentStore.save(LlmContextDocument, { role: 'user', text: 'System prompt' }); ``` ## Templates `render` is available directly on `BaseWorkflow` (like `documentStore`). Use `this.render()` to render Handlebars template files: ```typescript const rendered = this.render(join(__dirname, 'templates', 'prompt.md'), { subject: args.subject, }); ``` ## 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-hitl-examples#prompt-input-chat) — Multi-turn chat workflow (the minimal example on this page) - [prompt-example-workflow](https://loopstack.ai/registry/loopstack-llm-examples#prompt) — Simple single-turn prompt workflow - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-agent-examples#custom-agent) — Tool calling loop with guards and conditional routing - [dynamic-routing-example-workflow](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#dynamic-routing) — Multi-level guard-based routing - [workflow-state-example-workflow](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#workflow-state) — State management with typed state interface - [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#sub-workflow) — Sub-workflow execution with callbacks --- > **Using an AI coding agent?** See [Skill: Create a Custom Workflow](../../skills/create-custom-workflow.md) 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 { RunContext } from '@loopstack/common'; import { LlmGenerateTextTool } 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: RunContext) { await this.llmGenerateText.call({ prompt: `Say hello to ${ctx.args.name} in a fun way in one sentence.`, }); } } ``` 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](../learn/core-concepts.md) — understand workflows, tools, documents, and providers - [Creating Workflows](./fundamentals/workflows.md) — transitions, guards, state, and wait patterns - [AI Text Generation](./ai/text-generation.md) — add LLM calls to your workflows ## zod version (reference) Loopstack requires **zod v4** — it uses the v4-only `z.toJSONSchema()` API to turn workflow input schemas into JSON Schema. npm 7+ installs it automatically as a peer dependency when you run `npm install @loopstack/loopstack-module`, so you don't normally need to install it yourself. Older tutorials may show `zod@^3`; that won't resolve against Loopstack's peer constraint and `npm install` will fail with `ERESOLVE`. --- > 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. ## How It Works The module is split into three layers: 1. **Provider registry** — provider modules (Google, GitHub, etc.) implement `OAuthProviderInterface` and self-register at module init. The registry resolves a provider by name at call time. 2. **OAuth workflow** — a generic `OAuthWorkflow` that builds the auth URL, surfaces a sign-in prompt via `OAuthPromptDocument`, waits for the browser callback, and exchanges the auth code for tokens. Run it as a sub-workflow from any parent workflow. 3. **Token store** — `OAuthTokenStore` persists access/refresh tokens per user per provider in Redis (falls back to in-memory when Redis is unavailable). `getValidAccessToken()` automatically refreshes expired tokens via the provider's `refreshToken()` method. ``` start ──► initiateOAuth ──► awaiting_auth ──► exchangeToken ──► end │ │ │ Builds auth URL, │ Validates CSRF state, │ saves OAuthPromptDocument │ exchanges code, │ with sign-in prompt │ stores tokens ▼ ▼ (waits for user to (callback resumes complete OAuth in browser) parent workflow) ``` Tools that need an access token call `OAuthTokenStore.getValidAccessToken(userId, provider)` and return `{ error: 'unauthorized' }` if no valid token exists, which lets the parent workflow guard branch into running `OAuthWorkflow`. ## Setup ```typescript import { Module } from '@nestjs/common'; import { GoogleWorkspaceModule } from '@loopstack/google-workspace-module'; @Module({ imports: [GoogleWorkspaceModule], providers: [MyWorkflow], exports: [MyWorkflow], }) export class MyModule {} ``` `GoogleWorkspaceModule` and `GitHubModule` import `OAuthModule` internally — you don't need to add it explicitly. For a custom provider, import `OAuthModule` directly alongside your provider module: ```typescript import { OAuthModule } from '@loopstack/oauth-module'; import { MyCustomOAuthModule } from './my-custom-oauth.module'; @Module({ imports: [OAuthModule, MyCustomOAuthModule], providers: [MyWorkflow], }) export class MyModule {} ``` `OAuthModule` is decorated with `@Global()`, so a single import makes the registry, token store, and OAuth tools available throughout your app. ## OAuth as Sub-Workflow The simplest approach: launch the built-in `OAuthWorkflow` when authentication is needed. ```typescript import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common'; import type { RunContext, TransitionInput } from '@loopstack/common'; import { MarkdownDocument } from '@loopstack/common'; import { OAuthWorkflow } from '@loopstack/oauth-module'; interface CalendarState { events?: CalendarEvent[]; requiresAuthentication?: boolean; } type CalendarArgs = { calendarId: string }; @Workflow({ widget: './calendar.ui.yaml' }) export class CalendarWorkflow extends BaseWorkflow { constructor( private readonly calendarFetchEvents: CalendarFetchEventsTool, private readonly oAuth: OAuthWorkflow, ) { super(); } @Transition({ to: 'calendar_fetched' }) async fetchEvents(state: CalendarState, ctx: RunContext) { const result = await this.calendarFetchEvents.call({ calendarId: ctx.args.calendarId, }); this.assignState({ 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) { await this.oAuth.run( { provider: 'google', scopes: ['https://www.googleapis.com/auth/calendar.readonly'] }, { callback: { transition: 'authCompleted' }, show: 'inline', label: 'Google authentication required' }, ); } needsAuth(state: CalendarState): boolean { return !!state.requiresAuthentication; } // After auth -> retry from start @Transition({ from: 'awaiting_auth', to: 'start', wait: true }) authCompleted(state: CalendarState, _input: TransitionInput) {} // Success -> display results @Transition({ from: 'calendar_fetched', to: 'end' }) async displayResults(state: CalendarState) { await this.documentStore.save(MarkdownDocument, { markdown: this.render(join(__dirname, 'templates', 'summary.md'), { events: state.events }), }); } } ``` ## Using Tokens in Custom Tools ```typescript import { z } from 'zod'; import { BaseTool, Tool, ToolEnvelope } from '@loopstack/common'; import type { RunContext } 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: RunContext): 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](../../extend/oauth-providers.md) for how to implement `OAuthProviderInterface` and register a custom provider. ## Environment Variables Each OAuth provider reads its own credentials from env, conventionally named `_CLIENT_ID`, `_CLIENT_SECRET`, and `_OAUTH_REDIRECT_URI`: ``` # Google Workspace GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... GOOGLE_OAUTH_REDIRECT_URI=... # GitHub GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... GITHUB_OAUTH_REDIRECT_URI=... ``` The token store also reads `REDIS_HOST`, `REDIS_PORT`, and `REDIS_PASSWORD` — see [Configuration Reference](../../reference/configuration.md) for defaults. Redis is optional; an in-memory fallback is used when unavailable. ## 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-oauth-examples) — 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-oauth-examples) — GitHub OAuth integration with repos overview and GitHub agent with 25+ tools --- > Source: https://loopstack.ai/llms/build/integrations/programmatic-execution.md --- title: Scheduling & Programmatic Execution description: Scheduling and programmatic workflow execution with WorkflowRunner — cron (@Cron), webhook endpoints, delayed runs (SchedulerRegistry), batch fan-out, and triggering from API requests or internal events. run vs runSync, appName/userId context. --- # Scheduling & Programmatic Execution 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 For a runnable version of every pattern below — cron, webhook, delayed run, and batch — see the [`@loopstack/scheduling-examples`](#registry-references) package. Each fundamental ships as a small workflow plus the real trigger that fires it, and you can drive the HTTP-triggered ones straight from Studio. ## 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, }, ); } } } ``` ### Delayed Run Run a workflow once after a delay — e.g. "follow up 24h after signup". Register a one-off timeout with NestJS's `SchedulerRegistry`, then trigger the workflow when it fires: ```typescript import { Injectable } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { randomUUID } from 'node:crypto'; import { WorkflowRunner } from '@loopstack/core'; @Injectable() export class SignupFollowupService { constructor( private readonly workflowRunner: WorkflowRunner, private readonly scheduler: SchedulerRegistry, ) {} scheduleFollowup(email: string, userId: string, delayMs = 24 * 60 * 60 * 1000) { const name = `signup-followup-${randomUUID()}`; const timeout = setTimeout(() => { this.scheduler.deleteTimeout(name); void this.workflowRunner.run(SignupFollowupWorkflow, { email }, { appName: 'onboarding', userId }); }, delayMs); this.scheduler.addTimeout(name, timeout); } } ``` `SchedulerRegistry` timeouts live in memory, so a server restart loses any pending run. For delays that must survive restarts, enqueue a [BullMQ delayed job](https://docs.bullmq.io/guide/jobs/delayed) instead. ### 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 }; } } ``` ## Registry References - [scheduling-examples](https://loopstack.ai/registry/loopstack-scheduling-examples) — Runnable examples of every scheduling fundamental: cron (`@Cron` + `WorkflowRunner.run`), webhook (`@Public @Post` controller), delayed run (`SchedulerRegistry` timeout), and batch (`Promise.all` fan-out). Includes Studio-launchable workflows that make the real HTTP call to each trigger endpoint. --- > 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. ## Prerequisites Docker must be installed and running on the host system before any sandbox tool is invoked. The sandbox tools shell out to the local Docker daemon to create, start, exec into, and remove containers — `sandbox_init` will fail at runtime if Docker isn't available. Any Docker image works — `imageName` is passed straight through to Docker, so anything you'd put after `docker pull` (`node:18`, `python:3.11-slim`, your own private image) is valid. Images are pulled automatically on first use, so the first init for an unfamiliar image may be slow. ## Host ↔ Container Filesystem `sandbox_init` takes two related paths: - `projectOutPath` — an absolute path on the **host** machine. - `rootPath` — a path **inside** the container (defaults to `workspace`). The host directory at `projectOutPath` is bind-mounted into the container at `/`. Anything the container writes under `/` shows up at `projectOutPath` on the host, and vice versa — it's the same set of files viewed from two sides. Use this to feed inputs into the sandbox, read outputs back out, and persist artifacts across container destroys. ## 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, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; import { SandboxCreateDirectory, SandboxDelete, SandboxReadFile, SandboxWriteFile, } from '@loopstack/sandbox-filesystem'; import { SandboxDestroy, SandboxInit } from '@loopstack/sandbox-tool'; interface SandboxState { containerId?: string; } const SandboxArgsSchema = z.object({ outputDir: z.string().default(process.cwd() + '/out') }); type SandboxArgs = z.infer; @Workflow({ widget: './sandbox.ui.yaml', schema: SandboxArgsSchema, }) export class SandboxWorkflow extends BaseWorkflow { 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: RunContext) { const result = await this.sandboxInit.call({ containerId: 'my-sandbox', imageName: 'node:18', containerName: 'sandbox-container', projectOutPath: ctx.args.outputDir, rootPath: 'workspace', }); this.assignState({ containerId: result.data.containerId }); } @Transition({ from: 'sandbox_ready', to: 'file_written' }) async writeFile(state: SandboxState) { 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, }); } @Transition({ from: 'file_written', to: 'file_read' }) async readFile(state: SandboxState) { await this.sandboxReadFile.call({ containerId: state.containerId!, path: '/workspace/src/hello.js', encoding: 'utf8', }); } @Transition({ from: 'file_read', to: 'end' }) async destroySandbox(state: SandboxState) { await this.sandboxDestroy.call({ containerId: state.containerId!, removeContainer: true, }); } } ``` ## 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-filesystem-examples#sandbox) — 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 persisted in the database via `SecretEntity`, scoped per workspace. Values are never exposed to the LLM — only key names and availability flags (`GetSecretKeysTool`) are returned to workflow code. ### Providing Secrets to Remote Environments When a workflow runs commands on a remote sandbox or Fly.io machine via `@loopstack/remote-client-module`, secrets must reach that environment to be useful. The `SyncSecretsTool` (`sync_secrets`) reads all workspace secrets, ships them to the remote agent over its authenticated control channel, and writes them as `.env` variables before restarting the app. Values stay on the server side of the control channel and are not surfaced to the LLM. Call `sync_secrets` before launching long-running commands or whenever a secret changes: ```typescript constructor(private readonly syncSecrets: SyncSecretsTool) { super(); } @Transition({ from: 'secrets_received', to: 'ready' }) async pushSecrets(state: SecretsState) { await this.syncSecrets.call({}); } ``` ## 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, 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: './secrets-example.ui.yaml' }) export class SecretsExampleWorkflow extends BaseWorkflow { constructor( private readonly requestSecrets: RequestSecretsTool, private readonly getSecretKeys: GetSecretKeysTool, ) { super(); } @Transition({ to: 'requesting_secrets' }) async requestSecretsFromUser(state: SecretsState) { 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' }], }); } @Transition({ from: 'requesting_secrets', to: 'verifying', wait: true }) async secretsSubmitted(state: SecretsState) { const result = await this.getSecretKeys.call({}); this.assignState({ secretKeys: result.data }); } @Transition({ from: 'verifying', to: 'end' }) async showResult(state: SecretsState) { await this.documentStore.save(MarkdownDocument, { markdown: this.render(join(__dirname, 'templates', 'secretsVerified.md'), { secretKeys: state.secretKeys, }), }); } } ``` ## 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-examples) — 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') routeHigh(state: MyState) {} @Transition({ from: 'check', to: 'low' }) routeLow(state: MyState) {} // 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 { RunContext } from '@loopstack/common'; import { MessageDocument } from '@loopstack/common'; interface RoutingState { value?: number; } const RoutingSchema = z.object({ value: z.number().default(150) }).strict(); type RoutingArgs = z.infer; @Workflow({ schema: RoutingSchema, }) export class DynamicRoutingExampleWorkflow extends BaseWorkflow { @Transition({ to: 'prepared' }) async createMockData(state: RoutingState, ctx: RunContext) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Analyzing value = ${ctx.args.value}`, }); this.assignState({ value: ctx.args.value }); } // First fork: value > 100? @Transition({ from: 'prepared', to: 'placeA', priority: 10 }) @Guard('isAbove100') routeToPlaceA(state: RoutingState) {} @Transition({ from: 'prepared', to: 'placeB' }) routeToPlaceB(state: RoutingState) {} // 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') routeToPlaceC(state: RoutingState) {} @Transition({ from: 'placeA', to: 'placeD' }) routeToPlaceD(state: RoutingState) {} // Fallback: 100 < value <= 200 isAbove200(state: RoutingState): boolean { return (state.value ?? 0) > 200; } // Terminal transitions @Transition({ from: 'placeB', to: 'end' }) async showMessagePlaceB(state: RoutingState) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: 'Value is less or equal 100' }); } @Transition({ from: 'placeC', to: 'end' }) async showMessagePlaceC(state: RoutingState) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: 'Value is greater than 200' }); } @Transition({ from: 'placeD', to: 'end' }) async showMessagePlaceD(state: RoutingState) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: 'Value is less or equal 200, but greater than 100', }); } } ``` ## 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](../ai/tool-calling.md)): ```typescript @Transition({ from: 'prompt_executed', to: 'ready', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: MyState) { ... } // Run tools, loop back @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: MyState) { ... } // Fallback: no tool calls hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } ``` For async tools that complete via callback (sub-workflows, HITL), insert an `awaiting_tools` intermediate state with a `wait: true` re-entry transition — see [Agent Workflows](../ai/agent-workflows.md). ### 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) { ... } @Transition({ from: 'fetched', to: 'end' }) async displayResults(state: MyState) { ... } 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-advanced-workflows-examples#dynamic-routing) — Multi-level guard-based routing with cascading forks - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-agent-examples#custom-agent) — 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, retryTarget for retry-via-another-place, errorPlace routing for sync throws and sub-workflow failure callbacks, manual retry via Studio UI, and transition timeouts. --- # Error Handling, Retry & Timeout When a transition fails — by throwing, timing out, or receiving a `failed` / `canceled` callback from a sub-workflow — the framework rolls back local changes and then runs a single decision tree: 1. **Auto-retry** if `retryAttempts` has budget left. 2. **Route to `errorPlace`** if declared (retries exhausted, or no retries configured). 3. **Manual retry (default)** — stay at the current place and surface a Retry button in the UI. The same rule applies to sync throws, timeouts, and sub-workflow failure callbacks — there's one mental model. ## Retry Modes ### Auto-Retry Automatically re-run a failed transition with exponential backoff: ```typescript @Transition({ from: 'fetching', to: 'done', retryAttempts: 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. ### Retry via a Different Place (`retryTarget`) Some retries need preparation work between attempts — refresh a token, invalidate a cache, log out and back in. `retryTarget` moves the workflow to a different place on each auto-retry, instead of re-running the failing transition directly: ```typescript @Transition({ from: 'fetching', to: 'done', retryAttempts: 3, retryTarget: 'refresh_credentials', }) async callApi() { await this.api.call({ ... }); } @Transition({ from: 'refresh_credentials', to: 'fetching' }) async refreshCredentials() { await this.auth.refreshToken(); } ``` On failure, `callApi`'s retry budget is decremented, the workflow jumps to `refresh_credentials`, the refresh transition runs, and the workflow loops back to `fetching` to re-attempt `callApi`. Transitions inside the retry target have their own independent retry budget — a failure in `refreshCredentials` doesn't consume `callApi`'s attempts. ### Custom Error Place (`errorPlace`) Route to a dedicated error state when a transition fails: ```typescript @Transition({ from: 'processing', to: 'done', errorPlace: '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', text: '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). When `errorPlace` is set without `retryAttempts`, attempts default to `0` — failures route straight to the error place on the first try. ### Manual Retry (Default) When no failure handling 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', retryAttempts: 2, errorPlace: '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. ## Sub-Workflow Failure Callbacks A wait transition that resumes from a sub-workflow callback can fail just like a synchronous transition — when the child finishes with `status: failed` or `canceled`. The same `retryAttempts` / `retryTarget` / `errorPlace` rules apply: ```typescript @Transition({ from: 'awaiting_child', to: 'done', wait: true, errorPlace: 'sub_failed' }) async childCompleted(_state, _input) { // Happy path only — the child succeeded. } @Transition({ from: 'sub_failed', to: 'done', wait: true }) async handleSubFailed() { // Recovery when the child failed — surfaces a Recover button. } ``` Without `errorPlace` (or `retryAttempts`), a sub-workflow failure callback still fires the body — for accumulator patterns where the body itself handles error results (e.g. LLM tool delegation). With either declared, the framework treats the failure as the wait transition failing and skips the body entirely. That protects schema-validated bodies from receiving `null` / malformed data when the child never reached `setResult(...)`. ## Failure-Handling Configuration ```typescript retryAttempts?: number // -1 = unlimited manual retry (default). // 0 = no auto-retry (default when errorPlace is set). // N>0 = up to N auto-retries. retryDelay?: number // Base delay in ms (default: 1000). retryBackoff?: 'fixed' | 'exponential' // (default: 'exponential') retryMaxDelay?: number // Backoff cap in ms (default: 30000). retryTarget?: string // Re-enter this place on each auto-retry, instead of re-running this transition. errorPlace?: string // Where to route when retries are exhausted (or no retry is configured). ``` **Backoff calculation (exponential):** `retryDelay * 2^(attempt - 1)`, capped at `retryMaxDelay`. | Attempt | Delay (default config) | | ------- | ---------------------- | | 1 | 1,000ms | | 2 | 2,000ms | | 3 | 4,000ms | | 4 | 8,000ms | | 5 | 16,000ms | | 6+ | 30,000ms (capped) | ### Reading the retry count Transitions can access the current retry count through `ctx.execution.retryCount`. It is 0-indexed: `0` on the first attempt, `1` after the first retry, and so on. Add `1` for a human-friendly attempt number when logging or branching on retries. ```typescript @Transition({ from: 'fetching', to: 'done', retryAttempts: 3 }) async fetchData(state: MyState, ctx: RunContext) { const attempt = (ctx.execution?.retryCount ?? 0) + 1; this.logger.log(`Fetch attempt ${attempt}`); // ... } ``` `ctx.execution` is optional in the type — guard with `?.` or `!` depending on your call site. `ctx.execution.place` is also available for the current place name. ## 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, retryAttempts: 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-advanced-workflows-examples#error-retry) — Demonstrates all seven retry modes: auto-retry, manual retry, custom error place, timeout, hybrid, `retryTarget`, and sub-workflow failure callback routed via `errorPlace`. --- > 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. Covers wait:true transitions on custom documents, AskUserWorkflow / ConfirmUserWorkflow sub-workflow shortcuts, and LLM-agent HITL via the ask_clarification and ask_for_approval tools. --- # Human-in-the-Loop Pause workflows for user input, review, or confirmation. Loopstack offers three distinct patterns — pick the one that matches who decides what to ask. ## Choosing a HITL Pattern | You are building... | Use | Where it lives | | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | A predefined workflow with known user-input steps — form fields, structured records | **Custom document with a widget** — your workflow owns the document + `wait: true` | This page → [Wait Transition Pattern](#wait-transition-pattern) | | A predefined workflow that just needs a quick generic ask (free text, yes/no, pick one) | **`AskUserWorkflow` / `ConfirmUserWorkflow`** as a sub-workflow shortcut | This page → [Using HITL as a Sub-Workflow](#using-hitl-as-a-sub-workflow) | | An LLM agent loop where the agent dynamically decides when to ask | **Agent tools** `ask_clarification` / `ask_for_approval` | This page → [Agent-Driven HITL](#agent-driven-hitl) | The custom-document pattern is the **default for predefined workflows**: the form _is_ the structured data, the wait-transition payload schema _is_ the document schema, and types line up end-to-end. Sub-workflow shortcuts are for when designing a form would be overkill. Agent tools are for LLM-driven flows where the next question isn't known in advance. ## Wait Transition Pattern A transition with `wait: true` pauses the workflow until externally triggered by user interaction: ```typescript import type { TransitionInput } from '@loopstack/common'; @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.object({ message: z.string() }), }) async userMessage(state: Record, input: TransitionInput<{ message: string }>) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: input.data.message, }); } ``` ## 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, input: TransitionInput) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: input.data, }); } ``` ## 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 { RunContext } from '@loopstack/common'; import { LlmGenerateObjectTool } from '@loopstack/llm-provider-module'; interface MeetingNotesState { meetingNotes?: z.infer; } const MeetingNotesArgsSchema = z.object({ inputText: z.string().default('...') }); type MeetingNotesArgs = z.infer; @Workflow({ widget: './meeting-notes.ui.yaml', schema: MeetingNotesArgsSchema, }) export class MeetingNotesWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateObject: LlmGenerateObjectTool) { super(); } @Transition({ to: 'waiting_for_response' }) async createForm(state: MeetingNotesState, ctx: RunContext) { await this.documentStore.save(MeetingNotesDocument, { text: ctx.args.inputText }, { key: 'input' }); } // Wait for user to edit and submit @Transition({ from: 'waiting_for_response', to: 'response_received', wait: true, schema: MeetingNotesDocumentSchema }) async userResponse(state: MeetingNotesState, input: TransitionInput>) { const result = await this.documentStore.save(MeetingNotesDocument, input.data, { key: 'input' }); this.assignState({ meetingNotes: result.content as z.infer }); } // AI generates structured output @Transition({ from: 'response_received', to: 'notes_optimized' }) async optimizeNotes(state: MeetingNotesState) { 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, { key: 'final', validate: 'skip' }, ); } // Wait for user to confirm @Transition({ from: 'notes_optimized', to: 'end', wait: true, schema: OptimizedMeetingNotesDocumentSchema }) async confirm(state: MeetingNotesState, input: TransitionInput>) { await this.documentStore.save(OptimizedNotesDocument, input.data, { key: 'final' }); } } ``` ## `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. ## Using HITL as a Sub-Workflow The `wait: true` pattern above is for workflows that own their own UI. For generic prompts you don't want to design a form for, run `AskUserWorkflow` or `ConfirmUserWorkflow` from `@loopstack/hitl` as a sub-workflow and receive the answer through a callback. The callback delivers the standard `TransitionInput` envelope — the `schema` on the transition describes only `data`, and `input.data` is fully typed. See [Sub-Workflows → Typing the Callback Envelope](./sub-workflows.md#typing-the-callback-envelope) for the full reference. ### `AskUserWorkflow` — free text ```typescript import { z } from 'zod'; import { BaseWorkflow, MessageDocument, Transition, type TransitionInput, Workflow } from '@loopstack/common'; import { AskUserWorkflow } from '@loopstack/hitl'; const AnswerSchema = z.object({ answer: z.string() }); @Workflow({ title: 'Ask Then Continue' }) export class AskThenContinueWorkflow extends BaseWorkflow { constructor(private readonly askUser: AskUserWorkflow) { super(); } @Transition({ to: 'waiting' }) async ask(state: Record) { await this.askUser.run({ question: 'What is your name?' }, { callback: { transition: 'onAnswer' } }); } @Transition({ from: 'waiting', to: 'end', wait: true, schema: AnswerSchema }) onAnswer(state: Record, input: TransitionInput<{ answer: string }>) { this.setResult({ name: input.data.answer }); } } ``` ### `AskUserWorkflow` — pick from options Pass `mode: 'options'` with a list of choices. `allowCustomAnswer: true` adds a free-text field alongside the choices for "other". ```typescript await this.askUser.run( { question: 'Which environment should we deploy to?', mode: 'options', options: ['staging', 'production'], allowCustomAnswer: true, }, { callback: { transition: 'choiceReceived' } }, ); ``` The envelope shape is the same as the free-text case (`input.data: { answer: string }`). ### `AskUserWorkflow` — yes / no Pass `mode: 'confirm'`. The answer comes back as the literal string `'yes'` or `'no'` in `input.data.answer` — compare directly. ```typescript await this.askUser.run( { question: 'Send the email now?', mode: 'confirm' }, { callback: { transition: 'decisionReceived' } }, ); ``` ### `ConfirmUserWorkflow` — markdown review For showing a pre-rendered markdown blob (a release plan, a summary, a code diff) and receiving an explicit approve/deny, use `ConfirmUserWorkflow`. The callback `input.data` carries both the user's decision and the original markdown: ```typescript import { ConfirmUserWorkflow } from '@loopstack/hitl'; const ConfirmSchema = z.object({ confirmed: z.boolean(), markdown: z.string() }); @Transition({ to: 'awaiting' }) async showSummary(state: Record) { await this.confirmUser.run( { markdown: '## Ready to deploy v1.2.3?\n\n- 3 commits since last release\n- Smoke tests passing' }, { callback: { transition: 'decisionReceived' } }, ); } @Transition({ from: 'awaiting', to: 'end', wait: true, schema: ConfirmSchema }) decisionReceived( state: Record, input: TransitionInput>, ) { this.setResult({ confirmed: input.data.confirmed }); } ``` ## Agent-Driven HITL When the asking party is an LLM agent rather than your workflow, use the `ask_clarification` and `ask_for_approval` tools from `@loopstack/hitl`. The agent decides at runtime to call the tool; the agent loop pauses, the user answers, and the answer flows back as the tool result — no extra wait-transition wiring at your level. ### `ask_clarification` — agent asks the user a question Register the tool in your module and add it to the agent's tool list. A system prompt that tells the agent to use the tool when info is missing is enough: ```typescript import { z } from 'zod'; import { AgentWorkflow } from '@loopstack/agent'; import { BaseWorkflow, MessageDocument, Transition, type TransitionInput, Workflow } from '@loopstack/common'; const AgentResponseSchema = z.object({ response: z.string() }); const SYSTEM_PROMPT = `You are a trip-planning assistant. - Before recommending a destination, you MUST know BOTH the user's budget AND climate preference. - If either is missing, your response MUST be exactly ONE tool call to "ask_clarification".`; @Workflow({ title: 'Trip Planner' }) export class TripPlannerWorkflow extends BaseWorkflow { constructor(private readonly agent: AgentWorkflow) { super(); } @Transition({ to: 'running' }) async start(state: Record) { await this.agent.run( { system: SYSTEM_PROMPT, tools: ['ask_clarification'], userMessage: 'Where should I go on holiday next month?', }, { callback: { transition: 'onComplete' } }, ); } @Transition({ from: 'running', to: 'end', wait: true, schema: AgentResponseSchema }) async onComplete(state: Record, input: TransitionInput<{ response: string }>) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: input.data.response }); this.setResult({ response: input.data.response }); } } ``` `ask_clarification` supports the same `mode` arg as `AskUserWorkflow` (`'text'` / `'options'` / `'confirm'`) — the LLM can pick the right mode per call. ### `ask_for_approval` — agent asks the user to approve content Same shape, with `tools: ['ask_for_approval']`. The agent drafts content and passes it as the `concept` argument; the user sees the markdown and confirms or denies. The agent loop only resumes after the decision. ```typescript const SYSTEM_PROMPT = `You are a release-notes drafting assistant. - Your response MUST be exactly ONE tool call to "ask_for_approval". - The "concept" argument IS the markdown draft.`; await this.agent.run( { system: SYSTEM_PROMPT, tools: ['ask_for_approval'], userMessage: 'Draft release notes for v1.2.3.', }, { callback: { transition: 'onComplete' } }, ); ``` See [`@loopstack/hitl`](https://loopstack.ai/registry/loopstack-hitl-module) for the full tool args reference. ## Registry References - [hitl-example-module](https://loopstack.ai/registry/loopstack-hitl-examples) — Side-by-side examples of every HITL pattern: custom document with widget, all `AskUserWorkflow` modes, `ConfirmUserWorkflow`, and both agent tools - [@loopstack/hitl](https://loopstack.ai/registry/loopstack-hitl-module) — The underlying HITL module: `AskUserWorkflow`, `ConfirmUserWorkflow`, `ask_clarification`, `ask_for_approval` - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-hitl-examples#meeting-notes) — Full human-in-the-loop workflow with editable form, AI optimization, and user confirmation - [chat-example-workflow](https://loopstack.ai/registry/loopstack-hitl-examples#prompt-input-chat) — 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, per-transition state typing, state persistence across transitions, and state access patterns. --- # State Management Workflow state is managed through a typed state interface passed as the first parameter to transition methods. Transitions return nothing — they write through setter methods on `BaseWorkflow`, and the engine persists the updated state automatically across transitions. Use `async` when the body awaits. ## Defining State Define a state interface and reference it on each transition's `state` parameter: ```typescript interface MyState { counter?: number; llmResult?: LlmGenerateTextResult; items?: string[]; } export class MyWorkflow extends BaseWorkflow { // State is typed per-transition via the `state` parameter — see below. } ``` State begins as an empty object `{}` — the initial transition is responsible for populating it. For this reason, **all properties on a state schema should be optional**. If a property is required, the empty starting state will fail validation immediately. Treat missing fields as the absence of data and read them defensively (`state.counter ?? 0`). ## Writing State `BaseWorkflow` exposes four setters. Pick the one that matches the write you want: | Setter | Effect | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `this.assignState(partial)` | Shallow-merge `partial` into the current state. The most common form — leaves any field you don't mention untouched. | | `this.setState(full)` | Replace the state object outright. Use when you want to reset state or know you're writing every field. | | `this.assignResult(partial)` | Shallow-merge `partial` into the workflow's published `result` (the value parent callbacks and `WorkflowRunner` callers see). | | `this.setResult(full)` | Replace the published `result` outright. | Transitions return nothing — mutate state via setters. Returning a value is a runtime error. ```typescript @Transition({ from: 'ready', to: 'processed' }) async process(state: MyState) { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); this.assignState({ llmResult: result.data, counter: (state.counter ?? 0) + 1, items: [...(state.items ?? []), 'new item'], }); } ``` A transition that doesn't write state simply omits the setter call: ```typescript @Transition({ from: 'ready', to: 'logged' }) log(state: MyState) { this.logger.log(`counter = ${state.counter}`); } ``` If `stateSchema` is defined on the workflow, the merged state is validated after every write. ## Reading State Access state in any transition or guard method: ```typescript @Transition({ from: 'processed', to: 'end' }) async display(state: MyState) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Processed ${state.counter} items. Result: ${state.llmResult?.text}`, }); } hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } ``` ## Publishing a Result The workflow's `result` field is what `WorkflowRunner.runSync()` returns and what parent callbacks receive as `input.data`. Write to it on the final transition (or any earlier transition that wants to surface partial output): ```typescript @Transition({ from: 'done', to: 'end' }) finish(state: MyState) { this.setResult({ concept: state.confirmedConcept! }); } ``` Use `this.assignResult(partial)` to build the result up across multiple transitions, and `this.setResult(full)` to replace it. ## Persistence Across Pauses State survives when a workflow pauses at a `wait: true` transition and resumes later: ```typescript @Transition({ to: 'waiting' }) setup(state: MyState) { this.assignState({ counter: 42 }); // Set before pause } @Transition({ from: 'waiting', to: 'end', wait: true }) onResume(state: MyState) { // state.counter is still 42 } ``` ## Accessing Workflow Args Input arguments are available via `ctx.args`: ```typescript const MyArgsSchema = z.object({ value: z.number().default(150) }); type MyArgs = z.infer; @Workflow({ schema: MyArgsSchema, }) export class MyWorkflow extends BaseWorkflow { @Transition({ to: 'ready' }) setup(state: MyState, ctx: RunContext) { console.log(ctx.args.value); // 150 } } ``` ## Helper Methods Use regular private methods for reusable logic — no special decorator needed: ```typescript export class MyWorkflow extends BaseWorkflow { @Transition({ from: 'data_created', to: 'end' }) async showResults(state: MyState) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: this.formatMessage(state.message!), }); } private formatMessage(text: string): string { return text.toUpperCase(); } } ``` ## Registry References - [workflow-state-example-workflow](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#workflow-state) — Stores state in typed state interface, accesses in transitions, uses helper methods - [accessing-tool-results-example-workflow](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#workflow-state) — 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(), the show option ('inline' | 'link' | 'hidden') for parent-view rendering, callback transitions, typing the callback envelope via TransitionInput, handling sub-workflow failures via input.hasError / input.errorMessage without try/catch, passing arguments to child workflows, receiving sub-workflow results, and coordinating multiple sub-workflows via FanOutWorkflow (parallel) and SequenceWorkflow (sequential) with 'all' / 'allSettled' failure modes. --- # 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 { QueueResult } from '@loopstack/common'; constructor(private readonly subWorkflow: SubWorkflow) { super(); } ``` ## Running a Sub-Workflow ```typescript @Transition({ to: 'sub_started' }) async start(state: MyState) { await this.subWorkflow.run( { prompt: 'Hello' }, // Args passed to the sub-workflow { callback: { transition: 'onSubComplete' } }, // Method to call when done ); } ``` The parent's run view automatically renders the child sub-workflow inline by default — there is no extra `documentStore.save(LinkDocument, …)` step. ## Controlling How the Child Appears: `show` The `show` option on `RunOptions` controls how the child sub-workflow is rendered inside the parent's run view: | `show` | What the parent sees | Use for | | ---------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `'inline'` _(default)_ | The child is embedded as an inline iframe; the user can interact with it in place. | HITL prompts, OAuth flows, agents whose progress you want visible. | | `'link'` | A status link card with the child's label and live status — opens the child in a separate window when clicked. | Long-running autonomous children the parent just tracks. | | `'hidden'` | Nothing is shown. | Background fan-out where surfacing each child would be noise. | ```typescript await this.askUser.run(args, { callback: { transition: 'answered' }, show: 'inline', // (default) embed the child UI in the parent's view label: 'Waiting for user answer', // optional — defaults to the child workflow's name }); await this.longJob.run(args, { callback: { transition: 'done' }, show: 'link', // status card, opens child in a separate window }); await this.background.run(args, { show: 'hidden', // no card at all }); ``` The link card's status is read live from the child workflow's actual state — it transitions from pending to success or failure automatically as the child runs. ## Receiving the Callback The sub-workflow's published `result` (built via `assignResult` / `setResult`) is passed as `input.data`: ```typescript import type { TransitionInput } from '@loopstack/common'; @Transition({ from: 'sub_started', to: 'sub_done', wait: true, schema: z.object({ message: z.string() }), }) async onSubComplete( state: MyState, input: TransitionInput<{ message: string }>, ) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Sub-workflow said: ${input.data.message}`, }); } ``` ## Typing the Callback Envelope Every `wait: true` transition receives a `TransitionInput` envelope — the same shape whether the resume came from a sub-workflow completion or a frontend / API trigger: ```typescript interface TransitionInput { workflowId: string; status: 'completed' | 'failed' | 'canceled'; hasError: boolean; errorMessage: string | null; data: TData; meta?: TMeta; } ``` | Field | Type | What it is | | -------------- | ---------------- | ---------------------------------------------------------------------------------------------------------- | | `workflowId` | `string` | ID of the run that produced this resume (the child for sub-workflow callbacks, the parent for user input). | | `status` | enum | `'completed'` / `'failed'` / `'canceled'`. | | `hasError` | `boolean` | `true` if the trigger source ended in failure — branch on this, not on `status`. | | `errorMessage` | `string \| null` | Error message if `hasError`, otherwise `null`. | | `data` | `TData` | The validated payload (the child's published `result`, or the user's form data). | | `meta` | `TMeta?` | Optional correlation metadata passed via `callback.metadata`. Undefined for user-driven resumes. | The `schema` on `@Transition({ wait: true })` describes **only `data`** — the framework constructs the surrounding envelope. Type the parameter via `TransitionInput`: ```typescript import type { TransitionInput } from '@loopstack/common'; import { z } from 'zod'; const AnswerSchema = z.object({ answer: z.string() }); @Transition({ from: 'awaiting', to: 'end', wait: true, schema: AnswerSchema, }) onAnswer(state: MyState, input: TransitionInput<{ answer: string }>) { // input.workflowId, input.status, input.hasError available at the top level // input.data.answer is fully typed against AnswerSchema this.setResult({ answer: input.data.answer }); } ``` ## Error Handling When a sub-workflow throws, the failure does **not** bubble up through `run()` — `run()` only schedules the child. Instead, the parent's callback transition still fires, with `hasError: true` and `errorMessage` populated. The parent branches on `input.hasError`: ```typescript import { BaseWorkflow, MessageDocument, Transition, type TransitionInput, Workflow } from '@loopstack/common'; @Workflow({ title: 'Recovers from a Failing Child' }) export class RecoveringParentWorkflow extends BaseWorkflow { constructor(private readonly failingSub: FailingSubWorkflow) { super(); } @Transition({ to: 'awaiting' }) async launch(state: Record) { await this.failingSub.run({}, { callback: { transition: 'onFinished' }, show: 'link', label: 'Failing child' }); } @Transition({ from: 'awaiting', to: 'end', wait: true }) async onFinished(state: Record, input: TransitionInput) { if (input.hasError) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Child failed: ${input.errorMessage ?? 'unknown error'} — continuing with a fallback.`, }); this.setResult({ recovered: true }); return; } this.setResult({ recovered: false }); } } ``` Two things to note: - **No try/catch at the parent.** The child's exception is captured by the framework, persisted on the child run, and surfaced through the envelope. The parent only sees `hasError`. - **The link card / inline iframe turns red automatically.** No extra UI wiring is needed to reflect the failure in the parent's run view. For `FanOutWorkflow` and `SequenceWorkflow` the same idea applies one level deeper: each item's per-result entry carries its own `hasError`, and the aggregate `input.data` exposes `hasErrors` and `errorCount`. ## Sub-Workflow Output The sub-workflow defines its output by writing to the run's `result` field via `this.assignResult(...)` or `this.setResult(...)`: ```typescript @Workflow({ widget: './sub.ui.yaml' }) export class SubWorkflow extends BaseWorkflow { @Transition({ to: 'end' }) start() { this.setResult({ message: 'Hi mom!' }); } } ``` ## Complete Example ```typescript @Workflow({ widget: './parent.ui.yaml' }) export class ParentWorkflow extends BaseWorkflow { constructor(private readonly subWorkflow: SubWorkflow) { super(); } @Transition({ to: 'sub_started' }) async runWorkflow(state: Record) { await this.subWorkflow.run( {}, { callback: { transition: 'subWorkflowCallback' }, show: 'link', label: 'Sub-Workflow' }, ); } @Transition({ from: 'sub_started', to: 'end', wait: true, schema: z.object({ message: z.string() }), }) async subWorkflowCallback(state: Record, input: TransitionInput<{ message: string }>) { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Message from sub-workflow: ${input.data.message}`, }); } } ``` ## Running Sub-Workflows in Parallel To launch multiple sub-workflows at the same time and receive a single aggregated callback when they all complete, use the built-in `FanOutWorkflow` from `@loopstack/core`. Inject it like any other sub-workflow. ```typescript import { z } from 'zod'; import { type TransitionInput } from '@loopstack/common'; import { FanOutResultSchema, FanOutWorkflow } from '@loopstack/core'; type FanOutResultData = z.infer; @Workflow({ title: 'Parallel Fan-Out' }) export class ParallelWorkflow extends BaseWorkflow { constructor(private readonly fanOut: FanOutWorkflow) { super(); } @Transition({ to: 'awaiting' }) async launch(state: Record) { await this.fanOut.run( { items: { user: { workflow: 'fetch_user', args: { id: 1 } }, orders: { workflow: 'fetch_orders', args: { id: 1 } }, }, }, { callback: { transition: 'onAllDone' } }, ); } @Transition({ from: 'awaiting', to: 'end', wait: true, schema: FanOutResultSchema }) onAllDone(state: Record, input: TransitionInput) { const user = (input.data.results as Record).user.data; const orders = (input.data.results as Record).orders.data; this.setResult({ user, orders }); } } ``` `items` accepts either a **keyed record** (results addressable by name) or an **array** of `{ workflow, args, label?, show? }` (results returned in input order with each entry's `key`). The `workflow` field is the canonical name string — either the explicit `@Workflow({ name })` value or the auto-derived snake_case form (e.g. `FetchUserWorkflow` → `'fetch_user'`). ### Failure modes | `mode` | Behavior | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'all'` _(default)_ | First failure triggers `cancelChildren` on the parent; the callback fires once every in-flight child has settled (canceled siblings also send callbacks). `input.data.hasErrors` is `true`. | | `'allSettled'` | Every child runs to completion regardless of siblings; the callback aggregates `completed` / `failed` results for every item. | ## Running Sub-Workflows in Sequence To run sub-workflows one after another with a single aggregated callback at the end, use `SequenceWorkflow`. Same call shape as `FanOutWorkflow`, same `'all'` / `'allSettled'` modes. ```typescript import { type TransitionInput } from '@loopstack/common'; import { SequenceResultSchema, SequenceWorkflow } from '@loopstack/core'; import { z } from 'zod'; type SequenceResultData = z.infer; @Workflow({ title: 'Sequential' }) export class SequentialWorkflow extends BaseWorkflow { constructor(private readonly sequence: SequenceWorkflow) { super(); } @Transition({ to: 'awaiting' }) async launch(state: Record) { await this.sequence.run( { items: [ { workflow: 'step_a', args: { ... }, label: 'step-1' }, { workflow: 'step_b', args: { ... }, label: 'step-2' }, { workflow: 'step_c', args: { ... }, label: 'step-3' }, ], }, { callback: { transition: 'onComplete' } }, ); } @Transition({ from: 'awaiting', to: 'end', wait: true, schema: SequenceResultSchema }) onComplete(state: Record, input: TransitionInput) { this.setResult({ results: input.data.results }); } } ``` In `mode: 'all'`, a failure aborts the sequence and remaining items are marked `'skipped'` in the result. In `mode: 'allSettled'`, the sequence continues past failures. `FanOutWorkflow` and `SequenceWorkflow` are auto-registered by `LoopstackModule.forRoot()` — there is no manual `providers: [...]` entry needed for them. You still register your own sub-workflows normally. ## 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: RunContext, options?: ToolCallOptions, ): Promise { const result = await this.testRunner.run( { testDirectory: args.testDirectory }, { callback: options?.callback, show: 'inline', label: 'Running tests...' }, ); return { data: { workflowId: result.workflowId }, pending: { workflowId: result.workflowId }, }; } async complete(result: Record): Promise { const data = result as { data?: { passed: boolean; output: string } }; 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 - **`show`** decides how the child appears in the parent's run view (`'inline'` by default) - **`complete()`** is called when the sub-workflow finishes — transform results and return the tool's final value here ## Nested Agents The sub-workflow can be an `AgentWorkflow` itself, enabling multi-agent architectures. See [Agent Workflows](../ai/agent-workflows.md) for the full pattern. ## Registry References - [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-advanced-workflows-examples#sub-workflow) — Parent calling sub-workflows with callbacks and typed output, all three `show` modes chained, `FanOutWorkflow` / `SequenceWorkflow` coordination, and a failing-child workflow paired with a parent that branches on `input.hasError` - [@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 import { join } from 'node:path'; const rendered = this.render(join(__dirname, 'templates', 'prompt.md'), { subject: args.subject, items: state.items, }); ``` `this.render()` expects an absolute path. Build it with `path.join(__dirname, ...)` so templates resolve relative to the workflow's compiled `.js` file at runtime (where the `templates/` folder is copied during build). 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 — typed via RunContext<{ subject: string }>) this.render(templatePath, { subject: ctx.args.subject }); // Workflow state (from state parameter) this.render(templatePath, { items: state.items, count: state.counter }); // Mixed data this.render(templatePath, { prompt: ctx.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-llm-examples#prompt) — Uses `this.render()` for Handlebars prompt templates - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-hitl-examples#meeting-notes) — 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:** - `widget:` paths on `@Workflow` / `@Tool` / `@Document` are resolved relative to the file containing the decorator. Use a `./` or `../` prefix: ```typescript @Document({ widget: './my-document.yaml', }) ``` - `this.render(...)` takes an absolute path. Use `path.join(__dirname, ...)`: ```typescript this.render(join(__dirname, 'templates', 'prompt.md')); ``` Not a hardcoded path like `'src/my-feature/my-document.yaml'` — those resolve from the process's `cwd`, not the file's directory. --- ## 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 }) confirm(state: MyState, input: TransitionInput) { // ↑ 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/custom-bootstrap.md --- title: Custom Bootstrap description: Advanced — replace LoopstackModule.forRoot() by wiring its underlying modules (ConfigModule, TypeOrmModule, EventEmitterModule, LoopCoreModule, LoopstackApiModule) yourself for granular control over database, config, and Nest bootstrap. --- # Custom Bootstrap `LoopstackModule.forRoot()` is the supported way to bootstrap a Loopstack app — it wires up config loading, TypeORM, the event emitter, the core engine, and the REST API in one call. For most apps you should use it as-is. If you need finer-grained control — for example to reuse an existing TypeORM connection that has special pool settings, register a different `ConfigModule`, or co-locate Loopstack with another Nest framework — you can skip `LoopstackModule` and import its underlying modules directly. This is an advanced setup and you become responsible for reproducing the same wiring. ## What `LoopstackModule.forRoot()` Does It registers these modules with sensible defaults: - `ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', load: [...] })` - `TypeOrmModule.forRoot({ type: 'postgres', autoLoadEntities: true, synchronize: true, ... })` — skipped when `database.connection` points to an existing connection - `EventEmitterModule.forRoot()` - `LoopCoreModule.forRoot({ connection, redis })` — the workflow engine - `LoopstackApiModule.register({ connection, cors })` — the REST API and controllers See `loopstack/packages/loopstack-module/src/loopstack.module.ts` for the exact wiring. ## Bootstrapping Manually To replace `LoopstackModule.forRoot()` with a custom setup, import these modules yourself: ```typescript import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule } from '@nestjs/typeorm'; import { LoopstackApiModule } from '@loopstack/api'; import { LoopCoreModule } from '@loopstack/core'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true /* your own config setup */ }), TypeOrmModule.forRoot({ /* your own database setup, must point at PostgreSQL */ }), EventEmitterModule.forRoot(), LoopCoreModule.forRoot({ connection: undefined, redis: { /* ... */ }, }), LoopstackApiModule.register({ cors: { origin: true, credentials: true } }), ], }) export class AppModule {} ``` The app and auth configs that `LoopstackModule` loads (`app`, `auth` namespaces with the keys documented in [Configuration](../reference/configuration.md)) must also be provided — feed them in via your own `ConfigModule.forRoot({ load: [...] })`. If you skip `LoopCoreModule.forRoot()` or `LoopstackApiModule.register()` your app will be missing the workflow engine or the REST API, respectively. --- > Source: https://loopstack.ai/llms/extend/features.md --- title: Studio Features description: How Loopstack features (Git, File Explorer, Secrets, etc.) are registered, discovered, and surfaced in Studio. Covers the registerFeature() helper, forFeature() module pattern, and the backend → frontend feature flow. --- # Studio Features A **feature** in Loopstack is an optional capability that a module opts into and that the Studio UI can render a dedicated surface for — typically a sidebar panel or a document widget. Built-in examples are the `git`, `fileExplorer`, and `secrets` features. Features are an advanced extension point: most apps never need to create one. ## When to Use a Feature Use a feature when a module wants Studio to: - show an extra UI panel (e.g. a Git history panel) only when the app opts in - expose runtime config to the frontend (e.g. which environments a panel applies to) If you only need workflows, tools, and documents, you don't need a feature — those are surfaced automatically. ## How It Works 1. A feature module exposes a `forFeature(config)` static method that registers a tagged provider via `registerFeature(id, config)`. 2. At bootstrap, the framework walks the import graph of each `@StudioApp` module and collects every registered feature reachable from it. 3. The Studio API returns the active features per app. The frontend has an `AVAILABLE_FEATURES` registry that maps each feature `id` to a UI surface (panel, widget, etc.). The feature `id` on the backend must match the key in the frontend's `AVAILABLE_FEATURES` registry. ## Example — Enabling the Git Feature ```typescript import { Module } from '@nestjs/common'; import { StudioApp } from '@loopstack/common'; import { GitModule } from '@loopstack/git-module'; import { MyWorkflow } from './workflows/my.workflow'; @StudioApp({ title: 'My App', workflows: [MyWorkflow], }) @Module({ imports: [GitModule.forFeature({ enabled: true })], providers: [MyWorkflow], }) export class MyAppModule {} ``` Importing `GitModule` alone provides the Git tools. Calling `GitModule.forFeature(...)` additionally registers the `git` feature for this app, which makes Studio render the Git sidebar panel. ## Defining a Custom Feature A custom feature module uses `registerFeature()` inside `forFeature()`: ```typescript import { DynamicModule, Module } from '@nestjs/common'; import { registerFeature } from '@loopstack/common'; @Module({ /* providers, controllers… */ }) export class MyFeatureModule { static forFeature(config?: { enabled?: boolean } & Record): DynamicModule { return { module: MyFeatureModule, providers: [registerFeature('myFeature', config)], }; } } ``` To make the feature visible in Studio, the frontend must register a matching entry in its `AVAILABLE_FEATURES` registry under the same `id` (`myFeature` here). Without that frontend entry the feature is registered on the backend but has no UI surface. ## Studio Extensions (advanced) Some feature modules also contribute arbitrary config sections to a `@StudioApp` via `registerStudioExtension(section, data)`. The collected payloads are grouped by `section` and exposed on the resolved `StudioAppConfig.extensions[section]`. This is an internal extension point (currently used by `RemoteClientModule` to register environment slots) and not a stable public API — refer to the JSDoc on `registerStudioExtension` for details before using it. ## References - `loopstack/packages/common/src/utils/feature-registration.ts` — `registerFeature()` helper - `loopstack/packages/core/src/workflow-processor/services/studio-discovery.service.ts` — bootstrap-time feature discovery - `loopstack/registry/features/git-module/src/git.module.ts` — reference implementation --- > 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(message: LlmNormalizedMessage): unknown { // Convert normalized message back to provider-specific message format // Used by resolveMessages() for API round-trips return { role: message.role, content: message.blocks ? message.blocks.map((block) => this.convertBlock(block)) : message.text, }; } } ``` ## 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 message to provider-specific message format. */ toProviderMessage(message: 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'; text: string; blocks?: LlmContentBlock[]; stopReason?: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence'; } ``` `text` is the plain-text projection (always populated). `blocks` is the structured form, present when the message contains non-text blocks (thinking, tool calls, tool results). 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'` | --- > Source: https://loopstack.ai/llms/extend/tool-interceptors.md --- title: Tool Interceptors description: Advanced — register chain-based interceptors around every tool.call() for cross-cutting concerns (quota tracking, caching, structured logging, error handling). Covers the ToolInterceptor interface, @UseToolInterceptor() decorator, ToolExecutionContext, priority ordering, and the built-in ToolLoggingInterceptor. --- # Tool Interceptors Tool interceptors are a chain-based extension point that wraps every `tool.call()` in your app. They are the right surface for cross-cutting concerns that should run around _every_ tool call without changing tool implementations — quota enforcement, response caching, structured tracing, custom error handling, billing accounting. This is an advanced extension point. Most apps don't need a custom interceptor — the framework already ships `ToolLoggingInterceptor` for timing/logging, and the `@loopstack/quota` registry feature includes a working `QuotaInterceptor` you can copy. ## How They Work Interceptors form a NestJS-style chain. Each interceptor calls `next()` to pass control to the next interceptor (or, eventually, to the tool's `handle()`). You can: - run logic before and after the tool call - transform the result - short-circuit by not calling `next()` (e.g. cache hit returns a result directly) - handle errors with `try/catch` around `next()` The chain is built once at app bootstrap from every NestJS provider decorated with `@UseToolInterceptor()`. Ordering is controlled by `priority` — **lower runs first / outermost**. The built-in `ToolLoggingInterceptor` uses priority `0` so its timing includes every other interceptor. ``` caller → ToolLoggingInterceptor(0) → CacheInterceptor(50) → QuotaInterceptor(80) → tool.handle() ``` ## Implementing an Interceptor Implement `ToolInterceptor` and decorate with `@UseToolInterceptor({ priority? })`. The decorator applies `@Injectable()` for you, so the class only needs to be registered in a NestJS module like any other provider. ```typescript import { ToolEnvelope, ToolExecutionContext, ToolInterceptor, UseToolInterceptor } from '@loopstack/common'; @UseToolInterceptor({ priority: 50 }) export class CacheInterceptor implements ToolInterceptor { private readonly cache = new Map(); async intercept(context: ToolExecutionContext, next: () => Promise): Promise { const key = `${context.tool.constructor.name}:${JSON.stringify(context.args)}`; const hit = this.cache.get(key); if (hit) { context.metadata.cacheHit = true; return hit; // short-circuit — `next()` not called, tool doesn't run } const result = await next(); this.cache.set(key, result); return result; } } ``` Register it in a module: ```typescript @Module({ providers: [CacheInterceptor /*, ...your tools and workflows */], }) export class MyAppModule {} ``` That's it — bootstrap-time discovery picks it up via `@UseToolInterceptor()` metadata. No manual registration list. ## `ToolExecutionContext` The first argument to `intercept()` carries everything an interceptor needs. | Field | Type | Notes | | ------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tool` | `object` | The tool instance. Use `context.tool.constructor.name` for the class name. | | `args` | `Record \| undefined` | The arguments passed to `tool.call()` (post-validation when reaching `handle()`). | | `runContext` | `RunContext` | The per-job framework context: `userId`, `workspaceId`, `workflowId`, `args`. | | `metadata` | `Record` | **Mutable.** Use this to pass data between interceptors in the chain (e.g. cache key, timings, quota cost). The built-in logging interceptor writes `durationMs` here. | ## Priority Ordering `priority` is a number. **Lower runs first / outermost** — that interceptor wraps every later one. | Priority | Position | Use for | | -------- | --------- | --------------------------------------------------------------- | | `0` | outermost | Logging, tracing — see everything that happens inside. | | `1–50` | early | Auth gates, request validation, kill-switches. | | `50–100` | middle | Caching, idempotency, response transformation. | | `>100` | inner | Per-tool accounting (quota debit, billing) close to `handle()`. | `@UseToolInterceptor()` defaults to `100` when omitted. ## Built-in Interceptors - **`ToolLoggingInterceptor`** (priority `0`) — auto-registered by `LoopCoreModule`. Logs each tool's start/finish/timing and writes `context.metadata.durationMs`. Source: `loopstack/packages/core/src/workflow-processor/services/tool-logging.interceptor.ts`. ## Real-world Example: Quota The `@loopstack/quota` package ships a `QuotaInterceptor` that uses the chain pattern to enforce per-user quotas before the tool runs and report usage after: ```typescript @UseToolInterceptor({ priority: 80 }) export class QuotaInterceptor implements ToolInterceptor { async intercept(context: ToolExecutionContext, next: () => Promise): Promise { const userId = context.runContext.userId; await this.checkQuota(userId, context.tool); const result = await next(); await this.reportUsage(userId, context.tool, result); return result; } } ``` See `loopstack/registry/features/quota-module/src/services/quota.interceptor.ts` for the full implementation. --- # Reference API and configuration reference — module options, environment variables, YAML schemas for workflows and documents, and import paths. --- - [API: @loopstack/agent](https://loopstack.ai/llms/reference/api/agent.md): Public API reference for @loopstack/agent - [API: @loopstack/claude-module](https://loopstack.ai/llms/reference/api/claude-module.md): Public API reference for @loopstack/claude-module - [API: @loopstack/claude-tools-module](https://loopstack.ai/llms/reference/api/claude-tools-module.md): Public API reference for @loopstack/claude-tools-module - [API: @loopstack/code-agent](https://loopstack.ai/llms/reference/api/code-agent.md): Public API reference for @loopstack/code-agent - [API: @loopstack/common](https://loopstack.ai/llms/reference/api/common.md): Public API reference for @loopstack/common - [API: @loopstack/core](https://loopstack.ai/llms/reference/api/core.md): Public API reference for @loopstack/core - [API: @loopstack/git-module](https://loopstack.ai/llms/reference/api/git-module.md): Public API reference for @loopstack/git-module - [API: @loopstack/github-integration](https://loopstack.ai/llms/reference/api/github-integration.md): Public API reference for @loopstack/github-integration - [API: @loopstack/github-module](https://loopstack.ai/llms/reference/api/github-module.md): Public API reference for @loopstack/github-module - [API: @loopstack/google-workspace-module](https://loopstack.ai/llms/reference/api/google-workspace-module.md): Public API reference for @loopstack/google-workspace-module - [API: @loopstack/hitl](https://loopstack.ai/llms/reference/api/hitl.md): Public API reference for @loopstack/hitl - [API: @loopstack/llm-provider-module](https://loopstack.ai/llms/reference/api/llm-provider-module.md): Public API reference for @loopstack/llm-provider-module - [API: @loopstack/local-file-explorer-module](https://loopstack.ai/llms/reference/api/local-file-explorer-module.md): Public API reference for @loopstack/local-file-explorer-module - [API: @loopstack/mcp-module](https://loopstack.ai/llms/reference/api/mcp-module.md): Public API reference for @loopstack/mcp-module - [API: @loopstack/oauth-module](https://loopstack.ai/llms/reference/api/oauth-module.md): Public API reference for @loopstack/oauth-module - [API: @loopstack/openai-module](https://loopstack.ai/llms/reference/api/openai-module.md): Public API reference for @loopstack/openai-module - [API: @loopstack/quota](https://loopstack.ai/llms/reference/api/quota.md): Public API reference for @loopstack/quota - [API: @loopstack/remote-client](https://loopstack.ai/llms/reference/api/remote-client.md): Public API reference for @loopstack/remote-client - [API: @loopstack/remote-file-explorer-module](https://loopstack.ai/llms/reference/api/remote-file-explorer-module.md): Public API reference for @loopstack/remote-file-explorer-module - [API: @loopstack/sandbox-filesystem](https://loopstack.ai/llms/reference/api/sandbox-filesystem.md): Public API reference for @loopstack/sandbox-filesystem - [API: @loopstack/sandbox-tool](https://loopstack.ai/llms/reference/api/sandbox-tool.md): Public API reference for @loopstack/sandbox-tool - [API: @loopstack/secrets-module](https://loopstack.ai/llms/reference/api/secrets-module.md): Public API reference for @loopstack/secrets-module - [API: @loopstack/web-module](https://loopstack.ai/llms/reference/api/web-module.md): Public API reference for @loopstack/web-module > 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. > Need finer-grained control over the database connection, config loading, or which submodules are registered? See [Custom Bootstrap](../extend/custom-bootstrap.md) for replacing `LoopstackModule.forRoot()` with its underlying modules. ## `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` Standard NestJS/Express CORS options (the [`cors`](https://github.com/expressjs/cors#configuration-options) package). 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' ``` ## Document-Type Options ### `internal` — Frame­work-internal documents Declare `internal: true` directly on the `@Document` decorator to mark every instance of this type as framework plumbing. Internal documents are persisted server-side and still readable by code that queries the document store (e.g. LLM providers building conversation history), but they're excluded from API responses and live updates — Studio never sees them. ```typescript @Document({ schema: LlmContextSchema, internal: true, tags: ['message'], }) export class LlmContextDocument { /* ... */ } ``` The built-in `LlmContextDocument` uses exactly this combination so LLM context messages reach the model but never render in the chat UI. ### Meta Properties Loopstack splits document metadata into two kinds: **static meta** (declared once on `@Document({ meta })` and applied to every instance) and **dynamic meta** (passed per-call via `documentStore.save(…, { meta })` and persisted on that specific document row). ### Static Meta — `@Document({ meta })` Declared on the decorator. Applies to every instance of this document type. ```typescript @Document({ schema: ReportSchema, meta: { mimeType: 'text/markdown', level: 'info' }, }) export class ReportDocument { /* ... */ } ``` | Property | Type | Description | | ---------------- | ------------------------------------------- | ------------------------------------------------------------------------------- | | `mimeType` | `string` | MIME type hint used by Studio for rendering/downloads (see below for the list). | | `level` | `'debug' \| 'info' \| 'warning' \| 'error'` | Severity tag. Studio may style documents based on this. | | `enableAtPlaces` | `string[]` | Only render this document type when the workflow is at one of these places. | | `hideAtPlaces` | `string[]` | Hide this document type when the workflow is at one of these places. | ### Dynamic Meta — `documentStore.save(…, { meta })` Set per call. Persisted on the specific document row. ```typescript await this.documentStore.save(MyDocument, content, { key: 'doc-1', meta: { invalidate: false } }); ``` | Property | Type | Description | | --------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalidate` | `boolean` | **Opt-out of replacement.** Default behavior (omitted/`true`) invalidates the previous version when reusing the same `key`. Set to `false` to keep both. | | `data` | `any` | Arbitrary per-instance metadata bag for user-defined data. Not used by the framework. | | `streaming` | `boolean` | _Frontend-managed._ Set by Studio during LLM streaming to indicate this document is still being filled in. Not typically set by backend code. | | `streamReadyForFinal` | `boolean` | _Frontend-managed._ Companion to `streaming` — marks the stream complete and the final version ready to persist. Not typically set by backend code. | ### 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, Guard, QueueResult, Transition, Workflow } from '@loopstack/common'; import type { RunContext, TransitionInput } from '@loopstack/common'; // Tools import { BaseTool, ServerTool, Tool, ToolEnvelope, 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/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 Document](https://loopstack.ai/llms/skills/create-custom-document.md): Step-by-step instructions for AI agents to scaffold a new document — @Document decorator, Zod schema, YAML widget config, and how to save instances via documentStore. - [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 and tools via npm, example workflows via giget for source access. --- # Registry The Loopstack Registry — a curated collection of npm packages providing feature modules, standalone tools, and example workflows. --- > Source: https://loopstack.ai/llms/registry/index.md --- title: Registry Overview description: The Loopstack Registry — a curated collection of npm packages providing feature modules (LLM, OAuth, Git, HITL), standalone tools (sandbox, filesystem), and example workflows. How to discover, install, and use @loopstack/* packages. --- # Registry The Loopstack Registry is a curated collection of `@loopstack/*` npm packages that extend Loopstack with ready-to-use capabilities. Instead of building everything from scratch, install a package, import its module, and start using its tools and workflows immediately. ## Package Categories ### Features Feature packages add entire capabilities to your app — LLM providers, OAuth flows, Git integration, human-in-the-loop, and more. Each feature ships as a NestJS module with tools, services, and configuration. Examples: `@loopstack/claude-module`, `@loopstack/github-module`, `@loopstack/hitl`, `@loopstack/oauth-module`, `@loopstack/web-module` (web fetch and summarization) ### Examples Example packages are complete, working workflows that demonstrate Loopstack patterns. Use them as starting points — install the package, study the source, and adapt it to your needs. Examples: `@loopstack/hitl-examples`, `@loopstack/agent-examples`, `@loopstack/filesystem-examples` ## Installing a Package All registry packages are published on npm: ```bash npm install @loopstack/claude-module ``` Import the module in your app: ```typescript import { ClaudeModule } from '@loopstack/claude-module'; @Module({ imports: [ClaudeModule], }) export class AppModule {} ``` The module exports its tools, making them available for injection in your workflows via standard NestJS constructor injection: ```typescript @Workflow({ name: 'my-workflow' }) export class MyWorkflow { constructor(private readonly generateText: LlmGenerateTextTool) {} } ``` ## Inspecting a Package To browse the source code of any registry package, use [giget](https://github.com/unjs/giget) to download it directly from the GitHub repository: ```bash # Download a feature module npx giget@latest gh:loopstack-ai/loopstack/registry/features/claude-module /tmp/claude-module # Download an example workflow npx giget@latest gh:loopstack-ai/loopstack/registry/examples/hitl-examples /tmp/chat-example ``` The repo path pattern is: ``` gh:loopstack-ai/loopstack/registry// ``` Where `` is `features`, `tools`, or `examples`. Review the `README.md` for usage documentation, installation, and configuration. For implementation details, look at the TypeScript source in `src/`. --- # 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 workflows for Loopstack — AgentWorkflow (single-run tool loop), ChatAgentWorkflow (multi-turn chat with optional task mode), AgentFinishTool, tool resolution via NestJS DI, configurable system prompt and tool set - [Claude Module](https://loopstack.ai/llms/registry/features/claude-module.md): A collection of tools for performing AI actions using the Anthropic Claude API directly via the official SDK. - [Claude Tools Module](https://loopstack.ai/llms/registry/features/claude-tools-module.md): Claude-specific tools that consume the LLM provider (e.g. web search using Claude server tools). - [Code Agent Module](https://loopstack.ai/llms/registry/features/code-agent-module.md): AI-powered codebase exploration for Loopstack — ExploreTask tool launches AgentWorkflow sub-agent with glob/grep/read tools, CodeAgentModule registration, forFeature() LLM config, TransitionInput envelope for sub-workflow completion - [Git Module](https://loopstack.ai/llms/registry/features/git-module.md): Git version control tools for Loopstack workflows — GitStatusTool, - [GitHub Integration Module](https://loopstack.ai/llms/registry/features/github-integration-module.md): ConnectGitHubWorkflow — end-to-end guided workflow that authenticates via OAuth, creates or links a GitHub repo, configures git remotes, resolves branch divergence via HITL, and pushes. Uses GitHubIntegrationModule, OAuthWorkflow, AskUserWorkflow, git tools. - [GitHub Module](https://loopstack.ai/llms/registry/features/github-module.md): GitHub OAuth provider and 25 API tools for Loopstack workflows — GitHubModule, GitHubOAuthProvider, OAuthProviderInterface, repositories, issues, pull requests, actions, content/git ops, search, users/orgs. Covers installation, tool args, auth pattern, and env vars. - [Google Workspace Module](https://loopstack.ai/llms/registry/features/google-workspace-module.md): Google Calendar, Gmail, and Drive tools for Loopstack — 11 tools across 3 domains, Google OAuth provider, OAuthProviderInterface, token-based API access with automatic unauthorized error handling - [Human-in-the-Loop Module](https://loopstack.ai/llms/registry/features/hitl-module.md): HITL workflows and tools for Loopstack — AskUserWorkflow (free-text, confirm, multiple-choice), ConfirmUserWorkflow (markdown review + confirm/deny), AskClarificationTool, AskForApprovalTool, document types for UI rendering - [LLM Provider Module](https://loopstack.ai/llms/registry/features/llm-provider-module.md): Shared LLM provider contracts, registry, and helper services for the Loopstack automation framework. Provider modules (Claude, OpenAI, etc.) implement the LlmProviderInterface and register themselves at module init. - [Local File Explorer Module](https://loopstack.ai/llms/registry/features/local-file-explorer-module.md): Loopstack registry feature exposing the local filesystem of a workspace as a REST API. LocalFileExplorerModule, LocalFileExplorerController endpoints for /local-files/tree and /local-files/read, FileApiService, FileSystemService, FileExplorerNodeDto, FileContentDto, path traversal protection, 10 MB file size limit, workflow YAML parsing. - [MCP Module](https://loopstack.ai/llms/registry/features/mcp-module.md): Remote MCP client tools for Loopstack — McpModule.forRoot(), McpCallTool (mcp_call), McpListToolsTool (mcp_list_tools), McpToolConfig with allowedHosts, hostHeaderEnv, SSRF allowlist, Streamable HTTP and SSE transports, McpClientService, error hierarchy, McpMetricsPort - [OAuth Module](https://loopstack.ai/llms/registry/features/oauth-module.md): Provider-agnostic OAuth 2.0 framework for Loopstack — OAuthModule, OAuthWorkflow, OAuthProviderRegistry, OAuthTokenStore, OAuthProviderInterface, BuildOAuthUrlTool, ExchangeOAuthTokenTool, OAuthPromptDocument, token storage with Redis fallback, pluggable provider interface, authorization code flow - [OpenAI Module](https://loopstack.ai/llms/registry/features/openai-module.md): OpenAI LLM provider for the Loopstack automation framework. Implements LlmProviderInterface with the OpenAI SDK. - [Quota Module](https://loopstack.ai/llms/registry/features/quota-module.md): Opt-in quota tracking and enforcement for Loopstack tool calls — QuotaModule.forRoot(), QuotaInterceptor, QuotaCalculatorRegistry, QuotaClientService, AiGenerateTextQuotaCalculator, ProcessingTimeQuotaCalculator, Redis-backed usage counters, model pricing lookup - [Remote Client Module](https://loopstack.ai/llms/registry/features/remote-client-module.md): HTTP client and workflow tools for Loopstack remote servers — RemoteClientModule, RemoteClient service, EnvironmentService, ReadTool, WriteTool, EditTool, BashTool, GlobTool, GrepTool, RebuildAppTool, ResetWorkspaceTool, LogsTool, SyncSecretsTool, file operations, shell commands, environment management on remote workspaces - [Remote File Explorer Module](https://loopstack.ai/llms/registry/features/remote-file-explorer-module.md): REST API controller for browsing files on remote Loopstack workspaces — RemoteFileExplorerModule, RemoteFileExplorerController, file tree and file content endpoints, proxies requests via RemoteClient and EnvironmentService - [Secrets Module](https://loopstack.ai/llms/registry/features/secrets-module.md): Workspace-scoped secrets storage for Loopstack workflows — SecretEntity, SecretService, SecretController REST API, GetSecretKeysTool (get_secret_keys), RequestSecretsTool (request_secrets), RequestSecretsTask (request_secrets_task), SecretsRequestWorkflow, SecretRequestDocument. CRUD service, upsert, request secrets from users at runtime. - [Web Module](https://loopstack.ai/llms/registry/features/web-module.md): Fetch and process web content. Converts HTML to Markdown, optionally summarizes against a prompt via Claude, with URL validation, same-origin redirect handling, LRU caching, and a preapproved-host allowlist. --- # Registry — Examples Example workflows demonstrating Loopstack patterns: chat, tool calling, agents, HITL, structured output, sub-workflows, and integrations. --- - [Advanced Workflows Examples](https://loopstack.ai/llms/registry/examples/advanced-workflows-examples.md): In-depth examples of advanced Loopstack workflow patterns — state, dynamic routing, error retry, sub-workflows (parent, fan-out, sequence, show modes, error handling), batch processing, custom tools, configurable modules, and built-in UI documents. - [Agent Examples](https://loopstack.ai/llms/registry/examples/agent-examples.md): Workflow examples for LLM agents in Loopstack — basic AgentWorkflow with custom tools, code-exploration agent, MCP-connected agent, and a from-scratch custom agent loop with error handling. - [Filesystem Examples](https://loopstack.ai/llms/registry/examples/filesystem-examples.md): Workflow examples for filesystem operations in Loopstack — Docker sandbox, remote file browsing, remote command execution, local file tree. - [Git Examples](https://loopstack.ai/llms/registry/examples/git-examples.md): Git and GitHub workflow examples — scripted multi-tool git operations and end-to-end repo sync via ConnectGitHubWorkflow - [HITL Examples](https://loopstack.ai/llms/registry/examples/hitl-examples.md): Workflow examples for Human-in-the-Loop patterns in Loopstack — custom Document with a form widget, AskUserWorkflow / ConfirmUserWorkflow sub-workflow shortcuts, LLM agent loops with ask_clarification / ask_for_approval, and an end-to-end meeting-notes review flow. - [Integration Examples (placeholder)](https://loopstack.ai/llms/registry/examples/integration-examples.md): Placeholder for integration / notification workflow examples in Loopstack — Slack, email, generic HTTP webhook fire-and-forget (coming soon) - [LLM Examples](https://loopstack.ai/llms/registry/examples/llm-examples.md): Workflow examples for LLM integration in Loopstack — simple prompts, structured output with Zod schemas, multi-provider comparison, web fetch with summarization - [OAuth Examples](https://loopstack.ai/llms/registry/examples/oauth-examples.md): Workflow examples for OAuth-protected integrations in Loopstack — GitHub repos overview and chat agent, Google Calendar summary and Google Workspace chat agent. Each pair demonstrates the same shape - scripted single-pass workflow + interactive agent with auto-OAuth retry. - [Observability Examples](https://loopstack.ai/llms/registry/examples/observability-examples.md): Workflow examples for observability in Loopstack — opt-in quota tracking and enforcement - [Scheduling Examples](https://loopstack.ai/llms/registry/examples/scheduling-examples.md): Runnable Loopstack examples for the scheduling fundamentals — cron (@Cron), webhook (@Post + @Public controller), delayed runs (SchedulerRegistry timeout), and batch (Promise.all fan-out). Each trigger starts a workflow with WorkflowRunner.run. - [Secrets Examples](https://loopstack.ai/llms/registry/examples/secrets-examples.md): Workflow examples for secrets management in Loopstack — deterministic request/verify flow and an agentic LLM flow using get_secret_keys and request_secrets_task tools - [Testing Examples (placeholder)](https://loopstack.ai/llms/registry/examples/testing-examples.md): Placeholder for workflow testing examples in Loopstack — @loopstack/testing fixtures, transition assertions, mocked LLM responses (coming soon) ---