Features
Everything you need to build, operate, and trust durable AI workflows — from human-in-the-loop to testing, durability, and observability.
Human-in-the-Loop
Approvals, forms, and confirmations are first-class workflow steps. Your run pauses for a real person, then continues the moment they respond.
Escalate ticket #42 to on-call engineering? Severity was classified high.
Testing, Built In
Test workflows like any other code — unit tests for tools, end-to-end tests for whole runs. Recorded responses stand in for live LLM calls, simulated answers stand in for the human. Fast, deterministic, CI-ready.
const run = await runWorkflow(TriageTicketWorkflow, { ticket: 1042 }, {
fixture: 'triage.fixture.json', // recorded tool responses
answers: { approve: { approved: true } }, // scripted human
});
expect(run.path).toEqual(['classify', 'report', 'approve']);
expect(run.result).toMatchObject({ severity: 'high' });Never Lose a Run
Kill the process mid-run — the run survives. Every step is checkpointed to Postgres, so a restart reclaims the job and resumes from the last checkpoint. No lost work, no manual cleanup.
Server
Terminal
Inspect Every Run
Every run records what happened — each step with its duration, every tool call and document. Inspect runs live or afterwards, track token usage, and enforce quotas.
Code Examples
Agents With Full Control
Use a ready made agent or create your own flow. Same system, just the right level of abstraction.
Call an Agent
await this.agent.run(
{
system: 'Review the codebase and write a summary.',
tools: ['read', 'glob', 'grep'],
userMessage: 'Focus on the auth module.',
},
{ callback: { transition: 'agentDone' } },
);Build Your Own
// 1. LLM turn
@Transition({ from: 'ready', to: 'prompt_executed' })
async llmTurn(state) {
const result = await this.llmGenerateText.call({}, {
config: { system: '…', tools: ['read', 'grep'] },
});
this.assignState({ llmResult: result.data });
}
// 2. Dispatch tool calls, then loop back
@Transition({ from: 'prompt_executed', to: 'awaiting_tools' })
@Guard('hasToolCalls')
async dispatchTools(state) {
await this.llmDelegateToolCalls.call({
message: state.llmResult.message,
callback: { transition: 'toolResult' },
});
}
// 3. Resume on each tool result, then loop back to LLM
@Transition({ from: 'awaiting_tools', to: 'ready', wait: true })
toolResult(state, input) {}
// 4. LLM returned a final answer — done
@Transition({ from: 'prompt_executed', to: 'end' })
@Guard('isDone')
respond(state) {}Tool calling, error recovery, and cancellation built in. Need custom exit logic, setup phases, or human interaction? Copy the agent and make it yours.
Nested Agents and -Workflows
Let agents launch sub-agents: Just wrap them in tools.
1. Define a workflow
@Workflow({ title: 'Test Runner', schema: TestRunnerSchema })
export class TestRunnerWorkflow extends BaseWorkflow {
// runs tests, sets result via assignResult
}2. Wrap it as a tool
@Tool({ name: 'run_tests', schema: RunTestsSchema })
export class RunTestsTool extends BaseTool {
// inject workflow via constructor
async handle(args, ctx, options) {
const result = await this.testRunner.run(
args, { callback: options?.callback },
);
return {
data: { workflowId: result.workflowId },
pending: { workflowId: result.workflowId },
};
}
}3. Let the agent use it
await this.llmGenerateText.call({}, {
config: {
system: 'You are a build agent.',
tools: ['read', 'write', 'run_tests'],
},
});The LLM decides when to launch sub-workflows. Each one runs in the background and reports back. Nest workflows as you need.
Built-In Error Recovery
Auto-retry, timeout, and custom error states — for sync throws and sub-workflow failures alike.
Auto-Retry
@Transition({
from: 'fetching',
to: 'done',
retryAttempts: 3,
})
async fetchData(state) {
await this.http.call({ url });
}Retries 3 times with exponential backoff. State rolls back between attempts.
Timeout
@Transition({
from: 'analyzing',
to: 'done',
timeout: 5000,
})
async analyze(state) {
await this.analyzer.call({ data });
}Kills the transition after 5s. Combine with retry to auto-retry on timeout.
Error States
@Transition({
from: 'deploying',
to: 'deployed',
errorPlace: 'deploy_failed',
})
async deploy(state) {
await this.deployer.call({});
}Routes to a custom error state with recovery transitions. Also routes sub-workflow failure callbacks.
Documents and state roll back automatically on failure. Every error is recorded as an audit trail. Manual retry is always available as a fallback.
Configurable UI
Documents define your data. A YAML config controls how they render - choices, forms, buttons, markdown. No frontend code needed.
The workflow renders the UI, pauses execution, the user responds, and the workflow continues.
widget: choices
options:
transition: userAnswered
Ready to Build?
Start a new app or drop Loopstack into your existing NestJS project.
Read loopstack.ai/llms.txt, then help me build a Loopstack project and my first AI workflowWorks in Claude Code, Cursor, or any coding agent.