Skip to Content

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:

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:

// app.module.ts — global default model @Module({ imports: [LoopstackModule.forRoot(), LlmProviderModule.forRoot({ model: 'claude-sonnet-4-5' }), ClaudeModule], }) export class AppModule {}
// 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.

export class MyWorkflow extends BaseWorkflow { constructor( private readonly llmGenerateText: LlmGenerateTextTool, private readonly llmDelegateToolCalls: LlmDelegateToolCallsTool, ) { super(); } }
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 for full call examples.

ParameterLocationDescription
promptargsSimple prompt string
messagesargsExplicit message array
outputSchemaargsJSON Schema (LlmGenerateObjectTool only)
providerconfigLLM provider name (e.g. 'claude')
modelconfigModel name (e.g. 'claude-sonnet-4-6')
systemconfigSystem prompt
messagesSearchTagconfigLoad messages from documents by tag
toolsconfigTool names the LLM can call

Using Multiple Providers

Import both modules and configure each call with its provider:

@Module({ imports: [LoopstackModule.forRoot(), ClaudeModule, OpenAiModule], }) export class AppModule {}
// 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) — per-call config always takes priority.

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

FieldTypeDescription
maxTokensnumberMaximum tokens to generate
temperaturenumberSampling temperature (0–1)
stopSequencesstring[]Stop generation when any of these strings is produced
cachebooleanEnable 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.
envApiKeystringEnv var name holding the API key (defaults to ANTHROPIC_API_KEY)

OpenAiProviderConfig

FieldTypeDescription
maxTokensnumberMaximum tokens to generate
temperaturenumberSampling temperature (0–2)
stopSequencesstring[]Stop generation when any of these strings is produced
frequencyPenaltynumber-2.0 to 2.0; reduces token repetition
presencePenaltynumber-2.0 to 2.0; encourages topic diversity
envApiKeystringEnv 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.

ToolPurpose
LlmGenerateTextToolText generation with optional tool calling
LlmGenerateObjectToolStructured output conforming to a JSON Schema
LlmDelegateToolCallsToolExecute tool calls from an LLM response
LlmUpdateToolResultToolHandle 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 for how documents are collected from the document store and become the LLM’s conversation history.

DocumentContent FormatWidget
LlmMessageDocumentNormalized (text, thinking, tool_call blocks)llm-message

Response shape

LlmGenerateTextResult.data.message is an LlmNormalizedMessage — two views of the same response:

FieldTypeDescription
role'user' | 'assistant'Message role
textstringPlain-text projection — concatenated text-type blocks. Always populated by providers. Use this when you just want a string.
blocksLlmContentBlock[] (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
idstring (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<string, unknown> } — 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:

// 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 for the full interface.

Environment Variables

VariableProviderDescription
ANTHROPIC_API_KEYClaudeAPI key
OPENAI_API_KEYOpenAIAPI key
CLAUDE_MODELClaudeDefault model fallback
OPENAI_MODELOpenAIDefault model fallback

Available Providers

ProviderModuleID
Anthropic Claude@loopstack/claude-module'claude'
OpenAI@loopstack/openai-module'openai'

To create a custom provider, see Creating LLM Providers.

Last updated on