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:
loopstack install @loopstack/agentRegister tools on your workspace so the agent can use them:
@Workspace({ ... })
export class MyWorkspace {
@InjectWorkflow() agent: AgentWorkflow;
@InjectTool() glob: GlobTool;
@InjectTool() grep: GrepTool;
@InjectTool() read: ReadTool;
}Launch the agent from any workflow:
@Transition({ from: 'planning', to: 'implementing' })
async runAgent() {
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.',
}, { alias: 'agent', callback: { transition: 'agentDone' } });
}The agent runs a full tool-calling loop automatically: LLM turn → tool execution → loop back → until the LLM responds without tool calls.
Agent Args
| Arg | Type | Required | Description |
|---|---|---|---|
system | string | yes | System prompt |
tools | string[] | yes | Tool property names available to the LLM |
userMessage | string | yes | Initial user message |
context | string | no | Hidden context message (e.g. pre-loaded docs) |
model | string | no | Claude model (default: claude-sonnet-4-6) |
cache | boolean | no | Enable prompt caching (default: true) |
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:
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 in this order:
- Workflow —
@InjectTool()on the current workflow - Workspace —
@InjectTool()on the workspace
The agent workflow only injects the three tools it always needs (ClaudeGenerateText, DelegateToolCalls, UpdateToolResult). Domain-specific tools like glob or read are resolved from the workspace at runtime.
This means you register tools once on the workspace 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 DelegateToolCallsResult includes error metadata:
interface DelegateToolCallsResult {
allCompleted: boolean;
pendingCount: number;
hasErrors: boolean;
errorCount: number;
errors: { toolName: string; toolUseId: 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.
You can also cancel programmatically:
await this.orchestrator.cancelChildren(workflowId);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:
@Workflow({
uiConfig: __dirname + '/my-agent.ui.yaml',
schema: z.object({ instructions: z.string() }),
})
export class MyAgentWorkflow extends BaseWorkflow {
@InjectTool() claudeGenerateText: ClaudeGenerateText;
@InjectTool() delegateToolCalls: DelegateToolCalls;
@InjectTool() updateToolResult: UpdateToolResult;
@InjectTool() myCustomTool: MyCustomTool;
llmResult?: ClaudeGenerateTextResult;
delegateResult?: DelegateToolCallsResult;
@Initial({ to: 'ready' })
async setup(args: { instructions: string }) {
await this.repository.save(ClaudeMessageDocument, {
role: 'user',
content: args.instructions,
});
}
@Transition({ from: 'ready', to: 'prompt_executed' })
async llmTurn() {
const result = await this.claudeGenerateText.call({
system: 'You are a custom agent.',
claude: { model: 'claude-sonnet-4-6', cache: true },
messagesSearchTag: 'message',
tools: ['myCustomTool'],
});
this.llmResult = result.data;
}
@Transition({ from: 'prompt_executed', to: 'awaiting_tools', priority: 10 })
@Guard('hasToolCalls')
async executeToolCalls() {
const result = await this.delegateToolCalls.call({
message: this.llmResult!,
document: ClaudeMessageDocument,
callback: { transition: 'toolResultReceived' },
});
this.delegateResult = result.data;
}
@Transition({ from: 'awaiting_tools', to: 'awaiting_tools', wait: true })
async toolResultReceived(payload: unknown) {
const result = await this.updateToolResult.call({
delegateResult: this.delegateResult!,
completedTool: payload,
document: ClaudeMessageDocument,
});
this.delegateResult = result.data;
}
@Transition({ from: 'awaiting_tools', to: 'ready' })
@Guard('allToolsComplete')
async toolsComplete() {}
@Final({ from: 'prompt_executed' })
@Guard('isEndTurn')
async respond() {
await this.repository.save(ClaudeMessageDocument, this.llmResult!, {
id: this.llmResult!.id,
});
return { response: this.extractText() };
}
private hasToolCalls(): boolean {
return this.llmResult?.stop_reason === 'tool_use';
}
private allToolsComplete(): boolean {
return !!this.delegateResult?.allCompleted;
}
private isEndTurn(): boolean {
return this.llmResult?.stop_reason === 'end_turn';
}
}Adding User Interaction
Pause for user input between LLM turns:
// Instead of @Final, go to waiting_for_user
@Transition({ from: 'prompt_executed', to: 'waiting_for_user' })
@Guard('isEndTurn')
async respondToUser() {
await this.repository.save(ClaudeMessageDocument, this.llmResult!, {
id: this.llmResult!.id,
});
}
@Transition({ from: 'waiting_for_user', to: 'ready', wait: true, schema: z.string() })
async userMessage(payload: string) {
await this.repository.save(ClaudeMessageDocument, {
role: 'user',
content: payload,
});
}Custom Exit Conditions
End the loop when a specific tool is called:
@Final({ from: 'awaiting_tools', priority: 20 })
@Guard('isApproved')
async done() {
return { concept: this.approvedConcept };
}
// allToolsComplete only fires when isApproved is false
@Transition({ from: 'awaiting_tools', to: 'ready', priority: 10 })
@Guard('allToolsCompleteNotApproved')
async toolsComplete() {}Wrapping an Agent as a Tool
Make an agent callable by other agents via a task tool:
@Tool({
schema: z.object({ instructions: z.string() }),
uiConfig: { description: 'Launch a sub-agent to explore the codebase.' },
})
export class ExploreTask extends BaseTool {
@InjectWorkflow() private agent: AgentWorkflow;
async call(args: { instructions: string }, options?: ToolCallOptions): Promise<ToolResult> {
const result = await this.agent.run(
{
system: 'You are a codebase exploration agent.',
tools: ['glob', 'grep', 'read'],
userMessage: args.instructions,
},
{ alias: 'exploreAgent', callback: options?.callback },
);
return {
data: { workflowId: result.workflowId },
pending: { workflowId: result.workflowId },
};
}
async complete(result: Record<string, unknown>): Promise<ToolResult> {
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 — Built-in agent workflow module
- @loopstack/code-agent — Code exploration agent (ExploreTask) built on @loopstack/agent
- delegate-error-example-workflow — Example demonstrating tool error handling and recovery