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
import { z } from 'zod';import { BaseWorkflow, Transition, type TransitionInput, Workflow } from '@loopstack/common';import { LlmContextDocument, LlmGenerateTextTool, LlmMessageDocument } from '@loopstack/llm-provider';
@Workflow({ widget: './chat.ui.yaml' })export class ChatWorkflow extends BaseWorkflow { constructor(private readonly llmGenerateText: LlmGenerateTextTool) { super(); }
@Transition({ to: 'waiting_for_user' }) async setup(state: Record<string, unknown>) { await this.documentStore.save(LlmContextDocument, { role: 'user', text: this.render(join(__dirname, 'templates', 'systemMessage.md')), }); }
@Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string() }) async userMessage(state: Record<string, unknown>, input: TransitionInput<string>) { await this.documentStore.save(LlmMessageDocument, { role: 'user', text: input.data }); }
@Transition({ from: 'ready', to: 'waiting_for_user' }) async llmTurn(state: Record<string, unknown>) { await this.llmGenerateText.call({}, { config: { provider: 'claude', model: 'claude-sonnet-4-6' } }); }}YAML Config
title: 'Chat Assistant'
ui: widgets: - widget: prompt-input enabledWhen: - waiting_for_user options: transition: userMessageHow Message Accumulation Works
- All messages are saved as
LlmMessageDocument— automatically tagged withmessage - The LLM provider module collects all documents with the
messagetag as conversation history by default - 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:
args.prompt— if provided, used as a single user message. Documents are ignored.args.messages— if provided (and noprompt), used as the full message array. Documents are ignored.- Documents matching
config.messagesSearchTag— fallback when neither arg is set. Defaults to'message'.
When falling back to documents, the tool:
- Filters
DocumentEntityrecords bydoc.tags.includes(messagesSearchTag) - Excludes any document with
doc.isInvalidated === true - Sorts by
doc.indexto preserve chronological order - Replays an assistant message’s native response (
doc.meta.response) when the same provider wrote it, and otherwise rebuilds the message from its normalized content — see LLM Providers — Switching Provider Within a Conversation
This means you can run parallel conversation threads in the same workflow by saving messages under different tags and switching messagesSearchTag per call:
// Save a summary-thread messageawait this.documentStore.save( LlmMessageDocument, { role: 'user', text: 'Summarize the discussion so far.' }, { tags: ['summary-chat'] },);
// Call the LLM with only the summary thread as historyawait 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)- Initial transition — Create system message (hidden from UI)
- Workflow enters
waiting_for_user— UI shows the prompt-input widget - User sends message →
userMessagefires, saves user message as document llmTurnfires — calls the LLM with full message history, saves response- 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:
@Transition({ from: 'ready', to: 'prompt_executed' })async llmTurn(state: ChatState) { const result = await this.llmGenerateText.call( {}, { config: { provider: 'claude', model: 'claude-sonnet-4-6', tools: ['get_weather', 'search_database'] } }, ); this.assignState({ llmResult: result.data });}
@Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 })@Guard('hasToolCalls')async executeToolCalls(state: ChatState) { ... }
@Transition({ from: 'prompt_executed', to: 'waiting_for_user' })respond(state: ChatState) {}Registry References
- Prompt Input Chat example — Multi-turn chat loop with the prompt-input widget, without an LLM
- Custom Agent example — Tool calling loop built from the delegate primitives