generateText
Buffered, awaited text generation — single-turn, or the full agentic tool loop when you pass tools.
generateText runs a model to completion and resolves once. It is the buffered counterpart to streamChat: the same orchestration (retry, timeout, the canonical delta stream, the agentic loop), but it accumulates every delta for you and hands back a plain object.
When to use it
Reach for generateText when:
- Nobody is watching the output arrive — a background job, a cron task, a classification step inside a larger pipeline.
- You need the whole answer before you can do anything with it (parse it, branch on it, store it).
- You are running an agent whose steps matter more than its tokens, and you will render a summary at the end.
- You want the simplest possible error handling: it is a
Promise, sotry/catchworks.
Reach for streamChat instead when:
- A human is waiting. Time-to-first-token is the number they feel;
generateTexthas none by definition. - You want live tool-call, reasoning, cost or approval events as they happen.
- You are on a serverless platform with a short response budget and want to start writing bytes early.
Everything else is the same. The two share CommonCallOptions, the same registry, the same retry and timeout policy, and — with tools — the same loop semantics. Switching between them is a one-word change.
Signature
import { generateText } from '@deuz-sdk/core';
const result = await generateText(options);generateText is async and returns a Promise<GenerateTextResult>. Unlike streamChat, errors reject the promise — wrap the call in try/catch. That asymmetry is deliberate: a promise already has a failure channel, so there is no reason to invent one. See Error handling.
`maxSteps` defaults to 1 — the single most common surprise
Passing tools does not by itself give you a multi-step agent. The default step budget is 1, so the model calls a tool, the loop executes it, and then the run ends — text is empty and the answer you wanted never got written. If you want the model to use the tool result, set maxSteps to something above 1 (5–10 covers most tool agents).
The default is 1 because a step budget is a spend budget: an unbounded loop with a wrong prompt is an unbounded bill. You opt in, explicitly, per call.
Options
generateText takes the same CommonCallOptions as every other call. The ones you will actually reach for:
| Option | Type | Default | When you want it |
|---|---|---|---|
model | LanguageModel | — | Always. From a provider factory, e.g. createAnthropic(...)('claude-opus-4-8'). |
messages | Message[] | — | You have a conversation. Required unless you pass prompt — the two are mutually exclusive, and passing both (or neither) rejects with an InvalidRequestError before any network call. |
prompt | string | — | 1.9. One-shot calls. Shorthand for exactly one user turn. See Prompts. |
instructions | string | — | 1.9. The system prompt. Placed first and kept structurally separate from — possibly untrusted — history, so a prompt injection in messages cannot reorder or overwrite it. |
temperature | number | provider | You want more or less variance. Silently dropped (with a warning) on reasoning models that reject it. |
maxOutputTokens | number | provider | You need a hard cap on spend or length. Watch for finishReason: 'length'. |
topP | number | provider | Nucleus sampling. Same reasoning-model caveat as temperature. |
stopSequences | string[] | — | You are generating into a template and want a hard delimiter. |
effort | 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | — | Reasoning models. Canonical across wires; each adapter maps it to its own unit, and 'xhigh'/'max' clamp down where the wire lacks them. On a model the registry says cannot reason, the value is dropped and reported as a warning. |
signal | AbortSignal | — | The user can cancel. Propagated to the underlying fetch and to every tool execute. abortSignal is a deprecated 1.9 alias; signal wins if both are set. |
maxRetries | number | 2 | Flaky upstream. Pre-first-byte only — see Retries. |
timeout | number | { ttftMs?, totalMs?, stepMs?, toolMs? } | ttft 60s / total 300s | You are inside a platform budget the SDK defaults do not respect. A bare number is { totalMs }; an explicit 0 disables a layer. All four layers work in this loop. See Timeouts. |
capabilities | Partial<ModelCapabilities> | — | 1.9. The registry does not know your model yet and its conservative fallback is hurting you (notably maxOutput: 4096). Shallow-merged over the registry row. |
headers | Record<string, string> | — | A gateway needs a custom header. |
deps | Dependencies | in-memory defaults | Tests, custom transport, tracing, pricing, a shared circuit breaker. The one seam for everything ambient. |
providerOptions | { [provider]: Record<string, unknown> } | — | A provider-specific field the canonical surface has no name for. Merged into the request body; canonical fields the adapter sets always win. |
promptCaching | 'auto' | 'auto-1h' | — | Long, stable system prompts on Anthropic. Other providers cache implicitly and ignore it. |
onUsage | (usage, meta) => void | — | Per-model-call metering. In a loop it fires once per step, so it is the right hook for a per-request token ledger. |
onFinish | (meta) => void | — | Per-run notification. Fires once, with the run's finishReason. |
Agentic options
These only do anything once tools is present (or one of the other loop-routing options — chat, memory, verifyStep, doneWhen, guardrails, mcp — pulls the call into the loop).
| Option | Type | Default | When you want it |
|---|---|---|---|
tools | ToolSet | — | A Record<string, Tool>. Presence switches on the loop. |
toolChoice | 'auto' | 'required' | 'none' | { type: 'tool'; toolName: string } | 'auto' | Force the first turn to call a tool ('required'), or forbid tools for one call ('none'). |
maxSteps | number | 1 | Always, for real tool use. Counts model turns, not tool calls. |
stopWhen | StopCondition | StopCondition[] | — | You want to stop on a signal rather than a count. OR-ed with maxSteps — it can only stop earlier, never extend the budget. |
budget | { usd?: number; tokens?: number } | — | A hard spend ceiling. Sugar over costExceeds / totalTokensExceed with stoppedBy markers you can read back. |
maxToolConcurrency | number | 5 | Your tools hit a rate-limited API and 5 at once is too many, or they are cheap and you want more. |
onStepFinish | (step: StepResult) => void | — | Progress reporting. Fires after each step that made tool calls — not for the terminal text-only step. |
prepareStep | (ctx) => PrepareStepResult | undefined | Promise<…> | — | You want per-step control: rewrite messages, swap the model (cheap model for the middle steps), restrict activeTools, or override toolChoice. Runs before every model call, after compaction, so it has the last word on history. |
activeTools | string[] | — | You have one big ToolSet and want a subset on the wire for this call. Unknown names are ignored with a warning. prepareStep's activeTools overrides it per step. |
compaction | 'auto' | CompactionPolicy | off | Long runs that will outgrow the context window. Pruning is free; the summarize layer costs one extra model call whose usage counts toward the total. History stays immutable. |
approveToolCall | (call, ctx) => boolean | Promise<boolean> | — | Server-mode approval for needsApproval tools. Returning false (or throwing) denies — the denial becomes an is_error tool result and the loop continues. |
approvalResponses | ToolApprovalResponse[] | — | Resuming after a client-mode approval break. Unmatched pending calls are denied by default. |
verifyStep | (ctx) => VerifyStepResult | Promise<…> | — | 1.8. You want a second opinion before accepting an answer. Runs at every natural completion; { ok: false, feedback } re-drives the loop with the feedback as a user turn. |
maxVerifyAttempts | number | 3 | Cap on verifyStep re-drives. A separate budget — it does not consume maxSteps. |
`generateObject` and `streamObject` refuse loop options by design
Structured output is a single shaped answer, not a multi-step run. Passing tools/maxSteps there is an error rather than a silent no-op. If you need an agent that ends in a typed object, run the loop with generateText and call generateObject on the result.
Result shape
interface GenerateTextResult {
text: string;
usage: Usage;
finishReason: FinishReason;
response: { messages: Message[] };
steps?: StepResult[];
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
pendingApprovals?: ToolApprovalRequest[];
warnings?: CallWarning[];
providerMetadata?: Record<string, Record<string, unknown>>;
runId?: string;
memory?: Promise<MemoryMutation[]>;
observation?: { settled: Promise<void> };
}| Field | When | Notes |
|---|---|---|
text | always | Final assistant text. With tools, this is the last step's text — which is empty if the run ended on a tool call (see the maxSteps callout above). |
usage | always | Token usage summed across all steps, sub-agents included. usage.totalTokens is the provider's own total where one exists, not input + output — on a thinking model those differ by a lot. |
finishReason | always | 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'error' | 'aborted'. |
response.messages | always | Only the new turns this call produced. Append them to your prior messages to continue. |
steps | with tools | Per-step breakdown. undefined on a single-turn call — that absence is how you tell the two shapes apart. |
toolCalls / toolResults | with tools | Convenience: the last tool-calling step's calls and results. |
pendingApprovals | client-mode approval break | Calls awaiting a verdict. Resume by calling again with approvalResponses. |
warnings | when any were produced | Non-fatal notices — an unknown model slug, a stripped sampling parameter, a dropped document, a hosted tool the wire cannot carry. Omitted entirely when there are none. Deduplicated by (type, setting, message) and capped at 50. One gap: an activeTools name that matches nothing is logged here but not recorded on the field — only the streaming loop records it. |
providerMetadata.deuz | when the loop has something to report | stoppedBy (which named stopWhen/budget condition ended the run), verified (the verifyStep verdict), guardrails, handoffs. Absent when the run ended naturally or on the implicit maxSteps bound. |
runId | with session | The durable run id — pass it to resumeFromCheckpoint. |
memory | with memory extraction on | Resolves with the applied mutations once the post-run extract pass finishes. Never rejects. Await it on serverless or the isolate may be torn down first. |
observation | with an observer or tracer | await result.observation?.settled before closing observers, so async enrichments (e.g. price lookups) are flushed. |
A single buffered turn
With no tools, steps / toolCalls / toolResults are absent and exactly one model call happens.
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const { text, usage, finishReason } = await generateText({
model: anthropic('claude-opus-4-8'),
prompt: 'Name three primary colors.',
});
console.log(text);
console.log(finishReason); // 'stop'
console.log(usage.totalTokens);With tools (the agentic loop)
Add tools and generateText becomes the loop: call the model, execute any tool calls in parallel (capped by maxToolConcurrency), feed the results back as a new turn, repeat. A thrown execute is caught and fed back as an error tool result — it never rejects the promise.
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const { text, steps } = await generateText({
model: anthropic('claude-opus-4-8'),
prompt: 'What is the weather in Paris?',
maxSteps: 5, // WITHOUT THIS the loop runs one turn and `text` is empty
tools: {
getWeather: {
description: 'Get the current weather for a city.',
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, tempC: 22, sky: 'sunny' }),
},
},
});
console.log(text); // final natural-language answer
console.log(steps?.length); // e.g. 2: one tool turn + one final turnZod is optional. A raw JSON Schema works with no extra dependency at all:
const tools = {
getWeather: {
description: 'Get the current weather for a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
additionalProperties: false,
},
execute: async (args: { city: string }) => ({ city: args.city, tempC: 22 }),
},
} as const;For argument types that flow into execute without a cast, wrap the definition in tool().
Step array anatomy
result.steps is a StepResult[] — one entry per model turn, in order.
interface StepResult {
stepType: 'initial' | 'tool-result';
text: string;
reasoningText?: string;
toolCalls: ToolCall[];
toolResults: ToolResult[];
finishReason: FinishReason;
usage: Usage;
response: { messages: Message[] };
}The first step is 'initial'; every step produced by feeding tool results back is 'tool-result'. A step with empty toolCalls is the loop's last step. usage on each step is that step alone; result.usage is the sum.
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { z } from 'zod';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const result = await generateText({
model: anthropic('claude-opus-4-8'),
prompt: 'Weather in Paris and Berlin?',
maxSteps: 5,
tools: {
getWeather: {
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, tempC: 22 }),
},
},
onStepFinish: (step) => {
console.log(`[${step.stepType}] ${step.toolCalls.length} tool call(s)`);
},
});
for (const step of result.steps ?? []) {
for (const call of step.toolCalls) {
console.log('called', call.toolName, 'with', call.args);
}
for (const r of step.toolResults) {
console.log('result', r.toolName, r.isError ? '(error)' : '', r.result);
}
}`onStepFinish` does not fire for the final step
It fires after each step that made tool calls (including approval and client-tool breaks). The terminal text-only step — the one that produces the answer — is not reported through it, because that step is the result. Read it from result.steps.at(-1) or from result.text.
Stopping early
stopWhen adds predicate(s) OR-ed with maxSteps. Because they are OR-ed, stopWhen can only make the run shorter; it never extends the step budget.
Five ready-made conditions ship from the package root, so you rarely need to write your own:
| Condition | Stops when | Needs |
|---|---|---|
stepCountIs(n) | stepCount >= n | — |
hasToolCall(name) | the latest step called that tool | — |
totalTokensExceed(n) | cumulative real provider-reported usage crosses n | — |
costExceeds(usd) | cumulative cost crosses usd | deps.priceProvider — without one it warns once and never fires |
durationExceeds(ms) | the loop has run at least ms | nothing (time comes from the injected deps.clock, so it is deterministic in tests) |
import { stepCountIs, totalTokensExceed } from '@deuz-sdk/core';
// Stop at 10 steps or once accumulated usage crosses 50k tokens.
const result = await generateText({
model: anthropic('claude-opus-4-8'),
prompt: 'Plan my trip.',
stopWhen: [stepCountIs(10), totalTokensExceed(50_000)],
tools: { /* … */ },
});budget is the same machinery with a friendlier shape and a readable marker:
const result = await generateText({
model: anthropic('claude-opus-4-8'),
prompt: 'Research this thoroughly.',
maxSteps: 20,
budget: { usd: 0.5, tokens: 200_000 },
tools: { /* … */ },
});
// 'budget.usd' | 'budget.tokens' | 'totalTokensExceed' | … | undefined when it ended naturally
console.log(result.providerMetadata?.deuz?.stoppedBy);Custom conditions are plain functions and may be async:
import type { StopCondition } from '@deuz-sdk/core';
// Stop as soon as a step has produced final text (no tool calls).
const untilFinalText: StopCondition = ({ steps }) =>
(steps.at(-1)?.toolCalls.length ?? 0) === 0;
const result = await generateText({
model: anthropic('claude-opus-4-8'),
prompt: 'Plan my trip.',
maxSteps: 8,
stopWhen: untilFinalText,
tools: { /* … */ },
});Stop conditions run at step boundaries
Every condition is evaluated after a step that executed tools, and never mid-step. A budget of $0.50 therefore stops the run once cost has crossed the line, not before it does — an in-flight step always completes and is paid for. Size the budget with one step of headroom.
Continuing a conversation
response.messages holds only the new turns. Append them; never mutate the array you passed in — stable history is what makes provider prompt-cache hits possible.
import type { Message } from '@deuz-sdk/core';
let messages: Message[] = [{ role: 'user', content: 'Weather in Paris?' }];
const first = await generateText({
model: anthropic('claude-opus-4-8'),
messages,
maxSteps: 5,
tools: { /* … */ },
});
messages = [...messages, ...first.response.messages];
// next turn reuses the full, stable history (prompt-cache friendly)Approving tool calls
A tool marked needsApproval is gated before it runs. Two modes, and which one you get depends only on whether you passed approveToolCall:
- Server mode (
approveToolCallsupplied) — the loop asks your function per gated call and runs it only ontrue. Returningfalseor throwing denies it; the denial becomes anis_errortool result and the loop continues. Use this when policy lives on the server. - Client mode (no
approveToolCall) — the gated call breaks the loop and comes back inresult.pendingApprovals. Collect verdicts from your user, then callgenerateTextagain with the appended history plusapprovalResponses. Any pending call without a matching response is denied by default — the safe side.
Error handling
import { generateText, RateLimitError, ContextOverflowError, APICallError } from '@deuz-sdk/core';
try {
const { text } = await generateText({ model, prompt: 'hi' });
} catch (err) {
if (err instanceof RateLimitError) console.log('retry after (ms):', err.retryAfterMs);
else if (err instanceof ContextOverflowError) { /* trim history and retry */ }
else if (err instanceof APICallError) console.error(err.provider, err.statusCode, err.requestId);
else throw err;
}Two things that do not reject:
- A tool that throws. It becomes an
is_errortool result fed back to the model, which is usually able to recover. Look fortoolResults[i].isError. - A user abort. It resolves with
finishReason: 'aborted'and whatever partial usage accumulated. ATimeoutError, by contrast, rejects — see Abort vs timeout.
Loop guarantees
The loop is self-healing and bounded. The invariants that matter here:
- Client tools (a key present in
toolswith noexecute) cannot be auto-run — the loop stops and returns them intoolCallsso the caller owns the round-trip. - An unregistered tool name is not a client tool (1.9): a hallucinated name self-heals into an
is_errortool_resultlisting the real tool names, and the loop continues rather than breaking out with a danglingtool_use. - Runaway guard: the same tool erroring on three consecutive steps hard-stops the loop. Unknown-tool errors and tool timeouts count toward it; approval denials do not.
- Stop on tool count, not
finishReason: the loop continues whenever the last step emitted tool calls, even when the provider reportedfinish: stop. This is a live-verified Gemini behaviour, not a hypothetical.
For the full anatomy — the exact per-step ordering, immutable history, parallel execution, and every guard — see the tool loop reference.
What generateText does not do
- No partial output. If the run fails mid-generation, you get an exception, not the tokens that already arrived. Use
streamChatif partial output has value to you. - No
consume()and no lazy start. It is a promise; calling it is starting it. - No live events.
onStepFinishandonUsageare the only progress hooks; there is no per-token callback.streamChat'sfullStreamis the event channel. - No structured-output validation.
responseFormat: 'json'asks for JSON; it does not check it. Use generateObject for a schema-validated result with a repair retry.
See also
- streamChat — the streaming counterpart; same orchestration, token-by-token output.
- Tool loop — the agentic loop's step-by-step anatomy.
- generateObject — buffered structured output validated against a schema.
- Prompts, instructions & timeouts — the call-shape options in full.
- Error handling — the taxonomy this call rejects with.