# Loopstack Loopstack is a TypeScript workflow framework for building stateful automations, AI agents, and interactive workflows on top of NestJS. --- # Build Step-by-step guides for building with Loopstack — getting started, workflow fundamentals, AI/LLM integration, workflow patterns, and third-party integrations. --- > Source: https://loopstack.ai/llms/build/ai/agent-workflows.md --- title: Agent Workflows description: Building autonomous LLM agents that call tools in a loop. Covers the built-in AgentWorkflow module, custom agent loops with @Guard routing, error recovery, and max-iterations limits. --- # Agent Workflows Build LLM agents that call tools, handle errors, and run as sub-workflows. Use the built-in `AgentWorkflow` for the common case, or build your own loop from scratch with the same decorators. ## Using the Built-In Agent Install the agent module: ```bash npm install @loopstack/agent ``` Register tools in your module so the agent can use them: ```typescript @Module({ imports: [ClaudeModule, AgentModule], providers: [GlobTool, GrepTool, ReadTool, MyWorkflow], exports: [MyWorkflow], }) export class MyModule {} ``` Launch the agent from any workflow: ```typescript @Transition({ from: 'planning', to: 'implementing' }) async runAgent(state: MyState): Promise { await this.agent.run({ system: 'You are a code exploration agent. Summarize your findings.', tools: ['glob', 'grep', 'read'], userMessage: 'Find all API endpoints in the codebase.', }, { callback: { transition: 'agentDone' } }); return state; } ``` The agent runs a full tool-calling loop automatically: LLM turn → tool execution → loop back → until the LLM responds without tool calls. ### Agent Args | Arg | Type | Required | Description | | ------------- | ---------- | -------- | --------------------------------------------- | | `system` | `string` | yes | System prompt | | `tools` | `string[]` | yes | Tool names available to the LLM | | `userMessage` | `string` | yes | Initial user message | | `context` | `string` | no | Hidden context message (e.g. pre-loaded docs) | ### Pre-Loading Context Pass documentation or environment data as a hidden context message. The LLM sees it but it's not shown in the UI: ```typescript const docs = await this.loadFiles.call({ files: ['docs/api-reference.md', 'docs/architecture.md'], basePath: './src/assets', }); const context = this.render(__dirname + '/templates/context.md', { docs: docs.data, projectName: args.projectName, }); await this.agent.run({ system: 'You are a documentation agent.', tools: ['read', 'write', 'glob', 'grep'], userMessage: 'Generate API documentation.', context, }); ``` ## Tool Resolution When the LLM calls a tool, it's resolved from the NestJS dependency injection container by its `@Tool({ name })` value. The agent workflow only injects the three tools it always needs (`LlmGenerateTextTool`, `LlmDelegateToolCallsTool`, `LlmUpdateToolResultTool`). Domain-specific tools like `glob` or `read` are resolved from the module at runtime. This means you register tools once in the module and they're available to the agent and all other workflows. ## Error Handling Tool errors are handled automatically. When a tool call fails (schema validation or runtime error), the error is returned to the LLM as an `is_error` tool result. The LLM sees the error message and can self-correct on the next turn. The `LlmDelegateResult` includes error metadata: ```typescript interface LlmDelegateResult { allCompleted: boolean; toolResults: { type: 'tool_result'; toolCallId: string; content?: string; isError?: boolean }[]; pendingCount: number; hasErrors: boolean; errorCount: number; errors: { toolName: string; toolCallId: string; message: string }[]; } ``` ## Canceling Pending Tools If the agent is stuck at `awaiting_tools` (e.g. a sub-workflow hasn't returned), a "Cancel pending tools" button appears in the UI. This cancels all pending child workflows recursively and returns the agent to the LLM loop. ## Building a Custom Agent The built-in `AgentWorkflow` is a regular workflow. When you need custom behavior, copy it and modify directly. Here's the full loop: ```typescript import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; import type { LlmDelegateResult, LlmGenerateTextResult, LlmResultMeta } from '@loopstack/llm-provider-module'; import { LlmDelegateToolCallsTool, LlmGenerateTextTool, LlmMessageDocument, LlmUpdateToolResultTool, } from '@loopstack/llm-provider-module'; interface AgentState { llmResult?: LlmGenerateTextResult; llmMeta?: LlmResultMeta; delegateResult?: LlmDelegateResult; } @Workflow({ widget: __dirname + '/my-agent.ui.yaml', schema: z.object({ instructions: z.string() }), }) export class MyAgentWorkflow extends BaseWorkflow<{ instructions: string }, AgentState> { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, private readonly llmUpdateToolResult: LlmUpdateToolResultTool, private readonly myCustomTool: MyCustomTool, ) { super(); } @Transition({ to: 'ready' }) async setup(state: AgentState, ctx: RunContext): Promise { const args = ctx.args as { instructions: string }; await this.documentStore.save(LlmMessageDocument, { role: 'user', text: args.instructions, }); return state; } @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: AgentState): Promise { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', system: 'You are a custom agent.', tools: ['my_custom_tool'], }, }, ); return { ...state, llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined }; } @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: AgentState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); const result = await this.llmDelegateToolCalls.call({ message: state.llmResult!.message, callback: { transition: 'toolResultReceived' }, }); return { ...state, delegateResult: result.data }; } @Transition({ from: 'awaiting_tools', to: 'awaiting_tools', wait: true }) async toolResultReceived(state: AgentState, payload: unknown): Promise { const result = await this.llmUpdateToolResult.call({ delegateResult: state.delegateResult!, completedTool: payload, }); return { ...state, delegateResult: result.data }; } @Transition({ from: 'awaiting_tools', to: 'ready' }) @Guard('allToolsComplete') async toolsComplete(state: AgentState): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', blocks: state.delegateResult!.toolResults.map((tr) => ({ type: 'tool_result' as const, toolCallId: tr.toolCallId, content: tr.content ?? '', isError: tr.isError ?? false, })), }); return state; } @Transition({ from: 'prompt_executed', to: 'end' }) @Guard('isEndTurn') async respond(state: AgentState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return {}; } private hasToolCalls(state: AgentState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } private allToolsComplete(state: AgentState): boolean { return state.delegateResult?.allCompleted ?? false; } private isEndTurn(state: AgentState): boolean { return state.llmResult?.message.stopReason === 'end_turn'; } } ``` ### Adding User Interaction Pause for user input between LLM turns: ```typescript // Instead of final transition, go to waiting_for_user @Transition({ from: 'prompt_executed', to: 'waiting_for_user' }) @Guard('isEndTurn') async respondToUser(state: AgentState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return state; } @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string() }) async userMessage(state: AgentState, payload: string): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: payload, }); return state; } ``` > **Tip:** The `@loopstack/agent` package ships `ChatAgentWorkflow` which implements this pattern out of the box. Use it when you need a multi-turn chat agent without customization. ### Wrapping an Agent as a Tool Make an agent callable by other agents via a task tool: ```typescript @Tool({ name: 'explore_codebase', description: 'Launch a sub-agent to explore the codebase.', schema: z.object({ instructions: z.string() }), }) export class ExploreTask extends BaseTool { constructor(private readonly agentWorkflow: AgentWorkflow) { super(); } protected async handle( args: { instructions: string }, ctx: 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-delegate-error-example-workflow) — Example demonstrating tool error handling and recovery --- > Source: https://loopstack.ai/llms/build/ai/chat-flows.md --- title: Chat Flows description: Building multi-turn conversational workflows with LLMs using LlmMessageDocument, messagesSearchTag pattern, and wait transitions for user input. --- # Chat Flows Build multi-turn conversational workflows where users exchange messages with an LLM. Messages are persisted as documents and accumulated across turns using the `messagesSearchTag` pattern. ## Example ```typescript import { z } from 'zod'; import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import { LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; @Workflow({ widget: __dirname + '/chat.ui.yaml' }) export class ChatWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } @Transition({ to: 'waiting_for_user' }) async setup(state: Record): Promise> { await this.documentStore.save( LlmMessageDocument, { role: 'user', text: this.render(__dirname + '/templates/systemMessage.md') }, { meta: { hidden: true } }, ); return state; } @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string() }) async userMessage(state: Record, payload: string): Promise> { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: payload }); return state; } @Transition({ from: 'ready', to: 'waiting_for_user' }) async llmTurn(state: Record): Promise> { const result = await this.llmGenerateText.call({}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }); await this.documentStore.save(LlmMessageDocument, result.data!.message, { meta: { response: result.data!.response, provider: (result.metadata as { provider: string })?.provider }, }); return state; } } ``` ## YAML Config ```yaml title: 'Chat Assistant' ui: widgets: - widget: prompt-input enabledWhen: - waiting_for_user options: transition: userMessage ``` ## How Message Accumulation Works 1. All messages are saved as `LlmMessageDocument` — automatically tagged with `message` 2. The LLM provider module collects all documents with the `message` tag as conversation history by default 3. Each new message adds to the conversation — the LLM sees the full history on every turn ## 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 import type { LlmResultMeta } from '@loopstack/llm-provider-module'; @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: ChatState): Promise { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather', 'search_database'] } }, ); return { ...state, llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined }; } @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: ChatState): Promise { ... } @Transition({ from: 'prompt_executed', to: 'waiting_for_user' }) async respond(state: ChatState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return state; } ``` ## Registry References - [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) — Multi-turn chat with Claude, system message, and prompt-input widget - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — Chat with tool calling loop --- > Source: https://loopstack.ai/llms/build/ai/llm-providers.md --- title: LLM Providers description: Using multiple LLM providers (Claude, OpenAI) through the runtime provider registry. Covers LlmProviderModule setup, provider selection per-call, and switching providers without code changes. --- # LLM Providers Loopstack supports multiple LLM providers through a runtime registry. Provider modules self-register at startup. Workflows and tools resolve providers by name — swap or use multiple providers in parallel without changing workflow code. ## Quick Start Import `LlmProviderModule` for the adapter tools and a provider module (e.g. `ClaudeModule`) to register the LLM backend: ```typescript import { ClaudeModule } from '@loopstack/claude-module'; import { LlmProviderModule } from '@loopstack/llm-provider-module'; @Module({ imports: [LoopstackModule.forRoot(), LlmProviderModule, ClaudeModule], }) export class AppModule {} ``` ## Module-Level Defaults Use `LlmProviderModule.forRoot()` to set a default model for all LLM calls in your app. Use `forFeature()` to override per-module: ```typescript // app.module.ts — global default model @Module({ imports: [LoopstackModule.forRoot(), LlmProviderModule.forRoot({ model: 'claude-sonnet-4-5' }), ClaudeModule], }) export class AppModule {} ``` ```typescript // premium-feature.module.ts — this module uses a stronger model @Module({ imports: [LlmProviderModule.forFeature({ model: 'claude-opus-4-6' })], providers: [PremiumWorkflow], }) export class PremiumFeatureModule {} ``` ## Per-Call Configuration Override provider and model at individual call sites via `options.config`. Per-call config always takes priority over module defaults. ```typescript export class MyWorkflow extends BaseWorkflow { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, ) { super(); } } ``` ```typescript const result = await this.llmGenerateText.call( { prompt: 'Hello!' }, { config: { provider: 'claude', model: 'claude-opus-4-6', system: 'You are a helpful assistant.', messagesSearchTag: 'message', tools: ['get_weather'], }, }, ); ``` ### Args vs Config LLM tools separate **args** (per-request data) from **config** (provider/model/behavior settings). 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` When you save an `LlmMessageDocument` manually, 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 — tool results, multi-block content await this.documentStore.save(LlmMessageDocument, { role: 'user', blocks: [{ type: 'tool_result', toolCallId: '...', content: '...', isError: false }], }); // LLM response — both fields are already populated by the provider await this.documentStore.save(LlmMessageDocument, result.data!.message); ``` 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: __dirname + '/file-document.yaml', }) export class FileDocument { filename: string; description: string; code: string; } ``` ## Workflow Example ```typescript import { toJSONSchema, z } from 'zod'; import { BaseWorkflow, DocumentEntity, Transition, Workflow } from '@loopstack/common'; import type { 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; } @Workflow({ schema: z.object({ language: z.enum(['python', 'javascript', 'java', 'cpp', 'ruby', 'go', 'php']).default('python'), }), }) export class PromptStructuredOutputWorkflow extends BaseWorkflow<{ language: string }, StructuredOutputState> { constructor(private readonly llmGenerateObject: LlmGenerateObjectTool) { super(); } @Transition({ to: 'ready' }) async greeting(state: StructuredOutputState, ctx: RunContext): Promise { const args = ctx.args as { language: string }; await this.documentStore.save( LlmMessageDocument, { role: 'assistant', text: `Creating a Hello World script in ${args.language}...` }, { id: 'status' }, ); return { ...state, language: args.language }; } @Transition({ from: 'ready', to: 'prompt_executed' }) async prompt(state: StructuredOutputState): Promise { const result = await this.llmGenerateObject.call( { outputSchema: toJSONSchema(FileDocumentSchema) as Record, prompt: this.render(__dirname + '/templates/prompt.md', { language: state.language }), }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); const objectResult = result.data as LlmGenerateObjectResult; const llmResult = await this.documentStore.save(FileDocument, objectResult.data as FileDocumentType, { validate: 'skip', }); return { ...state, llmResult }; } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: StructuredOutputState): Promise { await this.documentStore.save( LlmMessageDocument, { role: 'assistant', text: `Generated: ${state.llmResult?.content?.description ?? ''}` }, { id: 'status' }, ); return {}; } } ``` ## How It Works 1. Convert your Zod schema to JSON Schema using `toJSONSchema()` 2. Pass the JSON Schema as `outputSchema` to `llmGenerateObject.call()` 3. The provider forces the LLM to return data matching the schema 4. Save the result as a typed document using `this.documentStore.save()` ## Key Parameters ```typescript await this.llmGenerateObject.call( { outputSchema: toJSONSchema(MyDocumentSchema) as Record, prompt: 'Generate structured data.', }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); ``` ## Registry References - [prompt-structured-output-example-workflow](https://loopstack.ai/registry/loopstack-prompt-structured-output-example-workflow) — Generates structured code files using the LLM provider --- > Source: https://loopstack.ai/llms/build/ai/text-generation.md --- title: AI Text Generation description: Calling LLMs for text generation using LlmGenerateTextTool. Covers setup, system prompts, message history, provider selection, prompt caching, and streaming. --- # AI Text Generation Generate text from any configured LLM provider using `LlmGenerateTextTool`. Pass prompts, system instructions, and message history — the tool handles provider routing, token counting, and optional streaming. ## Setup ```typescript import { Module } from '@nestjs/common'; import { ClaudeModule } from '@loopstack/claude-module'; @Module({ imports: [ClaudeModule], providers: [PromptWorkflow], exports: [PromptWorkflow], }) export class PromptModule {} ``` ## Example Workflow ```typescript import { z } from 'zod'; import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; import type { LlmGenerateTextResult, LlmResultMeta } from '@loopstack/llm-provider-module'; import { LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; interface PromptState { llmResult?: LlmGenerateTextResult; llmMeta?: LlmResultMeta; } @Workflow({ schema: z.object({ subject: z.string().default('coffee'), }), }) export class PromptWorkflow extends BaseWorkflow<{ subject: string }, PromptState> { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } @Transition({ to: 'prompt_executed' }) async prompt(state: PromptState, ctx: RunContext): Promise { const args = ctx.args as { subject: string }; const result = await this.llmGenerateText.call( { prompt: this.render(__dirname + '/templates/prompt.md', { subject: args.subject }), }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); return { llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined }; } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: PromptState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return {}; } } ``` ## 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', }, }, ); ``` ## Using Templates Render Handlebars templates for complex prompts (`this.render()` is available from `BaseWorkflow`): ```typescript const rendered = this.render(__dirname + '/templates/prompt.md', { subject: args.subject, }); const result = await this.llmGenerateText.call( { prompt: rendered }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); ``` ## 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 workflow persists the response via `documentStore.save(LlmMessageDocument, result.data.message, ...)`. 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. 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-prompt-example-workflow) — Single-turn prompt with subject parameter and Handlebars template --- > Source: https://loopstack.ai/llms/build/ai/tool-calling.md --- title: AI Tool Calling description: Enabling LLMs to invoke workflow tools via function calling. Covers LlmDelegateToolCallsTool, tool descriptions, passing tools to LLM calls, and handling tool results. --- # AI Tool Calling Enable the LLM to call workflow tools (function calling). The LLM decides which tools to invoke, and `LlmDelegateToolCallsTool` executes them. ## Create a Tool for the LLM Tools exposed to the LLM need a `description` so the LLM knows when to use them: ```typescript import { z } from 'zod'; import { BaseTool, Tool, ToolResult } from '@loopstack/common'; import type { 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(_args: { location: string }, _ctx: RunContext): Promise> { return Promise.resolve({ type: 'text', data: 'Mostly sunny, 14C, rain in the afternoon.' }); } } ``` ## Tool Calling Workflow ```typescript import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common'; import type { LlmDelegateResult, LlmGenerateTextResult, LlmResultMeta } from '@loopstack/llm-provider-module'; import { LlmDelegateToolCallsTool, LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; import { GetWeather } from './tools/get-weather.tool'; interface ToolCallState { llmResult?: LlmGenerateTextResult; llmMeta?: LlmResultMeta; delegateResult?: LlmDelegateResult; } @Workflow({}) export class ToolCallWorkflow extends BaseWorkflow, ToolCallState> { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, private readonly getWeather: GetWeather, ) { super(); } @Transition({ to: 'ready' }) async setup(state: ToolCallState): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: 'How is the weather in Berlin?', }); return state; } @Transition({ from: 'ready', to: 'prompt_executed' }) async llmTurn(state: ToolCallState): Promise { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather'] } }, ); return { ...state, llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined }; } @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: ToolCallState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); const result = await this.llmDelegateToolCalls.call({ message: state.llmResult!.message, }); return { ...state, delegateResult: result.data }; } hasToolCalls(state: ToolCallState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } @Transition({ from: 'awaiting_tools', to: 'ready' }) @Guard('allToolsComplete') async toolsComplete(state: ToolCallState): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', blocks: state.delegateResult!.toolResults.map((tr) => ({ type: 'tool_result' as const, toolCallId: tr.toolCallId, content: tr.content ?? '', isError: tr.isError ?? false, })), }); return state; } allToolsComplete(state: ToolCallState): boolean { return state.delegateResult?.allCompleted ?? false; } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: ToolCallState): Promise { await this.documentStore.save(LlmMessageDocument, state.llmResult!.message, { meta: { response: state.llmResult!.response, provider: state.llmMeta!.provider }, }); return {}; } } ``` ## How the Loop Works ``` setup → llmTurn → [hasToolCalls?] ├─ yes → executeToolCalls → toolsComplete → llmTurn (loop) └─ no → respond (done) ``` 1. `llmGenerateText` is called — the `tools` array in config lists available tools 2. If the LLM returns `stopReason: 'tool_use'`, the guard routes to `executeToolCalls` 3. `llmDelegateToolCalls` executes the requested tools and stores results 4. The loop continues back to the LLM 5. When no more tool calls are needed, the fallback transition to `end` fires ## Key Concepts - **`tools` array in config** — Lists tool names the LLM can call. 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`** — Executes tool calls from the LLM response message - **`message.stopReason === 'tool_use'`** — The LLM wants to call a tool - **`allCompleted`** — All delegated tool calls have finished - **`@Guard` + `priority`** — Routes between tool calling and final response ## 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. **Executes 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. This is why your tool-calling loop should always branch on `allCompleted` (continue when true, pause when false), not just on whether `tool_call` blocks exist — `allCompleted` is the signal that all parallel work, sync and async, is in. ## Registry References - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — Complete tool calling loop with GetWeather tool, guard-based routing, and delegate pattern --- > Source: https://loopstack.ai/llms/build/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 import type { LlmResultMeta } from '@loopstack/llm-provider-module'; const result = await this.llmGenerateText.call( { prompt: 'Write a haiku about coffee' }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); const meta = result.metadata as LlmResultMeta | undefined; const usage = meta?.usage; if (usage) { console.log( `${meta!.provider}/${meta!.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): Promise { const result = await this.llmGenerateText.call( { prompt: 'Write a haiku' }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); return { llmResult: result.data, llmMeta: result.metadata as LlmResultMeta | undefined, }; } ``` ## See also - [Text Generation](./text-generation.md) — `LlmGenerateTextTool` call signature - [Structured Output](./structured-output.md) — `LlmGenerateObjectTool` call signature --- > Source: https://loopstack.ai/llms/build/fundamentals/documents.md --- title: Creating Documents description: How to define typed document classes with @Document() decorator, Zod validation schemas, and YAML widget configs for rendering in Loopstack Studio. --- # Creating Documents Documents are typed data objects displayed in the Loopstack Studio UI. They have a Zod schema for validation and a YAML config for rendering. ## Basic Document ```typescript import { z } from 'zod'; import { Document } from '@loopstack/common'; export const NotesSchema = z.object({ text: z.string(), }); @Document({ schema: NotesSchema, widget: __dirname + '/notes.ui.yaml', }) export class NotesDocument { text: string; } ``` ```yaml # notes.ui.yaml type: document ui: widgets: - widget: form options: properties: text: title: Notes widget: textarea rows: 8 ``` ## The `@Document` Decorator ```typescript @Document({ schema: NotesSchema, widget: __dirname + '/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. | > **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 ID await this.documentStore.save(NotesDocument, { text: 'Updated content' }, { id: 'notes-1' }); // With meta options await this.documentStore.save(NotesDocument, { text: 'Hidden note' }, { id: 'hidden', meta: { hidden: true } }); ``` ### 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, { id: '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 | | ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | `string` | Custom ID — use for updating existing documents. | | `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.hidden` | `boolean` | Hide this row from the Studio UI. | | `meta.invalidate` | `boolean` | When `false`, prevents the previous version with the same `id` 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` | | `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`** — 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. Save manually to seed system messages or inject synthetic turns; the LLM provider tools save these for you in normal use. - **`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: __dirname + '/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: __dirname + '/file-document.yaml', }) export class FileDocument { filename: string; description: string; code: string; } ``` ```yaml # file-document.yaml type: document ui: widgets: - widget: form options: order: [filename, description, code] properties: filename: title: File Name readonly: true description: title: Description readonly: true code: title: Code widget: code-view ``` Used in a workflow: ```typescript const result = await this.llmGenerateObject.call( { outputSchema: toJSONSchema(FileDocumentSchema) as Record, prompt: 'Generate a Hello World script in Python', }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); ``` ## Registry References - [prompt-structured-output-example-workflow](https://loopstack.ai/registry/loopstack-prompt-structured-output-example-workflow) — FileDocument with code-view widget for AI-generated code - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-meeting-notes-example-workflow) — MeetingNotesDocument and OptimizedNotesDocument with form widgets and action buttons - [test-ui-documents-example-workflow](https://loopstack.ai/registry/loopstack-test-ui-documents-example-workflow) — Demonstrates all core UI document types: MessageDocument, ErrorDocument, MarkdownDocument, PlainDocument --- > **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-module-config-example) 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: __dirname + '/chat.ui.yaml', }) export class ChatWorkflow extends BaseWorkflow { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly myTool: MyCustomTool, ) { super(); } } ``` Sub-workflows are also injected via constructor: ```typescript export class ParentWorkflow extends BaseWorkflow { constructor(private readonly subWorkflow: SubWorkflow) { super(); } } ``` ## Using in Loopstack Studio Once registered: 1. Open Loopstack Studio at `http://localhost:5173` 2. Your workspace appears in the sidebar 3. Click a workflow to create a new run 4. Fill in the input form and start the workflow ## Registry References - [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) — Example module with ClaudeModule import - [custom-tool-example-module](https://loopstack.ai/registry/loopstack-custom-tool-example-module) — Module with custom tools, services, and workflow providers - [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-run-sub-workflow-example) — Module registering both parent and sub-workflow providers --- > Source: https://loopstack.ai/llms/build/fundamentals/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, ToolResult } 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()`. ## ToolResult ```typescript type ToolResult = { type?: 'text' | 'image' | 'file'; data?: TData; error?: string; metadata?: Record; }; ``` Return patterns: ```typescript return { data: 42 }; // Simple value return { data: { name: 'result', items: [...] } }; // Typed data return { error: 'Something went wrong' }; // Error return { type: 'text', data: 'Mostly sunny, 14C.' }; // Typed output return { data: result, metadata: { tokensUsed: 150 } }; // With metadata ``` ## Dependency Injection Use standard NestJS `@Inject()` or constructor injection: ```typescript import { Inject } from '@nestjs/common'; @Tool({ name: 'math_sum', description: 'Calculates the sum of two numbers.', schema: z.object({ a: z.number(), b: z.number() }).strict(), }) export class MathSumTool extends BaseTool<{ a: number; b: number }, object, number> { constructor(private readonly mathService: MathService) { super(); } protected async handle(args: { a: number; b: number }, ctx: 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 `ToolResult` 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): Promise { const result = await this.myTool.call({ query: 'hello', limit: 5 }); return { ...state, searchResults: result.data }; } ``` ## Module Registration ```typescript @Module({ providers: [SearchTool, MathService], exports: [SearchTool], }) export class MyToolModule {} ``` Then import the module in the workflow's parent module. ## File Structure ``` src/ ├── tools/ │ ├── search.tool.ts │ ├── math-sum.tool.ts │ └── index.ts # Re-exports all tools ├── services/ │ └── math.service.ts ├── my-feature.module.ts └── index.ts ``` ## Registry References - [custom-tool-example-module](https://loopstack.ai/registry/loopstack-custom-tool-example-module) — MathSumTool with injected service, stateful CounterTool, and workflow demonstrating tool usage - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — GetWeather tool exposed to the LLM for function calling --- > **Using an AI coding agent?** See [Skill: Create a Custom Tool](../../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. ## Chat Example A simple chat workflow: wait for a user message, call LLM, display the response, and loop back. ```typescript import { z } from 'zod'; import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import { LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider-module'; @Workflow({ widget: __dirname + '/chat.ui.yaml', // UI config }) export class ChatWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } // 1. Entry point @Transition({ to: 'waiting_for_user' }) async setup(state: Record): Promise> { return state; } // 2. Wait for user message @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string(), }) async userMessage(state: Record, payload: string): Promise> { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: payload }); return state; } // 3. Call LLM and loop back @Transition({ from: 'ready', to: 'waiting_for_user', }) async llmTurn(state: Record): Promise> { const result = await this.llmGenerateText.call({}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }); await this.documentStore.save(LlmMessageDocument, result.data!.message, { meta: { response: result.data!.response, provider: (result.metadata as { provider: string })?.provider }, }); return state; } } ``` That's a complete workflow. The state flow is: ``` start → waiting_for_user → [user sends message] → ready → llmTurn → waiting_for_user (loop) ``` ## The `@Workflow` Decorator ```typescript @Workflow({ widget: __dirname + '/chat.ui.yaml', }) ``` 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: __dirname + '/prompt.ui.yaml', schema: z.object({ subject: z.string().default('coffee'), }), }) ``` ## `BaseWorkflow` All workflows extend `BaseWorkflow`, which provides: | Property / Method | Description | | -------------------- | ----------------------------------------------------------------------------------- | | `this.documentStore` | Save and query documents via `this.documentStore.save(DocClass, content, options?)` | | `this.render` | Render Handlebars templates via `this.render(templatePath, data?)` | Context is passed as a parameter to transition methods via `ctx: 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() }) }) async setup(state: MyState, ctx: RunContext<{ subject: string }>): Promise { return { ...state, 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' }) async setup(state: MyState, ctx: RunContext<{ subject: string }>): Promise { return { ...state, 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): Promise { const result = await this.myTool.call({ query: 'hello' }); return { ...state, data: result.data }; } ``` A method can listen on **multiple source states**: ```typescript @Transition({ from: 'ready', to: 'prompt_executed' }) @Transition({ from: 'tools_done', to: 'prompt_executed' }) async llmTurn(state: MyState): Promise { ... } ``` ### Final Transition — Completion Uses `@Transition` with `to: 'end'`. The return value is the workflow's output (passed to parent workflow callbacks). ```typescript @Transition({ from: 'done', to: 'end' }) async finish(state: MyState): Promise<{ concept: string }> { return { concept: state.confirmedConcept! }; } ``` ## State State is managed through a typed state interface passed as a parameter and returned from transitions: ```typescript interface MyState { counter: number; llmResult?: LlmGenerateTextResult; } export class MyWorkflow extends BaseWorkflow, MyState> { @Transition({ from: 'ready', to: 'processed' }) async process(state: MyState): Promise { return { ...state, counter: (state.counter ?? 0) + 1 }; } } ``` Values persist even when the workflow pauses and resumes. ### 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, z.infer> { @Transition({ from: 'ready', to: 'processed' }) async process(state) { return { ...state, 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): Promise { const result = await this.llmGenerateText.call( { prompt: 'Write a haiku' }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); return { ...state, llmResult: result.data }; } ``` ## Documents Use `this.documentStore.save()` to create or update documents. Reference document classes directly — no injection needed. ```typescript // Create a document await this.documentStore.save(LlmMessageDocument, { role: 'user', text: 'Hello!', }); // Update an existing document by ID await this.documentStore.save( LlmMessageDocument, { role: 'assistant', text: 'Updated response' }, { id: 'response-1' }, ); // Hidden document (not shown in UI) await this.documentStore.save(LlmMessageDocument, { role: 'user', text: 'System prompt' }, { meta: { hidden: true } }); ``` ## Templates `render` is available directly on `BaseWorkflow` (like `documentStore`). Use `this.render()` to render Handlebars template files: ```typescript const rendered = this.render(__dirname + '/templates/prompt.md', { subject: args.subject, }); ``` ## Wait Transitions Add `wait: true` to pause the workflow until externally triggered — by user input, a button click, or a sub-workflow callback. ```typescript @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.object({ message: z.string() }), }) async userMessage(state: MyState, payload: { message: string }): Promise { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: payload.message, }); return state; } ``` Use `schema` to validate and type the incoming payload. ## Guards (Conditional Routing) When multiple transitions share the same `from` state, use `@Guard` to choose which one fires. Higher `priority` is checked first. A transition without a guard acts as the fallback. ```typescript @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: MyState): Promise { ... } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: MyState): Promise { ... } // Fallback — no guard hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } ``` ## Places (States) Places are implicit — defined by `from`/`to` values in your decorators. Two special places: - **`start`** — Implicit initial place (the initial transition moves from here when `from` is omitted) - **`end`** — When reached, the workflow completes All other place names are arbitrary strings you choose. ## YAML Configuration YAML files define **UI layout only** — no transitions, conditions, or tool calls. They configure what widgets appear in the Studio interface. ```yaml title: 'My Workflow' description: 'What this workflow does' ui: widgets: - widget: form enabledWhen: [waiting] options: properties: name: title: Name actions: - type: button transition: userResponse label: Submit - widget: prompt-input enabledWhen: [waiting_for_user] options: transition: userMessage ``` The `transition` values must match **method names** of `wait: true` transitions. ### `enabledWhen` Controls when a widget is visible based on the current workflow place: ```yaml - widget: prompt-input enabledWhen: - waiting_for_user # Only show at this place options: transition: userMessage ``` ### Form Actions Buttons that trigger `wait: true` transitions when clicked: ```yaml actions: - type: button transition: confirm # Must match the method name label: 'Confirm' ``` ## Module Registration ```typescript @Module({ imports: [ClaudeModule], providers: [ChatWorkflow], exports: [ChatWorkflow], }) export class ChatModule {} ``` ## File Structure ``` src/ ├── workflows/ │ ├── chat.workflow.ts │ ├── chat.ui.yaml │ └── templates/ │ └── systemMessage.md ├── chat.module.ts └── index.ts ``` ## Registry References - [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) — Multi-turn chat workflow (the minimal example on this page) - [prompt-example-workflow](https://loopstack.ai/registry/loopstack-prompt-example-workflow) — Simple single-turn prompt workflow - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — Tool calling loop with guards and conditional routing - [dynamic-routing-example-workflow](https://loopstack.ai/registry/loopstack-dynamic-routing-example-workflow) — Multi-level guard-based routing - [workflow-state-example-workflow](https://loopstack.ai/registry/loopstack-workflow-state-example-workflow) — State management with typed state interface - [run-sub-workflow-example](https://loopstack.ai/registry/loopstack-run-sub-workflow-example) — Sub-workflow execution with callbacks --- > **Using an AI coding agent?** See [Skill: Create a Custom Workflow](../../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, LlmMessageDocument } from '@loopstack/llm-provider-module'; const InputSchema = z.object({ name: z.string().default('World'), }); type InputArgs = z.infer; @Workflow({ title: 'Hello World', description: 'A simple workflow that greets you by name using an LLM.', schema: InputSchema, }) export class HelloWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); } @Transition({ to: 'end' }) async greet(_state: unknown, ctx: RunContext) { const args = ctx.args as InputArgs; const result = await this.llmGenerateText.call({ prompt: `Say hello to ${args.name} in a fun way in one sentence.`, }); await this.documentStore.save(LlmMessageDocument, result.data!.message); } } ``` Create `src/hello/hello.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { ClaudeModule } from '@loopstack/claude-module'; import { StudioApp } from '@loopstack/common'; import { LlmProviderModule } from '@loopstack/llm-provider-module'; import { HelloWorkflow } from './hello.workflow'; @StudioApp({ title: 'Hello World App', workflows: [HelloWorkflow], }) @Module({ imports: [ClaudeModule, LlmProviderModule.forFeature({ model: 'claude-sonnet-4-5' })], providers: [HelloWorkflow], }) export class HelloModule {} ``` Register it in `src/app.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { LoopstackModule } from '@loopstack/loopstack-module'; import { HelloModule } from './hello/hello.module'; @Module({ imports: [LoopstackModule.forRoot(), HelloModule], }) export class AppModule {} ``` Set your Anthropic API key in `.env`: ``` ANTHROPIC_API_KEY=sk-ant-... ``` Restart the dev server. Open Studio at [http://localhost:5173](http://localhost:5173) — you'll see the **Hello World App**. Start a new run, enter your name, and the LLM will greet you. ## Next steps - [Core Concepts](../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, CallbackSchema, Guard, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; import { MarkdownDocument } from '@loopstack/common'; import { OAuthWorkflow } from '@loopstack/oauth-module'; interface CalendarState { events?: CalendarEvent[]; requiresAuthentication?: boolean; } @Workflow({ widget: __dirname + '/calendar.ui.yaml' }) export class CalendarWorkflow extends BaseWorkflow<{ calendarId: string }, CalendarState> { constructor( private readonly calendarFetchEvents: CalendarFetchEventsTool, private readonly oAuth: OAuthWorkflow, ) { super(); } @Transition({ to: 'calendar_fetched' }) async fetchEvents(state: CalendarState, ctx: RunContext): Promise { const args = ctx.args as { calendarId: string }; const result = await this.calendarFetchEvents.call({ calendarId: args.calendarId, }); return { ...state, requiresAuthentication: result.data!.error === 'unauthorized', events: result.data!.events, }; } // If unauthorized -> launch OAuth sub-workflow @Transition({ from: 'calendar_fetched', to: 'awaiting_auth', priority: 10 }) @Guard('needsAuth') async authRequired(state: CalendarState): Promise { await this.oAuth.run( { provider: 'google', scopes: ['https://www.googleapis.com/auth/calendar.readonly'] }, { callback: { transition: 'authCompleted' }, show: 'inline', label: 'Google authentication required' }, ); return state; } needsAuth(state: CalendarState): boolean { return !!state.requiresAuthentication; } // After auth -> retry from start @Transition({ from: 'awaiting_auth', to: 'start', wait: true, schema: CallbackSchema }) async authCompleted(state: CalendarState, _payload: { workflowId: string }): Promise { return state; } // Success -> display results @Transition({ from: 'calendar_fetched', to: 'end' }) async displayResults(state: CalendarState): Promise { await this.documentStore.save(MarkdownDocument, { markdown: this.render(__dirname + '/templates/summary.md', { events: state.events }), }); return {}; } } ``` ## Using Tokens in Custom Tools ```typescript import { z } from 'zod'; import { BaseTool, Tool, ToolResult } from '@loopstack/common'; import type { 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-google-oauth-example) — Google Calendar fetch with OAuth sub-workflow, custom calendar tool, and Google Workspace agent with tool calling - [github-oauth-example](https://loopstack.ai/registry/loopstack-github-oauth-example) — GitHub OAuth integration with repos overview and GitHub agent with 25+ tools --- > Source: https://loopstack.ai/llms/build/integrations/programmatic-execution.md --- title: Programmatic Workflow Execution description: Starting and managing workflows from code using WorkflowRunner. Covers triggering from API requests, webhooks, cron jobs, batch processing, and internal events. --- # Running Workflows Programmatically Start and manage workflows programmatically from your NestJS application — in response to API requests, webhook events, cron jobs, or internal application logic — without going through the Studio UI. ## Overview Use the `WorkflowRunner` to execute workflows in response to: - External API requests - Webhook events - Scheduled cron jobs - Internal application events - Batch processing tasks ## Basic Example ### Create a Controller ```typescript import { Body, Controller, Post } from '@nestjs/common'; import { WorkflowRunner } from '@loopstack/core'; import { MyWorkflow } from './workflows/my.workflow'; @Controller() export class AppController { constructor(private readonly workflowRunner: WorkflowRunner) {} @Post('run-my-workflow') async runMyWorkflow(@Body() payload: any) { const userId = '...'; // define a user id to run the workflow const result = await this.workflowRunner.run(MyWorkflow, payload, { appName: 'default', userId, }); return { message: 'Workflow run is queued.', workflowId: result.workflowId }; } } ``` ### WorkflowRunner Methods ```typescript // Async (queued via BullMQ) await this.workflowRunner.run( workflow, // Workflow class reference args, // Data passed as workflow args (type-safe) { appName, // App name for workspace resolution userId, // User ID for execution context }, ); // Sync (inline execution, awaits result) await this.workflowRunner.runSync( workflow, // Workflow class reference args, // Data passed as workflow args (type-safe) { appName, // App name for workspace resolution userId, // User ID for execution context stateless, // Optional: skip persistence (default: false) }, ); ``` ## Advanced Examples ### Webhook Handler Trigger a workflow when receiving a webhook: ```typescript import { Body, Controller, Headers, Post } from '@nestjs/common'; import { WorkflowRunner } from '@loopstack/core'; @Controller('webhooks') export class WebhookController { constructor(private readonly workflowRunner: WorkflowRunner) {} @Post('stripe') async handleStripeWebhook(@Body() webhookData: any, @Headers('stripe-signature') signature: string) { await this.workflowRunner.run( ProcessWebhookWorkflow, { source: 'stripe', event: webhookData, receivedAt: new Date().toISOString(), }, { appName: 'main', userId: webhookData.userId, }, ); return { received: true }; } } ``` ### Scheduled Task Execute a workflow on a schedule using NestJS's `@Cron` decorator: ```typescript import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { WorkflowRunner } from '@loopstack/core'; @Injectable() export class TaskScheduler { constructor(private readonly workflowRunner: WorkflowRunner) {} @Cron(CronExpression.EVERY_DAY_AT_9AM) async generateDailyReports() { const users = await this.getUsersWithReportsEnabled(); for (const user of users) { await this.workflowRunner.run( DailyReportWorkflow, { reportDate: new Date().toISOString(), reportType: 'daily', }, { appName: 'reports', userId: user.id, }, ); } } } ``` ### Batch Processing Process multiple items by triggering workflows in parallel: ```typescript import { Injectable } from '@nestjs/common'; import { WorkflowRunner } from '@loopstack/core'; @Injectable() export class OrderProcessingService { constructor(private readonly workflowRunner: WorkflowRunner) {} async processPendingOrders() { const pendingOrders = await this.getPendingOrders(); const promises = pendingOrders.map((order) => this.workflowRunner.run( ProcessOrderWorkflow, { orderId: order.id, orderData: order, }, { appName: 'orders', userId: order.userId, }, ), ); await Promise.all(promises); return { processed: pendingOrders.length }; } } ``` --- > Source: https://loopstack.ai/llms/build/integrations/sandbox.md --- title: Sandbox Execution description: Executing untrusted code in Docker containers using @loopstack/sandbox-tool and @loopstack/sandbox-filesystem. Setup, file I/O inside sandboxes, and cleanup. --- # Sandbox Execution Run untrusted or AI-generated code safely in isolated Docker containers. The sandbox packages provide tools for creating disposable execution environments with filesystem access, letting workflows execute arbitrary code without risking the host system. ## 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, ToolResult, 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; } @Workflow({ widget: __dirname + '/sandbox.ui.yaml', schema: z.object({ outputDir: z.string().default(process.cwd() + '/out') }), }) export class SandboxWorkflow extends BaseWorkflow<{ outputDir: string }, SandboxState> { constructor( private readonly sandboxInit: SandboxInit, private readonly sandboxDestroy: SandboxDestroy, private readonly sandboxWriteFile: SandboxWriteFile, private readonly sandboxReadFile: SandboxReadFile, private readonly sandboxCreateDirectory: SandboxCreateDirectory, ) { super(); } @Transition({ to: 'sandbox_ready' }) async initSandbox(state: SandboxState, ctx: RunContext): Promise { const args = ctx.args as { outputDir: string }; const result: ToolResult = await this.sandboxInit.call({ containerId: 'my-sandbox', imageName: 'node:18', containerName: 'sandbox-container', projectOutPath: args.outputDir, rootPath: 'workspace', }); return { ...state, containerId: result.data.containerId }; } @Transition({ from: 'sandbox_ready', to: 'file_written' }) async writeFile(state: SandboxState): Promise { await this.sandboxCreateDirectory.call({ containerId: state.containerId!, path: '/workspace/src', recursive: true, }); await this.sandboxWriteFile.call({ containerId: state.containerId!, path: '/workspace/src/hello.js', content: "console.log('Hello from sandbox!');", encoding: 'utf8', createParentDirs: true, }); return state; } @Transition({ from: 'file_written', to: 'file_read' }) async readFile(state: SandboxState): Promise { await this.sandboxReadFile.call({ containerId: state.containerId!, path: '/workspace/src/hello.js', encoding: 'utf8', }); return state; } @Transition({ from: 'file_read', to: 'end' }) async destroySandbox(state: SandboxState): Promise { await this.sandboxDestroy.call({ containerId: state.containerId!, removeContainer: true, }); return {}; } } ``` ## Available Tools ### Container Lifecycle | Tool | Args | Description | | ---------------- | ----------------------------------------------------------------- | -------------------------- | | `sandboxInit` | `containerId, imageName, containerName, projectOutPath, rootPath` | Create and start container | | `sandboxDestroy` | `containerId, removeContainer` | Stop/remove container | ### Filesystem Operations | Tool | Args | Description | | ------------------------ | ---------------------------------------------------------- | ---------------------- | | `sandboxWriteFile` | `containerId, path, content, encoding?, createParentDirs?` | Write file | | `sandboxReadFile` | `containerId, path, encoding?` | Read file content | | `sandboxListDirectory` | `containerId, path, recursive?` | List directory entries | | `sandboxCreateDirectory` | `containerId, path, recursive?` | Create directory | | `sandboxDelete` | `containerId, path, recursive?, force?` | Delete file/directory | | `sandboxExists` | `containerId, path` | Check if path exists | | `sandboxFileInfo` | `containerId, path` | Get file metadata | ### Command Execution | Tool | Args | Description | | ---------------- | ----------------------------------------------------------------------- | ------------------------ | | `sandboxCommand` | `containerId, executable, args?, workingDirectory?, envVars?, timeout?` | Run command in container | ## Security - Path traversal detection and prevention - Shell argument escaping - Isolated Docker containers with volume mounting - Configurable timeouts on command execution ## Registry References - [sandbox-example-workflow](https://loopstack.ai/registry/loopstack-sandbox-example-workflow) — Full sandbox lifecycle: init, create directory, write/read files, list directory, check existence, get file info, delete, and destroy --- > Source: https://loopstack.ai/llms/build/integrations/secrets.md --- title: Secrets Management description: Requesting, storing, and retrieving secrets (API keys, tokens) at runtime using RequestSecretsTool, RequestSecretsTask, and GetSecretKeysTool from @loopstack/secrets-module. --- # Secrets Management Loopstack provides built-in tools for requesting and retrieving secrets (API keys, tokens, etc.) from users at runtime. ## Overview Secrets are requested from the user via `RequestSecretsTool` and 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): Promise { await this.syncSecrets.call({}); return state; } ``` ## Available Tools | Tool | Source | Description | | ----------------------- | --------------------------- | -------------------------------------------------------- | | `RequestSecretsTool` | `@loopstack/secrets-module` | Request secrets from the user via a UI prompt | | `RequestSecretsTask` | `@loopstack/secrets-module` | Agent-friendly task that launches a secrets sub-workflow | | `GetSecretKeysTool` | `@loopstack/secrets-module` | List stored secret keys and their availability | | `SecretRequestDocument` | `@loopstack/secrets-module` | Document displaying the secret input form | ## Example Workflow ```typescript import { BaseWorkflow, ToolResult, Transition, Workflow } from '@loopstack/common'; import { MarkdownDocument } from '@loopstack/common'; import { GetSecretKeysTool, RequestSecretsTool, SecretRequestDocument } from '@loopstack/secrets-module'; interface SecretsState { secretKeys?: Array<{ key: string; hasValue: boolean }>; } @Workflow({ widget: __dirname + '/secrets-example.ui.yaml' }) export class SecretsExampleWorkflow extends BaseWorkflow, SecretsState> { constructor( private readonly requestSecrets: RequestSecretsTool, private readonly getSecretKeys: GetSecretKeysTool, ) { super(); } @Transition({ to: 'requesting_secrets' }) async requestSecretsFromUser(state: SecretsState): Promise { await this.requestSecrets.call({ variables: [{ key: 'EXAMPLE_API_KEY' }, { key: 'EXAMPLE_SECRET' }], }); await this.documentStore.save(SecretRequestDocument, { variables: [{ key: 'EXAMPLE_API_KEY' }, { key: 'EXAMPLE_SECRET' }], }); return state; } @Transition({ from: 'requesting_secrets', to: 'verifying', wait: true }) async secretsSubmitted(state: SecretsState): Promise { const result: ToolResult> = await this.getSecretKeys.call({}); return { ...state, secretKeys: result.data }; } @Transition({ from: 'verifying', to: 'end' }) async showResult(state: SecretsState): Promise { await this.documentStore.save(MarkdownDocument, { markdown: this.render(__dirname + '/templates/secretsVerified.md', { secretKeys: state.secretKeys, }), }); return {}; } } ``` ## How It Works 1. **Request** — `RequestSecretsTool` tells the framework which secrets are needed 2. **Display** — `SecretRequestDocument` shows a secure input form in the UI 3. **Wait** — The workflow pauses (`wait: true`) until the user submits the secrets 4. **Verify** — `GetSecretKeysTool` checks which secrets are now stored 5. **Use** — Secrets are available as environment variables in subsequent tool calls ## Template Example ```markdown # Secrets Verification {{#each secretKeys}} - **{{this.key}}**: {{#if this.hasValue}}Stored{{else}}Missing{{/if}} {{/each}} ``` ## Registry References - [secrets-example-workflow](https://loopstack.ai/registry/loopstack-secrets-example-workflow) — Request secrets from user, verify storage, and display results with both direct workflow and agent-based approaches --- > Source: https://loopstack.ai/llms/build/patterns/dynamic-routing.md --- title: Dynamic Routing description: Conditional workflow routing using @Guard decorators and priority-based transition selection when multiple transitions share the same source state. --- # Dynamic Routing Route workflows conditionally using `@Guard` decorators and `priority` to control which transition fires when multiple transitions share the same source state. ## Basic Guard ```typescript @Transition({ from: 'check', to: 'high', priority: 10 }) @Guard('isHigh') async routeHigh(state: MyState): Promise { return state; } @Transition({ from: 'check', to: 'low' }) async routeLow(state: MyState): Promise { return state; } // Fallback — no guard isHigh(state: MyState): boolean { return state.value > 100; } ``` **How it works:** 1. Transitions with higher `priority` are checked first 2. The `@Guard` references a method that returns a boolean 3. First transition whose guard returns `true` fires 4. A transition without `@Guard` acts as the fallback ## Multi-Level Routing Chain routing decisions with cascading forks: ```typescript import { z } from 'zod'; import { BaseWorkflow, Guard, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; import { MessageDocument } from '@loopstack/common'; interface RoutingState { value?: number; } @Workflow({ schema: z.object({ value: z.number().default(150) }).strict(), }) export class DynamicRoutingExampleWorkflow extends BaseWorkflow<{ value: number }, RoutingState> { @Transition({ to: 'prepared' }) async createMockData(state: RoutingState, ctx: RunContext): Promise { const args = ctx.args as { value: number }; await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Analysing value = ${args.value}`, }); return { ...state, value: args.value }; } // First fork: value > 100? @Transition({ from: 'prepared', to: 'placeA', priority: 10 }) @Guard('isAbove100') async routeToPlaceA(state: RoutingState): Promise { return state; } @Transition({ from: 'prepared', to: 'placeB' }) async routeToPlaceB(state: RoutingState): Promise { return state; } // Fallback: value <= 100 isAbove100(state: RoutingState): boolean { return (state.value ?? 0) > 100; } // Second fork: value > 200? @Transition({ from: 'placeA', to: 'placeC', priority: 10 }) @Guard('isAbove200') async routeToPlaceC(state: RoutingState): Promise { return state; } @Transition({ from: 'placeA', to: 'placeD' }) async routeToPlaceD(state: RoutingState): Promise { return state; } // Fallback: 100 < value <= 200 isAbove200(state: RoutingState): boolean { return (state.value ?? 0) > 200; } // Terminal transitions @Transition({ from: 'placeB', to: 'end' }) async showMessagePlaceB(state: RoutingState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', text: 'Value is less or equal 100' }); return {}; } @Transition({ from: 'placeC', to: 'end' }) async showMessagePlaceC(state: RoutingState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', text: 'Value is greater than 200' }); return {}; } @Transition({ from: 'placeD', to: 'end' }) async showMessagePlaceD(state: RoutingState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', text: 'Value is less or equal 200, but greater than 100', }); return {}; } } ``` ## Routing Flow ``` prepared → [value > 100?] ├─ yes → placeA → [value > 200?] │ ├─ yes → placeC (done) │ └─ no → placeD (done) └─ no → placeB (done) ``` ## Common Patterns ### Tool Call Routing Route based on LLM response (see [AI Tool Calling](../ai/tool-calling.md)): ```typescript @Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 }) @Guard('hasToolCalls') async executeToolCalls(state: MyState): Promise { ... } @Transition({ from: 'prompt_executed', to: 'end' }) async respond(state: MyState): Promise { ... } // Fallback: no tool calls hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } ``` ### Error-Based Routing Route based on a tool's error response: ```typescript @Transition({ from: 'fetched', to: 'auth_needed', priority: 10 }) @Guard('needsAuth') async startAuth(state: MyState): Promise { ... } @Transition({ from: 'fetched', to: 'end' }) async displayResults(state: MyState): Promise { ... } needsAuth(state: MyState): boolean { return state.fetchResult?.error === 'unauthorized'; } ``` ## Guard Method Rules - Guard methods must return a **boolean** (or truthy/falsy value) - They receive `state` as their first parameter - They should be **synchronous** — no async guards - Use descriptive names: `hasToolCalls`, `isAbove100`, `needsAuth` ## Registry References - [dynamic-routing-example-workflow](https://loopstack.ai/registry/loopstack-dynamic-routing-example-workflow) — Multi-level guard-based routing with cascading forks - [tool-call-example-workflow](https://loopstack.ai/registry/loopstack-tool-call-example-workflow) — Guard-based routing for LLM tool call detection --- > Source: https://loopstack.ai/llms/build/patterns/error-handling.md --- title: Error Handling, Retry & Timeout description: Recovering from transition errors with auto-retry and exponential backoff, custom error state transitions, manual retry via Studio UI, and transition timeouts. --- # Error Handling, Retry & Timeout When a transition throws an error, the framework rolls back all changes and gives you three ways to recover: auto-retry with backoff, transition to a custom error state, or manual retry via the UI. ## Retry Modes ### Auto-Retry Automatically re-run a failed transition with exponential backoff: ```typescript @Transition({ from: 'fetching', to: 'done', retry: 3 }) async fetchData() { await this.httpClient.call({ url: 'https://api.example.com/data' }); } ``` If `fetchData` throws, the framework retries up to 3 times with exponential delays (1s, 2s, 4s). The workflow stays at the `fetching` place during retries. ### Custom Error Place Route to a dedicated error state when a transition fails: ```typescript @Transition({ from: 'processing', to: 'done', retry: { place: 'error_processing' } }) async processData() { await this.processor.call({ data: this.rawData }); } @Transition({ from: 'error_processing', to: 'done', wait: true }) async handleProcessingError() { // Recovery logic — user clicks a "Recover" button to trigger this await this.documentStore.save(MessageDocument, { role: 'assistant', 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). ### Manual Retry (Default) When no `retry` config is specified, the workflow stays at the current place and shows a "Retry" button: ```typescript @Transition({ from: 'sending', to: 'sent' }) async sendEmail() { await this.email.call({ to: 'user@example.com', body: this.content }); } ``` If it fails, the user sees the error message and can retry manually. No auto-retry, no error place — just pause and let the user decide. ### Hybrid: Auto-Retry + Error Place Combine auto-retry with a fallback error state: ```typescript @Transition({ from: 'deploying', to: 'deployed', retry: { attempts: 2, place: 'deploy_failed' }, }) async deploy() { await this.deployer.call({ target: 'production' }); } @Transition({ from: 'deploy_failed', to: 'deployed', wait: true }) async retryDeploy() { // Manual recovery after auto-retries exhausted } ``` Retries twice automatically. If both fail, transitions to `deploy_failed` for manual intervention. ## Retry Configuration ```typescript retry: number // Shorthand: number of auto-retry attempts retry: { attempts?: number, // Auto-retry count (0 = skip auto-retry, -1 = manual only) delay?: number, // Base delay in ms (default: 1000) backoff?: 'fixed' | 'exponential', // Backoff strategy (default: 'exponential') maxDelay?: number, // Backoff cap in ms (default: 30000) place?: string, // Custom error place when retries exhausted } ``` **Backoff calculation (exponential):** `delay * 2^(attempt - 1)`, capped at `maxDelay`. | Attempt | Delay (default config) | | ------- | ---------------------- | | 1 | 1,000ms | | 2 | 2,000ms | | 3 | 4,000ms | | 4 | 8,000ms | | 5 | 16,000ms | | 6+ | 30,000ms (capped) | ### 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', retry: 3 }) async fetchData(state: MyState, ctx: RunContext): Promise { 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, retry: 2, }) async analyzeData() { await this.analyzer.call({ dataset: this.data }); } ``` Times out after 5s, retries up to 2 times, then falls to manual retry. ## What Gets Rolled Back When a transition fails, the framework rolls back: - **Documents** created during the transition (restored from snapshot) - **Database changes** within the transition's transaction - **Workflow state** stays at the pre-transition place What is **not** rolled back: - An `ErrorDocument` is saved after rollback as an audit trail - The error message is stored in workflow metadata - Workflow instance variables (`this.someField`) persist across retries — useful for attempt counters ## ErrorDocument Every failed transition creates an `ErrorDocument` with the error message: ```typescript // Automatically created by the framework: { className: 'ErrorDocument', content: { error: 'Connection refused: api.example.com' } } ``` Multiple `ErrorDocument`s accumulate if retries fail repeatedly — giving a full audit trail of each attempt. ## Registry References - [error-retry-example-workflow](https://loopstack.ai/registry/loopstack-error-retry-example-workflow) — Demonstrates all five retry modes: auto-retry, manual retry, custom error place, timeout, and hybrid --- > Source: https://loopstack.ai/llms/build/patterns/human-in-the-loop.md --- title: Human-in-the-Loop description: Pausing workflows for user input, review, or confirmation using wait:true transitions, document UI actions, payload schemas, and approval patterns. --- # Human-in-the-Loop Pause workflows for user input, review, or confirmation using `wait: true` transitions and document UI actions. ## Wait Transition Pattern A transition with `wait: true` pauses the workflow until externally triggered by user interaction: ```typescript @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.object({ message: z.string() }), }) async userMessage(state: Record, payload: { message: string }): Promise> { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: payload.message, }); return state; } ``` ## Document Action Buttons Documents can include buttons that trigger `wait: true` transitions: ```yaml # Document YAML type: document ui: widgets: - widget: form options: properties: text: title: Text widget: textarea actions: - type: button transition: userResponse # Must match the method name label: 'Submit' ``` When the user clicks **Submit**, the workflow's `userResponse` method fires with the document's current content as the payload. ## Chat Input Widget For conversational UIs, use the `prompt-input` widget: ```yaml ui: widgets: - widget: prompt-input enabledWhen: - waiting_for_user options: transition: userMessage ``` ```typescript @Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string(), }) async userMessage(state: Record, payload: string): Promise> { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: payload, }); return state; } ``` ## Confirmation Pattern Show AI-generated content for user review before proceeding: ```typescript import { z } from 'zod'; import { toJSONSchema } from 'zod'; import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; import { LlmGenerateObjectTool } from '@loopstack/llm-provider-module'; interface MeetingNotesState { meetingNotes?: z.infer; } @Workflow({ widget: __dirname + '/meeting-notes.ui.yaml', schema: z.object({ inputText: z.string().default('...') }), }) export class MeetingNotesWorkflow extends BaseWorkflow<{ inputText: string }, MeetingNotesState> { constructor(private readonly llmGenerateObject: LlmGenerateObjectTool) { super(); } @Transition({ to: 'waiting_for_response' }) async createForm(state: MeetingNotesState, ctx: RunContext): Promise { const args = ctx.args as { inputText: string }; await this.documentStore.save(MeetingNotesDocument, { text: args.inputText }, { id: 'input' }); return state; } // Wait for user to edit and submit @Transition({ from: 'waiting_for_response', to: 'response_received', wait: true, schema: MeetingNotesDocumentSchema }) async userResponse( state: MeetingNotesState, payload: z.infer, ): Promise { const result = await this.documentStore.save(MeetingNotesDocument, payload, { id: 'input' }); return { ...state, meetingNotes: result.content as z.infer }; } // AI generates structured output @Transition({ from: 'response_received', to: 'notes_optimized' }) async optimizeNotes(state: MeetingNotesState): Promise { const result = await this.llmGenerateObject.call( { outputSchema: toJSONSchema(OptimizedMeetingNotesDocumentSchema) as Record, prompt: `Structure these notes: ${state.meetingNotes?.text}`, }, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); const objectResult = result.data as LlmGenerateObjectResult; await this.documentStore.save( OptimizedNotesDocument, objectResult.data as z.infer, { id: 'final', validate: 'skip' }, ); return state; } // Wait for user to confirm @Transition({ from: 'notes_optimized', to: 'end', wait: true, schema: OptimizedMeetingNotesDocumentSchema }) async confirm( state: MeetingNotesState, payload: z.infer, ): Promise { await this.documentStore.save(OptimizedNotesDocument, payload, { id: 'final' }); return {}; } } ``` ## `enabledWhen` — Conditional Widgets Show/hide widgets based on the current workflow place: ```yaml ui: widgets: - widget: form enabledWhen: - review - editing options: properties: summary: title: Summary widget: textarea actions: - type: button transition: confirm label: 'Confirm' ``` The widget only appears when the workflow is at the `review` or `editing` place. ## Registry References - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-meeting-notes-example-workflow) — Full human-in-the-loop workflow with editable form, AI optimization, and user confirmation - [chat-example-workflow](https://loopstack.ai/registry/loopstack-chat-example-workflow) — Chat input pattern with prompt-input widget --- > Source: https://loopstack.ai/llms/build/patterns/state-management.md --- title: State Management description: Defining, reading, and updating typed workflow state. Covers state interfaces, BaseWorkflow generics, state persistence across transitions, and state access patterns. --- # State Management Workflow state is managed through a typed state interface and passed as the first parameter to transition methods. State is returned from each transition and automatically persisted across transitions. ## Defining State Define a state interface and pass it as a generic to `BaseWorkflow`: ```typescript interface MyState { counter?: number; llmResult?: LlmGenerateTextResult; items?: string[]; } export class MyWorkflow extends BaseWorkflow, MyState> { // ... } ``` 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 (or any transition that returns `{}` to reset) will fail validation immediately. Treat missing fields as the absence of data and read them defensively (`state.counter ?? 0`). ## Writing State Return updated state from transition methods: ```typescript @Transition({ from: 'ready', to: 'processed' }) async process(state: MyState): Promise { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }, ); return { ...state, llmResult: result.data, counter: (state.counter ?? 0) + 1, items: [...(state.items ?? []), 'new item'], }; } ``` ### Return value policy | Return | Effect | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `return;` / `return undefined` | Previous state is preserved unchanged. Use this when the transition has no state to write. | | `return { ... }` | Object becomes the new state. Validated against `stateSchema` if defined. | | `return {}` | State is reset to an empty object (validated against `stateSchema` if defined). | | `return null` / primitive | The returned value becomes the new state. If `stateSchema` is defined, the transition throws — `null` and primitives don't satisfy an object schema. | Returning a new state always replaces the previous state — there is no automatic merge. Spread the previous state (`return { ...state, ... }`) when you want to keep existing fields. ## Reading State Access state in any transition or guard method: ```typescript @Transition({ from: 'processed', to: 'end' }) async display(state: MyState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Processed ${state.counter} items. Result: ${state.llmResult?.text}`, }); return {}; } hasToolCalls(state: MyState): boolean { return state.llmResult?.message.stopReason === 'tool_use'; } ``` ## Persistence Across Pauses State survives when a workflow pauses at a `wait: true` transition and resumes later: ```typescript @Transition({ to: 'waiting' }) async setup(state: MyState): Promise { return { ...state, counter: 42 }; // Set before pause } @Transition({ from: 'waiting', to: 'end', wait: true }) async onResume(state: MyState): Promise { // state.counter is still 42 return {}; } ``` ## Accessing Workflow Args Input arguments are available via `ctx.args`: ```typescript @Workflow({ schema: z.object({ value: z.number().default(150) }), }) export class MyWorkflow extends BaseWorkflow<{ value: number }, MyState> { @Transition({ to: 'ready' }) async setup(state: MyState, ctx: RunContext): Promise { const args = ctx.args as { value: number }; console.log(args.value); // 150 return state; } } ``` ## Helper Methods Use regular private methods for reusable logic — no special decorator needed: ```typescript export class MyWorkflow extends BaseWorkflow, MyState> { @Transition({ from: 'data_created', to: 'end' }) async showResults(state: MyState): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', text: this.formatMessage(state.message!), }); return {}; } private formatMessage(text: string): string { return text.toUpperCase(); } } ``` ## Registry References - [workflow-state-example-workflow](https://loopstack.ai/registry/loopstack-workflow-state-example-workflow) — Stores state in typed state interface, accesses in transitions, uses helper methods - [accessing-tool-results-example-workflow](https://loopstack.ai/registry/loopstack-accessing-tool-results-example-workflow) — Storing and accessing tool results via workflow state --- > Source: https://loopstack.ai/llms/build/patterns/sub-workflows.md --- title: Sub-Workflows description: Running workflows inside other workflows via .run(), the show option ('inline' | 'link' | 'hidden') for parent-view rendering, callback transitions, passing arguments to child workflows, and receiving sub-workflow results. --- # Sub-Workflows Sub-workflows let you compose complex automations from smaller, reusable workflow building blocks. A parent workflow can launch one or more child workflows via `.run()`, pause until they complete, and receive results through a callback transition. ## Injecting a Sub-Workflow ```typescript import { CallbackSchema, QueueResult } from '@loopstack/common'; constructor(private readonly subWorkflow: SubWorkflow) { super(); } ``` ## Running a Sub-Workflow ```typescript @Transition({ to: 'sub_started' }) async start(state: MyState): Promise { await this.subWorkflow.run( { prompt: 'Hello' }, // Args passed to the sub-workflow { callback: { transition: 'onSubComplete' } }, // Method to call when done ); return state; } ``` 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 final transition return value is passed as `payload.data`: ```typescript const SubWorkflowCallbackSchema = CallbackSchema.extend({ data: z.object({ message: z.string() }), }); @Transition({ from: 'sub_started', to: 'sub_done', wait: true, schema: SubWorkflowCallbackSchema, }) async onSubComplete( state: MyState, payload: { workflowId: string; status: string; data: { message: string } }, ): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Sub-workflow said: ${payload.data.message}`, }); return state; } ``` ## Sub-Workflow Output The sub-workflow defines its output as the return value of its final transition: ```typescript @Workflow({ widget: __dirname + '/sub.ui.yaml' }) export class SubWorkflow extends BaseWorkflow { @Transition({ to: 'end' }) async start(): Promise<{ message: string }> { return { message: 'Hi mom!' }; } } ``` ## Complete Example ```typescript @Workflow({ widget: __dirname + '/parent.ui.yaml' }) export class ParentWorkflow extends BaseWorkflow { constructor(private readonly subWorkflow: SubWorkflow) { super(); } @Transition({ to: 'sub_started' }) async runWorkflow(state: Record): Promise> { await this.subWorkflow.run( {}, { callback: { transition: 'subWorkflowCallback' }, show: 'link', label: 'Sub-Workflow' }, ); return state; } @Transition({ from: 'sub_started', to: 'end', wait: true, schema: CallbackSchema.extend({ data: z.object({ message: z.string() }) }), }) async subWorkflowCallback( state: Record, payload: { workflowId: string; status: string; data: { message: string } }, ): Promise { await this.documentStore.save(MessageDocument, { role: 'assistant', text: `Message from sub-workflow: ${payload.data.message}`, }); return {}; } } ``` ## Registering Sub-Workflows Both workflows must be registered in the module: ```typescript @Module({ providers: [ParentWorkflow, SubWorkflow], exports: [ParentWorkflow, SubWorkflow], }) export class MyModule {} ``` ## Wrapping as a Task Tool A task tool is a `BaseTool` that launches a sub-workflow and returns `pending`. The framework calls `complete()` when the sub-workflow finishes. This lets agents decide when to run sub-workflows. ```typescript @Tool({ name: 'run_tests', description: 'Run tests in the specified directory.', schema: z.object({ testDirectory: z.string().describe('Directory containing the test files to run.'), }), }) export class RunTestsTask extends BaseTool { constructor(private readonly testRunner: TestRunnerWorkflow) { super(); } protected async handle( args: { testDirectory: string }, ctx: 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-run-sub-workflow-example) — Parent workflow calling a sub-workflow with callbacks and output passing - [@loopstack/code-agent](https://loopstack.ai/registry/loopstack-code-agent) — ExploreTask wrapping AgentWorkflow as a task tool --- > Source: https://loopstack.ai/llms/build/patterns/templates.md --- title: Template Expressions description: Rendering dynamic text content with Handlebars templates via this.render(). Covers template syntax, variable interpolation, helpers, and use in prompts. --- # Template Expressions Use Handlebars templates to render dynamic text content in workflows. Call `this.render()` from any `BaseWorkflow` to interpolate state values, format prompts, and generate dynamic output. ## Setup ```typescript import { BaseWorkflow, Transition, Workflow } from '@loopstack/common'; @Workflow({}) export class MyWorkflow extends BaseWorkflow { // this.render() is available from BaseWorkflow — no injection needed } ``` ## Usage ```typescript const rendered = this.render(__dirname + '/templates/prompt.md', { subject: args.subject, items: state.items, }); ``` Template file (`templates/prompt.md`): ```markdown Write a haiku about {{subject}}. {{#each items}} - {{this.name}} {{/each}} ``` ## Passing Data Pass any data as the second argument to `this.render()`: ```typescript // Workflow args (from ctx parameter) const args = ctx.args as { subject: string }; this.render(templatePath, { subject: args.subject }); // Workflow state (from state parameter) this.render(templatePath, { items: state.items, count: state.counter }); // Mixed data this.render(templatePath, { prompt: args.prompt, history: state.conversationHistory, timestamp: new Date().toISOString(), }); ``` ## Handlebars Syntax ### Variables ```markdown Hello {{name}} Nested: {{user.profile.email}} Array element: {{items.[0]}} ``` ### Conditionals ```markdown {{#if isActive}}Welcome back!{{else}}Please log in{{/if}} {{#unless isBlocked}}Access granted{{/unless}} ``` ### Iteration ```markdown {{#each items}} - {{this.name}}: {{this.value}} {{else}} No items found. {{/each}} ``` ### Context Scoping ```markdown {{#with user}}{{name}} ({{email}}){{/with}} ``` ## Multi-line Template Example ```markdown # Events This Week {{#each events}} - **{{this.summary}}**: {{this.start}} – {{this.end}} {{/each}} {{#unless events}} No events found. {{/unless}} ``` ## When to Use Templates | Scenario | Approach | | ----------------------------------- | --------------------------------- | | LLM prompts with variables | `this.render(templatePath, data)` | | Simple string interpolation | Template literals in TypeScript | | Complex multi-line content | Handlebars template file | | Prompts with iteration/conditionals | Handlebars with `#each`, `#if` | ## YAML UI Config YAML widget configuration uses `transition` values that reference method names and `enabledWhen` for conditional visibility. These are not template expressions — they are static configuration: ```yaml ui: widgets: - widget: prompt-input enabledWhen: [waiting_for_user] options: transition: userMessage ``` ## Registry References - [prompt-example-workflow](https://loopstack.ai/registry/loopstack-prompt-example-workflow) — Uses `this.render()` for Handlebars prompt templates - [meeting-notes-example-workflow](https://loopstack.ai/registry/loopstack-meeting-notes-example-workflow) — Uses templates for structured note rendering --- > Source: https://loopstack.ai/llms/build/troubleshooting.md --- title: Troubleshooting description: Solutions to common Loopstack setup and runtime issues — YAML assets missing at runtime, Studio not connecting to the backend, and wait transitions that never fire. --- # Troubleshooting ## YAML widget file not found at runtime **Symptom:** Your workflow starts but throws an error like `ENOENT: no such file or directory` referencing a `.yaml` file, or Studio shows no UI widgets. **Cause:** NestJS's TypeScript compiler strips non-TS files during build. YAML files are not copied to `dist/` unless explicitly configured. **Fix:** Add a YAML assets rule to `nest-cli.json`: ```json { "compilerOptions": { "assets": ["**/*.yaml"] } } ``` Then restart the dev server (`npm run start:dev`) — NestJS watches and copies asset files on change. **Also check:** The path passed to `widget:` or `this.render()` uses `__dirname`, which resolves to the compiled file's location in `dist/`. Make sure you're using: ```typescript @Document({ widget: __dirname + '/my-document.yaml', }) ``` Not a hardcoded path like `'src/my-feature/my-document.yaml'`. --- ## Studio shows blank or can't reach the backend **Symptom:** Opening `http://localhost:5173` shows an empty screen, a connection error, or no workflows/runs appear. **Cause:** Studio is a static web app that connects to your NestJS backend via the `VITE_API_URL` environment variable. If it's not set, it defaults to `http://localhost:3000`. If your backend is on a different port or host, Studio can't find it. **Fix:** Set `VITE_API_URL` in your `.env` file before starting the Docker Compose stack: ```dotenv VITE_API_URL=http://localhost:3000 ``` If you changed the NestJS default port (e.g. via `app.listen(8080)`), update `VITE_API_URL` to match. After changing `.env`, restart the stack: ```shell docker compose -f node_modules/@loopstack/loopstack-module/docker-compose.yml down docker compose -f node_modules/@loopstack/loopstack-module/docker-compose.yml up -d ``` --- ## `wait: true` transition never fires when clicking a button **Symptom:** You click a button in Studio and nothing happens — the workflow stays paused and doesn't advance. **Cause:** The `transition:` value in your YAML widget config must exactly match the **method name** of the `wait: true` transition in your workflow class. If there's any mismatch (typo, different casing), Studio sends the trigger but the engine can't find the transition. **Fix:** Make sure the names match exactly. In your document YAML: ```yaml actions: - type: button transition: confirm # ← must match the method name label: Confirm ``` In your workflow: ```typescript @Transition({ from: 'reviewing', to: 'end', wait: true }) async confirm(state: MyState, payload: unknown): Promise { // ↑ must match the transition: value above } ``` The same applies to `prompt-input` widgets: ```yaml widget: prompt-input options: transition: userMessage # ← must match the method name ``` --- # Extend How to add custom LLM providers and OAuth providers to the Loopstack registry. --- > Source: https://loopstack.ai/llms/extend/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 { ToolExecutionContext, ToolInterceptor, ToolResult, 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. --- > 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' ``` ## 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: { hidden: false, mimeType: 'text/markdown', level: 'info' }, }) export class ReportDocument { /* ... */ } ``` | Property | Type | Description | | ---------------- | ------------------------------------------- | ------------------------------------------------------------------------------- | | `hidden` | `boolean` | Hide every instance of this document type from the Studio UI by default. | | `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, { id: 'doc-1', meta: { hidden: true, invalidate: false } }); ``` | Property | Type | Description | | --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalidate` | `boolean` | **Opt-out of replacement.** Default behavior (omitted/`true`) invalidates the previous version when reusing the same `id`. 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. | > `hidden` also appears in `DocumentSaveOptions.meta` and overrides the static `hidden` value for this single row — handy when most rows should be visible but a specific one should be tucked away. ### Supported MIME Types `text/plain`, `text/html`, `text/markdown`, `text/css`, `text/xml`, `application/json`, `application/javascript`, `application/typescript`, `application/yaml`, `application/xml` ## Complete Example ```yaml type: document description: 'Generated code file' tags: - code - generated ui: widgets: - widget: form options: order: [filename, description, code] properties: filename: title: File Name readonly: true description: title: Description readonly: true widget: textarea code: title: Code widget: code-view actions: - type: button transition: confirm label: 'Accept' ``` --- > Source: https://loopstack.ai/llms/reference/imports.md --- title: Import Directory description: Quick-reference for all @loopstack/* import paths — workflows, tools, documents, LLM providers, OAuth, sandbox, secrets, and agent module exports. --- # Import Directory Quick-reference for all import paths. ## `@loopstack/common` ```typescript // Workflows import { BaseWorkflow, CallbackSchema, Guard, QueueResult, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; // Tools import { BaseTool, ServerTool, Tool, ToolResult } from '@loopstack/common'; import type { ToolCallOptions } from '@loopstack/common'; // Documents import { Document, DocumentEntity } from '@loopstack/common'; // Built-in Documents import { ErrorDocument, LinkDocument, MarkdownDocument, MessageDocument, PlainDocument } from '@loopstack/common'; // Apps import { StudioApp } from '@loopstack/common'; ``` ## `@loopstack/core` ```typescript import { LoopCoreModule } from '@loopstack/core'; import { WorkflowRunner } from '@loopstack/core'; ``` ## `@loopstack/llm-provider-module` ```typescript import { LlmDelegateResult, LlmDelegateToolCallsTool, LlmGenerateObjectResult, LlmGenerateObjectTool, LlmGenerateTextResult, LlmGenerateTextTool, LlmMessageDocument, LlmProviderRegistry, LlmResultMeta, LlmUpdateToolResultTool, } from '@loopstack/llm-provider-module'; ``` ## `@loopstack/claude-module` ```typescript import { ClaudeModule } from '@loopstack/claude-module'; ``` ## `@loopstack/openai-module` ```typescript import { OpenAiModule } from '@loopstack/openai-module'; ``` ## `@loopstack/secrets-module` ```typescript import { GetSecretKeysTool, RequestSecretsTool, SecretRequestDocument } from '@loopstack/secrets-module'; ``` ## `@loopstack/sandbox-tool` / `@loopstack/sandbox-filesystem` ```typescript import { SandboxCreateDirectory, SandboxDelete, SandboxExists, SandboxFileInfo, SandboxListDirectory, SandboxReadFile, SandboxWriteFile, } from '@loopstack/sandbox-filesystem'; import { SandboxFilesystemModule } from '@loopstack/sandbox-filesystem'; import { SandboxCommand, SandboxDestroy, SandboxInit } from '@loopstack/sandbox-tool'; import { SandboxToolModule } from '@loopstack/sandbox-tool'; ``` ## `@loopstack/oauth-module` ```typescript import { OAuthProviderInterface, OAuthProviderRegistry, OAuthTokenStore } from '@loopstack/oauth-module'; import { OAuthWorkflow } from '@loopstack/oauth-module'; ``` ## `@loopstack/google-workspace-module` ```typescript import { GoogleWorkspaceModule } from '@loopstack/google-workspace-module'; ``` --- > Source: https://loopstack.ai/llms/reference/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, tools, and example workflows. --- # 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/chat-example-workflow`, `@loopstack/tool-call-example-workflow`, `@loopstack/sandbox-example-workflow` ## 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:loopstackai/loopstack/registry/features/claude-module /tmp/claude-module # Download an example workflow npx giget@latest gh:loopstackai/loopstack/registry/examples/chat-example-workflow /tmp/chat-example ``` The repo path pattern is: ``` gh:loopstackai/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, CallbackSchema 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 /files/tree and /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. --- - [Accessing Tool Results Example](https://loopstack.ai/llms/registry/examples/accessing-tool-results-example-workflow.md): Example workflow showing how to store and access data across workflow transitions using typed workflow state - [Agent Example](https://loopstack.ai/llms/registry/examples/agent-example-workflow.md): Example launching AgentWorkflow as a sub-workflow with custom tools (weather_lookup, calculator) and rendering progress in Studio - [Chat Example](https://loopstack.ai/llms/registry/examples/chat-example-workflow.md): Example workflow building an interactive chat interface — system prompt setup, wait transitions, LlmGenerateTextTool, message loop, prompt-input widget - [Code Agent Example](https://loopstack.ai/llms/registry/examples/code-agent-example-workflow.md): Example launching ExploreAgentWorkflow as a sub-workflow to explore a remote workspace and surface a synthesized summary - [Custom Tool Example](https://loopstack.ai/llms/registry/examples/custom-tool-example-module.md): Example implementing custom tools in a Loopstack workflow — BaseTool subclass, @Tool decorator, Zod schema, tool registration and injection - [Delegate Error Handling Example](https://loopstack.ai/llms/registry/examples/delegate-error-example-workflow.md): Example showing how delegate tool-call loops handle failure modes — schema validation errors, runtime errors, failing sub-workflows, LLM error recovery - [Dynamic Routing Example](https://loopstack.ai/llms/registry/examples/dynamic-routing-example-workflow.md): Example workflow implementing conditional routing based on runtime values using guards and transition priorities - [Error Retry Example](https://loopstack.ai/llms/registry/examples/error-retry-example-workflow.md): Example demonstrating Loopstack retry and recovery — auto-retry, manual retry, custom error places, timeout handling, retry.place, hybrid retry patterns - [Git Commit Flow Example](https://loopstack.ai/llms/registry/examples/git-commit-flow-example-workflow.md): Example of scripted multi-tool orchestration using git-module — GitStatusTool, GitAddTool, GitCommitTool, GitLogTool in sequence, no LLM - [GitHub OAuth Example](https://loopstack.ai/llms/registry/examples/github-oauth-example.md): Example workflows using GitHub API with OAuth — structured repo overview and interactive Claude chat agent with 25 GitHub tools - [Google OAuth Example](https://loopstack.ai/llms/registry/examples/google-oauth-example.md): Example workflows using Google Workspace APIs with OAuth — Calendar, Gmail, Drive, structured calendar summary and Claude chat agent with 11 Google tools - [HITL Ask User Example](https://loopstack.ai/llms/registry/examples/hitl-ask-user-example-workflow.md): Example prompting the user for free-text input using AskUserWorkflow as a sub-workflow with callback, waiting on human input without blocking - [HITL Confirm Example](https://loopstack.ai/llms/registry/examples/hitl-confirm-example-workflow.md): Example asking the user for yes/no confirmation using ConfirmUserWorkflow as a sub-workflow, branching on confirmed/denied outcome - [LLM Multi-Provider Example](https://loopstack.ai/llms/registry/examples/llm-multi-provider-example-workflow.md): Example using multiple LLM providers (Claude and OpenAI) in the same workflow — side-by-side responses, call-time config with different provider settings - [MCP Linear Example](https://loopstack.ai/llms/registry/examples/mcp-linear-example-workflow.md): Example connecting a Loopstack chat agent to Linear's hosted MCP server — mcp_list_tools, mcp_call, MCP tool config with allowedHosts - [Meeting Notes Example](https://loopstack.ai/llms/registry/examples/meeting-notes-example-workflow.md): Example building a human-in-the-loop AI workflow — interactive documents, review steps, LLM-generated meeting notes with user approval - [Module Config Example](https://loopstack.ai/llms/registry/examples/module-config-example.md): Example demonstrating configurable module patterns — forRoot + forFeature, per-module configuration isolation, shared GreeterTool - [Prompt Example](https://loopstack.ai/llms/registry/examples/prompt-example-workflow.md): Example workflow integrating an LLM using a simple prompt pattern — single-shot text generation, LlmGenerateTextTool, saving response as document - [Structured Output Example](https://loopstack.ai/llms/registry/examples/prompt-structured-output-example-workflow.md): Example workflow generating structured output from an LLM — custom document schema, Zod validation, typed LLM responses - [Remote File Explorer Example](https://loopstack.ai/llms/registry/examples/remote-file-explorer-example-workflow.md): Example browsing a remote workspace — RemoteFileExplorerController endpoints, GlobTool + ReadTool workflow pattern - [Sub-Workflow Example](https://loopstack.ai/llms/registry/examples/run-sub-workflow-example.md): Example executing child workflows from a parent workflow — workflow.run(), hierarchical workflow composition, callback transitions, the show option for parent-view rendering - [Sandbox Example](https://loopstack.ai/llms/registry/examples/sandbox-example-workflow.md): Example workflow using Docker sandbox containers for secure filesystem operations — SandboxTool, SandboxFilesystemTool, isolated code execution - [Secrets Example](https://loopstack.ai/llms/registry/examples/secrets-example-workflow.md): Example demonstrating two secrets flows — deterministic request/verify workflow and agentic LLM workflow using get_secret_keys, request_secrets_task tools - [UI Documents Example](https://loopstack.ai/llms/registry/examples/test-ui-documents-example-workflow.md): Example demonstrating Studio document rendering — MessageDocument, ErrorDocument, MarkdownDocument, PlainDocument from a single workflow - [Tool Calling Example](https://loopstack.ai/llms/registry/examples/tool-call-example-workflow.md): Example workflow enabling LLM tool calling (function calling) with custom tools — LlmGenerateTextTool, LlmDelegateToolCallsTool, tool registration - [Workflow State Example](https://loopstack.ai/llms/registry/examples/workflow-state-example-workflow.md): Example workflow managing state across transitions using a typed state object — typed state interface, state persistence, accessing state in transitions ---