AI Agent Loops
Any LLM-driven loop where you need to observe each step, check permissions, and potentially pause/resume.
The Async Generator Loop is the heartbeat of Claude Code’s agentic architecture. Instead of using traditional recursion, event emitters, or finite state machines, Claude Code implements its core agent loop as an async generator function — a function that yields intermediate results while maintaining its execution context across asynchronous boundaries.
// The fundamental pattern: an async generator that yields each stepasync function* agenticLoop( messages: Message[], tools: Tool[], options: LoopOptions): AsyncGenerator<AgentEvent> { while (true) { // 1. Call the LLM const response = yield* streamCompletion(messages, tools);
// 2. Yield the assistant's response for observation yield { type: 'assistant_message', content: response };
// 3. Check stop conditions if (response.stopReason === 'end_turn' && !hasToolUse(response)) { return; // Generator completes naturally }
// 4. Execute tools, yielding each result for (const toolUse of response.toolCalls) { const result = yield* executeToolWithProgress(toolUse); yield { type: 'tool_result', toolUse, result }; messages.push(toToolResultMessage(toolUse, result)); }
// 5. Append assistant message and loop messages.push(toAssistantMessage(response)); }}| Aspect | Recursion | Event Emitter | State Machine | Async Generator |
|---|---|---|---|---|
| Stack overflow risk | ❌ Yes | ✅ No | ✅ No | ✅ No |
| Linear readability | ✅ Good | ❌ Poor | ❌ Poor | ✅ Excellent |
| Pausable | ❌ No | ❌ Hard | ✅ Yes | ✅ Native |
| Resumable | ❌ No | ❌ Hard | ✅ Yes | ✅ Native |
| Backpressure | ❌ No | ❌ No | ⚠️ Manual | ✅ Built-in |
| Observable | ❌ No | ✅ Yes | ⚠️ Manual | ✅ Each yield |
| Cancellable | ❌ Hard | ⚠️ Manual | ⚠️ Manual | ✅ .return() |
| Memory per step | 📈 Grows | ✅ Constant | ✅ Constant | ✅ Constant |
A naive recursive agent loop accumulates stack frames on every iteration:
// ❌ Recursion: stack grows with each turnasync function recursiveLoop(messages: Message[]): Promise<void> { const response = await callLLM(messages); messages.push(response);
if (hasToolUse(response)) { const results = await executeTools(response.toolCalls); messages.push(...results); return recursiveLoop(messages); // Stack frame accumulates } // Even with tail-call optimization (not guaranteed in JS), // there's no way to observe intermediate states}With a deep research task requiring 50+ tool calls, this risks stack overflow. More critically, there’s no observation point — the caller cannot see what’s happening mid-execution.
Event emitters decouple observation from execution but introduce the “spaghetti callback” problem:
// ❌ Event Emitter: control flow becomes implicitclass AgentLoop extends EventEmitter { async run(messages: Message[]) { const response = await callLLM(messages); this.emit('response', response); // Who handles this? this.emit('tool_start', response); // What order? // ... state scattered across listeners this.emit('tool_end', results); // Race conditions? }}The execution flow is now split across multiple listener registrations, making it hard to reason about ordering, error handling, and cancellation.
Explicit state machines are correct but verbose:
// ❌ State Machine: correct but painful to readtype State = 'idle' | 'calling_llm' | 'executing_tools' | 'checking_stop' | 'done';
class AgentStateMachine { state: State = 'idle';
async transition() { switch (this.state) { case 'idle': this.state = 'calling_llm'; break; case 'calling_llm': this.response = await callLLM(this.messages); this.state = 'executing_tools'; break; case 'executing_tools': // 20 more lines of state transition logic... this.state = 'checking_stop'; break; // ...and so on } }}For every new step, you add a state and two transitions. The business logic drowns in state management boilerplate.
In the Claude Code source, the core loop lives in the processConversationTurn area. Here’s the distilled pattern:
// Simplified from Claude Code's actual architectureasync function* processConversation( apiClient: ApiClient, messages: Message[], systemPrompt: string, tools: Tool[], abortSignal: AbortSignal,): AsyncGenerator<StreamEvent> { let continueLoop = true;
while (continueLoop) { // Phase 1: Stream the API response const streamEvents = apiClient.stream({ system: systemPrompt, messages, tools: tools.map(t => t.schema), });
const assistantBlocks: ContentBlock[] = [];
for await (const event of streamEvents) { // Yield each streaming token for real-time display yield { type: 'stream', event };
if (event.type === 'content_block') { assistantBlocks.push(event.block); } }
// Phase 2: Process tool calls const toolUseBlocks = assistantBlocks.filter(isToolUse);
if (toolUseBlocks.length === 0) { continueLoop = false; yield { type: 'turn_complete' }; break; }
// Phase 3: Execute tools with individual yielding const toolResults: ToolResult[] = [];
for (const toolUse of toolUseBlocks) { yield { type: 'tool_start', tool: toolUse.name };
// Check abort between tool executions if (abortSignal.aborted) { yield { type: 'aborted' }; return; }
const result = await executeTool(toolUse, tools); toolResults.push(result);
yield { type: 'tool_end', tool: toolUse.name, result }; }
// Phase 4: Append to conversation and continue messages.push({ role: 'assistant', content: assistantBlocks }); messages.push({ role: 'user', content: toolResults.map(toToolResultBlock) }); }}The magic of generators becomes clear when you see the consumer:
// The consumer controls the paceasync function runAgent(config: AgentConfig) { const loop = processConversation( config.apiClient, config.messages, config.systemPrompt, config.tools, config.abortController.signal, );
for await (const event of loop) { // Each iteration here pulls the next value from the generator. // The generator is PAUSED until we ask for the next value.
switch (event.type) { case 'stream': renderToTerminal(event); // Update UI in real-time break;
case 'tool_start': if (needsPermission(event.tool)) { const allowed = await askUser(`Allow ${event.tool}?`); if (!allowed) { loop.return(undefined); // Graceful cancellation return; } } showSpinner(event.tool); break;
case 'tool_end': hideSpinner(); logToolResult(event.result); break;
case 'turn_complete': showFinalResponse(); break;
case 'aborted': showAbortMessage(); return; } }}graph TD A[Cancellation Request] --> B{Strategy} B -->|Cooperative| C["abortSignal.aborted check<br/>between tool executions"] B -->|Immediate| D["generator.return()<br/>from consumer side"] B -->|Timeout| E["AbortSignal.timeout(ms)<br/>passed to API client"]
C --> F[Clean shutdown<br/>Partial results preserved] D --> G[Generator finalizes<br/>Finally blocks run] E --> H[API call aborted<br/>Network resources freed]// Strategy 1: Cooperative (check abort signal in the loop)if (abortSignal.aborted) { yield { type: 'aborted' }; return;}
// Strategy 2: Immediate (consumer calls .return())const generator = agenticLoop(messages, tools);// ... later:generator.return(undefined); // Generator's finally block executes
// Strategy 3: Timeout (built into AbortSignal)const signal = AbortSignal.timeout(300_000); // 5 minute timeoutconst generator = agenticLoop(messages, tools, { signal });Here’s a production-ready template you can adapt:
// ============================================// Reusable Agentic Loop Template// ============================================
interface AgentEvent { type: 'thinking' | 'response' | 'tool_call' | 'tool_result' | 'done' | 'error'; data: unknown;}
interface AgentConfig { maxTurns: number; tools: Map<string, ToolHandler>; shouldContinue: (response: LLMResponse) => boolean; onBeforeToolCall?: (call: ToolCall) => Promise<boolean>;}
async function* createAgentLoop( llm: LLMClient, initialMessages: Message[], config: AgentConfig,): AsyncGenerator<AgentEvent> { const messages = [...initialMessages]; let turns = 0;
try { while (turns < config.maxTurns) { turns++;
// Call LLM yield { type: 'thinking', data: { turn: turns } }; const response = await llm.complete(messages); yield { type: 'response', data: response };
// Check if we should stop if (!config.shouldContinue(response)) { yield { type: 'done', data: { turns, reason: 'stop_condition' } }; return; }
// Execute tool calls for (const call of response.toolCalls ?? []) { // Pre-execution hook (permission check, etc.) if (config.onBeforeToolCall) { const allowed = await config.onBeforeToolCall(call); if (!allowed) { yield { type: 'done', data: { turns, reason: 'user_denied' } }; return; } }
yield { type: 'tool_call', data: call };
const handler = config.tools.get(call.name); if (!handler) { const error = `Unknown tool: ${call.name}`; messages.push(toolErrorMessage(call.id, error)); yield { type: 'error', data: { call, error } }; continue; }
const result = await handler(call.input); messages.push(toolResultMessage(call.id, result)); yield { type: 'tool_result', data: { call, result } }; }
// Append assistant message for next turn messages.push(assistantMessage(response)); }
yield { type: 'done', data: { turns, reason: 'max_turns' } }; } finally { // Cleanup runs even on .return() or thrown errors console.log(`Agent loop finished after ${turns} turns`); }}yield*Claude Code uses yield* to compose generators, creating a modular pipeline:
// Parent generator delegates to child generatorsasync function* outerLoop(): AsyncGenerator<AgentEvent> { // yield* transparently forwards all yields from the inner generator yield* innerStreamPhase(); yield* innerToolPhase(); yield* innerCompactionPhase();}
// Each phase is its own generatorasync function* innerStreamPhase(): AsyncGenerator<AgentEvent> { for await (const token of apiStream) { yield { type: 'stream_token', data: token }; }}
async function* innerToolPhase(): AsyncGenerator<AgentEvent> { for (const tool of pendingTools) { yield { type: 'tool_start', data: tool }; const result = await tool.execute(); yield { type: 'tool_end', data: result }; }}This yield* delegation creates a composable pipeline without losing the flat control flow.
| Feature | Async Generator | RxJS Observable |
|---|---|---|
| Learning curve | Low (native JS) | High (100+ operators) |
| Bundle size | 0 KB (built-in) | ~30 KB min |
| Backpressure | Automatic (pull-based) | Manual (strategies needed) |
| Cancellation | .return() / AbortSignal | subscription.unsubscribe() |
| Composition | yield* delegation | pipe() + operators |
| Hot/Cold | Always cold (lazy) | Both hot and cold |
| Error handling | try/catch | .pipe(catchError(...)) |
| Multicasting | Manual (need to tee) | Built-in subjects |
| Time operators | Manual | debounce, throttle, etc. |
| Best for | Sequential async workflows | Complex event streams |
AI Agent Loops
Any LLM-driven loop where you need to observe each step, check permissions, and potentially pause/resume.
ETL Pipelines
Extract → Transform → Load workflows where each stage yields progress and supports cancellation.
Long-Running Tasks
Build systems, test runners, or deployment pipelines that need real-time progress reporting.
Interactive Workflows
Any process that alternates between automated steps and user input/approval.
// ❌ Don't buffer all results then yield at the endasync function* badGenerator() { const allResults = []; for (const item of items) { allResults.push(await process(item)); } yield allResults; // This defeats the purpose of streaming}
// ✅ Yield each result as it's producedasync function* goodGenerator() { for (const item of items) { yield await process(item); // Consumer sees each result immediately }}
// ❌ Don't catch errors silently inside the generatorasync function* badErrorHandling() { try { yield await riskyOperation(); } catch (e) { // Silently swallowed — consumer doesn't know something failed }}
// ✅ Yield errors as events or re-throwasync function* goodErrorHandling() { try { yield await riskyOperation(); } catch (e) { yield { type: 'error', error: e }; // Consumer can decide what to do }}The Async Generator Loop pattern is Claude Code’s most fundamental architectural decision. It provides:
yield is a window into the loop’s stateyield* enables modular sub-generators.return() and AbortSignal work togetherThis single pattern choice eliminates entire classes of bugs (stack overflow, lost events, race conditions) while keeping the code approachable for any TypeScript developer.