> ## Documentation Index > Fetch the complete documentation index at: https://openrouter.ai/docs/llms.txt > Use this file to discover all available pages before exploring further. # Build a Long-Horizon Agent > Run multi-hour agent loops with cost ceilings, resumable state, and voice input This cookbook assumes you have an OpenRouter API key and are using the Agent SDK (`@openrouter/agent`). If you are starting from scratch, read the [Agent SDK overview](/docs/agent-sdk/overview) and the [callModel reference](/docs/agent-sdk/call-model/overview) first. **Goal:** Run an agent that can keep working for hours, not seconds — research projects, multi-stage migrations, voice-driven assistants, or background jobs that span days. The same `callModel` loop works for all of them once you wire up four primitives. **Outcome:** A long-horizon agent that: * Caps total cost and step count so it always terminates. * Persists conversation state so it can be resumed after a crash, deploy, or human approval. * Streams progress events so dashboards and UIs stay live during the run. * Runs a self-ask loop — research, adversarial review, repeat — until the agent emits a `[DONE]` sentinel. * Optionally accepts voice input via OpenRouter's [Speech-to-Text](/docs/guides/overview/multimodal/stt) endpoint and replies with [Text-to-Speech](/docs/guides/overview/multimodal/tts). You can hand this page to your coding agent as the implementation brief. Adapt the storage, ceilings, and surface (CLI, API, queue worker) to your app rather than scaffold a separate project. ## Prerequisites * Node.js 20+ or Bun * An [OpenRouter API key](https://openrouter.ai/settings/keys) in `OPENROUTER_API_KEY` * A project with `@openrouter/agent` installed * A place to persist state — a database, Redis, S3, or the local filesystem * Optional: a microphone or audio file for the voice section ```bash title="npm" lines theme={null} npm install @openrouter/agent @openrouter/sdk zod ``` ```bash title="pnpm" lines theme={null} pnpm add @openrouter/agent @openrouter/sdk zod ``` ```bash title="yarn" lines theme={null} yarn add @openrouter/agent @openrouter/sdk zod ``` ```bash title="bun" lines theme={null} bun add @openrouter/agent @openrouter/sdk zod ``` ```bash title="deno" lines theme={null} deno add npm:@openrouter/agent npm:@openrouter/sdk npm:zod ``` ## 1. Set hard ceilings on every run Long-horizon agents must terminate. Combine multiple stop conditions so the loop ends as soon as the first one fires. The most useful for long runs are `maxCost`, `stepCountIs`, and `maxTokensUsed`. ```typescript expandable lines theme={null} import { OpenRouter, tool, stepCountIs, maxCost } from '@openrouter/agent'; import { z } from 'zod'; const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY, }); const searchTool = tool({ name: 'search', description: 'Search the web for information', inputSchema: z.object({ query: z.string() }), execute: async ({ query }) => { return { results: await fetchResults(query) }; }, }); const result = openrouter.callModel({ model: '~anthropic/claude-opus-latest', input: 'Research the fusion energy landscape and produce a 5-page report.', tools: [searchTool], // Stop on whichever fires first. stopWhen: [stepCountIs(200), maxCost(5)], }); const text = await result.getText(); ``` See the [Stop Conditions reference](/docs/agent-sdk/call-model/stop-conditions) for the full list (`stepCountIs`, `hasToolCall`, `maxTokensUsed`, `maxCost`, `finishReasonIs`) and how to compose custom predicates. Long-horizon runs spend real credits. Always set both a step ceiling and a cost ceiling before you start a multi-hour run, and start small while you are iterating. ## 2. Persist state for resumability A multi-hour run must survive restarts, deploys, and human approvals. `callModel` accepts a `StateAccessor` that loads and saves `ConversationState` between steps. Back it with whatever storage your app already uses. ```typescript expandable lines theme={null} import type { ConversationState, StateAccessor } from '@openrouter/agent'; import { readFile, rename, writeFile } from 'node:fs/promises'; const fileStateAccessor = (path: string): StateAccessor => ({ load: async () => { // Only swallow ENOENT — real I/O or permission errors should surface // instead of silently restarting the agent from scratch. const raw = await readFile(path, 'utf8').catch((err: NodeJS.ErrnoException) => { if (err.code === 'ENOENT') return null; throw err; }); return raw ? (JSON.parse(raw) as ConversationState) : null; }, // Atomic write: write to a temp file, then rename. POSIX rename is // atomic on the same filesystem, so a crash mid-write cannot leave // a truncated state file that breaks resumption. save: async (state) => { const tmp = `${path}.tmp`; await writeFile(tmp, JSON.stringify(state)); await rename(tmp, path); }, }); const result = openrouter.callModel({ model: '~anthropic/claude-opus-latest', input: 'Plan and start a 3-day data migration.', tools: [searchTool], state: fileStateAccessor('./run.json'), stopWhen: [stepCountIs(200), maxCost(5)], }); await result.getResponse(); ``` To resume after a crash, deploy, or human review, call `callModel` again with the same `StateAccessor`. Pass `input: []` to signal "no new user turn — continue from saved state"; the SDK loads the checkpoint and keeps going. ```typescript lines theme={null} const resumed = openrouter.callModel({ model: '~anthropic/claude-opus-latest', input: [], state: fileStateAccessor('./run.json'), tools: [searchTool], stopWhen: [stepCountIs(200), maxCost(5)], }); await resumed.getResponse(); ``` For production, swap the file accessor for one backed by Postgres, Redis, or an object store. See [Tool Approval & State](/docs/agent-sdk/call-model/tool-approval-state) for the full StateAccessor and resumption contract. ## 3. Stream progress instead of waiting A run that lasts an hour should not block your UI for an hour. `callModel` returns a result object with several streams you can consume independently: * `result.getTextStream()` — token deltas for the user-facing response. * `result.getToolCallsStream()` — tool calls as they complete. * `result.getFullResponsesStream()` — the full event stream, including tool preliminary results. * `result.getResponse()` — the final, fully-resolved response with usage data. ```typescript expandable lines theme={null} const result = openrouter.callModel({ model: '~anthropic/claude-opus-latest', input: 'Build a market analysis report on EV charging.', tools: [searchTool], stopWhen: [stepCountIs(100), maxCost(2)], }); // Stream tool calls and text deltas concurrently. const streamToolCalls = (async () => { for await (const call of result.getToolCallsStream()) { publishToDashboard({ kind: 'tool', name: call.name, args: call.arguments }); } })(); const streamText = (async () => { for await (const delta of result.getTextStream()) { publishToDashboard({ kind: 'token', delta }); } })(); await Promise.all([streamToolCalls, streamText]); const final = await result.getResponse(); publishToDashboard({ kind: 'done', usage: final.usage }); ``` See the [callModel API reference](/docs/agent-sdk/call-model/api-reference) for every stream method and event type. Wire `publishToDashboard` to whatever transport you already use — Server-Sent Events, WebSockets, a database table, or a pubsub channel. ## 4. Loop with adversarial self-review A single pass through `callModel` often leaves gaps — unverified citations, missing edge cases, or stale data. Wrap the run in an outer self-ask loop: research, adversarial review, repeat until the agent emits a `[DONE]` sentinel. Each iteration appends a new user turn to the persisted `StateAccessor`, so the agent builds on its prior work instead of starting over. ```typescript expandable lines theme={null} import { OpenRouter, stepCountIs, maxCost } from '@openrouter/agent'; const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY, }); const SELF_ASK_MAX_ITERATIONS = 10; const REVIEW_PROMPT = `Review your last response adversarially. - Are there gaps, ambiguities, or unverified claims? - If the work is complete and every claim is verified, reply with only [DONE]. - Otherwise list the gaps and keep researching.`; const state = fileStateAccessor('./run.json'); let input: string | unknown[] = 'Research the fusion energy landscape and produce a 5-page report.'; let final = ''; for (let i = 0; i < SELF_ASK_MAX_ITERATIONS; i++) { const result = openrouter.callModel({ model: '~anthropic/claude-opus-latest', input, state, tools: [searchTool], // Per-iteration ceilings. The outer for-loop adds a third guard. stopWhen: [stepCountIs(50), maxCost(2)], }); final = await result.getText(); if (final.includes('[DONE]')) break; // Hand the assistant's own output back as an adversarial reviewer turn. input = REVIEW_PROMPT; } ``` The `[DONE]` sentinel is intentionally cheap: any model can produce it, and a plain `String.includes` check keeps the control flow obvious. Swap the review prompt or the reviewer model (for example a faster `~anthropic/claude-sonnet-latest` critiquing an Opus researcher) without changing the loop. Three layers of ceilings keep cost bounded: `SELF_ASK_MAX_ITERATIONS` caps the number of review rounds, and each round inherits its own `stepCountIs` + `maxCost` budget. Pair this with the `state` accessor from step 2 so the loop survives crashes mid-review. On resume, re-enter the loop from the saved state and continue reviewing. ## 5. Add voice input Drive the same agent loop from a voice memo, phone call, or push-to-talk app. OpenRouter exposes a dedicated [`/api/v1/audio/transcriptions`](/docs/guides/overview/multimodal/stt) endpoint with a single STT model parameter. Hand the transcript to `callModel` exactly like a text prompt. ```typescript lines theme={null} import { OpenRouter as SDK } from '@openrouter/sdk'; import { OpenRouter, stepCountIs, maxCost } from '@openrouter/agent'; import { readFile } from 'node:fs/promises'; const sdk = new SDK({ apiKey: process.env.OPENROUTER_API_KEY }); const agent = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); const audio = await readFile('./voice-memo.wav'); const transcription = await sdk.stt.createTranscription({ model: 'openai/whisper-1', inputAudio: { data: audio.toString('base64'), format: 'wav' }, }); const result = agent.callModel({ model: '~anthropic/claude-opus-latest', input: transcription.text, stopWhen: [stepCountIs(50), maxCost(2)], }); const reply = await result.getText(); ``` For a streaming microphone, capture audio chunks on the client, send them to your server, and call `createTranscription` once silence is detected. Use the [STT cookbook](/docs/guides/overview/multimodal/stt) for the full request and response shape. ## 6. Speak the response back (optional) For voice-out, pipe the agent's reply through [`/api/v1/audio/speech`](/docs/guides/overview/multimodal/tts) and write the resulting bytes to a file or stream them to the caller. ```typescript lines theme={null} import { writeFile } from 'node:fs/promises'; const stream = await sdk.tts.createSpeech({ model: 'openai/gpt-4o-mini-tts-2025-12-15', input: reply, voice: 'alloy', responseFormat: 'mp3', }); const chunks: Uint8Array[] = []; const reader = stream.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } await writeFile('./reply.mp3', Buffer.concat(chunks)); ``` ## 7. Notify on completion Long-horizon jobs usually run somewhere the user is not watching. Notify them when the run terminates — by webhook, email, Slack message, or whatever your stack uses. Trigger the notification once `getResponse()` resolves so the agent has fully completed and ceilings have been honored. ```typescript lines theme={null} const final = await result.getResponse(); const webhookUrl = process.env.WEBHOOK_URL; if (!webhookUrl) { throw new Error('WEBHOOK_URL env var is required for webhook notifications'); } await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'completed', usage: final.usage, text: await result.getText(), }), }); ``` For agents that pause mid-run (for example, human-in-the-loop approvals), see [Add Human-in-the-Loop Controls](/docs/cookbook/building-agents/hitl-tools). ## Check your work A correct long-horizon implementation should pass all of the following: * A run with a low `maxCost` (for example, `maxCost(0.10)`) returns from `callModel` once the ceiling is hit, even if the agent has more work queued. * Killing the process mid-run and starting a new `callModel` invocation with the same `StateAccessor` resumes from the saved `ConversationState`. The message history grows rather than starting over. * `getToolCallsStream()` and `getTextStream()` yield events while the agent is still running, not only at the end. * Sending a voice file through `sdk.stt.createTranscription` returns the expected text, and feeding that text into `callModel` produces a response that references the spoken request. * A webhook (or other notification) fires after `getResponse()` resolves. ## Resources * [Agent SDK overview](/docs/agent-sdk/overview) * [callModel reference](/docs/agent-sdk/call-model/overview) * [Stop conditions reference](/docs/agent-sdk/call-model/stop-conditions) * [Tool Approval & State](/docs/agent-sdk/call-model/tool-approval-state) * [Speech-to-Text guide](/docs/guides/overview/multimodal/stt) * [Text-to-Speech guide](/docs/guides/overview/multimodal/tts) * [Add Human-in-the-Loop Controls](/docs/cookbook/building-agents/hitl-tools) * [Build Your Own Headless Agent](/docs/cookbook/building-agents/create-headless-agent)