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: same orchestration (retry, timeout, the canonical delta stream), but it accumulates every delta for you and returns a plain object. Reach for it when you want the final answer in one await and don't need to render tokens as they arrive. Pass tools and it becomes the agentic loop — model step, tool execution, feed results back, repeat.
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.
Options
generateText takes the same CommonCallOptions as every other call. The most common:
| Option | Type | Default | Notes |
|---|---|---|---|
model | LanguageModel | — | From a provider factory, e.g. createAnthropic(...)('claude-opus-4-8'). |
messages | Message[] | — | Canonical message history. |
temperature | number | provider | Sampling temperature. |
maxOutputTokens | number | provider | Cap on generated tokens. |
topP | number | provider | Nucleus sampling. |
stopSequences | string[] | — | Hard stop strings. |
effort | 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | — | Canonical reasoning effort; each adapter maps it to its own unit ('xhigh'/'max' clamp down on wires that lack them). |
signal | AbortSignal | — | Cancellation; propagated to the underlying fetch and to tool execute. |
maxRetries | number | 2 | Pre-first-byte retries only. |
headers | Record<string, string> | — | Extra request headers. |
onUsage | (usage, meta) => void | — | Per-request usage callback. |
onFinish | (meta) => void | — | Fires when the call settles. |
Agentic options (only meaningful with tools):
| Option | Type | Default | Notes |
|---|---|---|---|
tools | ToolSet | — | A Record<string, Tool>. Presence switches on the loop. |
toolChoice | 'auto' | 'required' | 'none' | { type: 'tool'; toolName: string } | 'auto' | Forces / forbids tool use. |
maxSteps | number | 1 | Max model turns in the loop. |
stopWhen | StopCondition | StopCondition[] | — | Extra stop predicate(s), OR-ed with maxSteps. |
maxToolConcurrency | number | 5 | Max parallel tool executions per step. |
onStepFinish | (step: StepResult) => void | — | Fires after each completed step. |
prepareStep | (ctx) => PrepareStepResult | undefined | Promise<…> | — | Pre-step hook run before every model call (after compaction). Return per-step overrides to rewrite messages, swap the model, restrict activeTools, or override toolChoice for that step. |
compaction | 'auto' | CompactionPolicy | off | Opt-in automatic layered context compaction for the loop. Pruning is free; the summarize layer costs one extra model call whose usage counts toward the total. History stays immutable. |
activeTools | string[] | — | Static tool filter: only these tools keys are sent to the model across all steps; unknown names log a warning and are ignored. prepareStep's activeTools overrides it per step. |
approveToolCall | (call, ctx) => boolean | Promise<boolean> | — | Server-mode approval gate for needsApproval tools; return false (or throw) to deny — denials become an is_error tool result and the loop continues. |
approvalResponses | ToolApprovalResponse[] | — | Resume after a client-mode break: verdicts for the trailing assistant turn's pending calls. Unmatched pending calls are denied by default. |
Result shape
interface GenerateTextResult {
text: string;
usage: Usage;
finishReason: FinishReason;
response: { messages: Message[] };
steps?: StepResult[];
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
pendingApprovals?: ToolApprovalRequest[];
}| Field | Type | When | Notes |
|---|---|---|---|
text | string | always | Final assistant text. With tools, this is the last step's text. |
usage | Usage | always | Token usage summed across all steps. |
finishReason | FinishReason | always | One of 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'error' | 'aborted'. |
response.messages | Message[] | always | New messages to append to history (assistant + tool turns across all steps). |
steps | StepResult[] | with tools | Per-step breakdown. undefined on a single-turn call (no tools). |
toolCalls | ToolCall[] | with tools | Convenience: the last tool-calling step's calls. |
toolResults | ToolResult[] | with tools | Convenience: that step's results. |
pendingApprovals | ToolApprovalRequest[] | client-mode approval break | Calls awaiting a verdict when a needsApproval tool broke the loop; resume by calling again with approvalResponses. |
response.messages only contains the new turns this call produced — append them to your prior messages to continue the conversation.
Simple call
A single buffered turn. With no tools, steps/toolCalls/toolResults are absent.
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'),
messages: [{ role: 'user', content: 'Name three primary colors.' }],
});
console.log(text);
console.log(finishReason); // 'stop'
console.log(usage.totalTokens);With tools (the agentic loop)
Add tools and generateText runs the loop: it calls the model, executes any tool calls in parallel (capped by maxToolConcurrency), feeds the results back as a new turn, and repeats until the model stops calling tools or a stop condition fires. maxSteps bounds the number of model turns — leave it at the default 1 and the loop runs a single turn, so for real tool use set it higher.
Tools are defined with a parameters schema (any Standard Schema such as Zod, or a raw JSON Schema) and an execute function. A thrown execute is caught and fed back to the model 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'),
messages: [{ role: 'user', content: 'What is the weather in Paris?' }],
maxSteps: 5,
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 turnYou can also pass a raw JSON Schema instead of Zod — no extra dependency required:
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;Step array anatomy
When tools are used, result.steps is a StepResult[] — one entry per model turn, in order. Each step:
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'; subsequent steps (produced because the previous step's tool results were fed back) are 'tool-result'. A step that called no tools has empty toolCalls/toolResults and is the loop's last step. usage on each step is that step alone; result.usage is the sum.
Reading steps
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'),
messages: [{ role: 'user', content: '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);
}
}Stopping early with stopWhen
stopWhen adds predicate(s) that are OR-ed with maxSteps. A StopCondition is a function over the loop state; return true to stop after the current step.
The SDK also ships four ready-made stop conditions you can import from @deuz-sdk/core instead of writing your own: stepCountIs(n), hasToolCall(name), totalTokensExceed(n), and costExceeds(usd). The last two read the real provider-reported usage accumulated across all steps; costExceeds needs deps.priceProvider injected (without one it warns once and never fires).
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'),
messages: [{ role: 'user', content: 'Plan my trip.' }],
stopWhen: [stepCountIs(10), totalTokensExceed(50_000)],
tools: { /* … */ },
});You can also write a custom StopCondition:
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'),
messages: [{ role: 'user', content: 'Plan my trip.' }],
maxSteps: 8,
stopWhen: untilFinalText,
tools: { /* … */ },
});Continuing a conversation
response.messages holds only the new turns. Append them to keep the history immutable across calls:
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. There are two modes:
- Server mode — pass
approveToolCall. The loop calls it for each gated tool and runs the call only if it returnstrue; returningfalse(or throwing) denies it, and the denial is fed back as anis_errortool result so the loop continues. - Client mode — omit
approveToolCall. A gated call breaks the loop and is returned inresult.pendingApprovals. Collect verdicts from your user, then callgenerateTextagain with the same appended history plusapprovalResponsesto resume; any pending call without a matching response is denied by default.
Loop guarantees
The loop is self-healing and bounded. A few invariants worth knowing here:
- Client tools (a
Toolwith noexecute) cannot be auto-run — the loop stops and returns them intoolCallsso the caller owns the round-trip. - Runaway guard: the same tool failing on three consecutive steps hard-stops the loop.
- Stop on tool count, not
finishReason: the loop continues whenever the last step emitted tool calls, even if the provider reportedfinish: stop(a Gemini quirk).
For the full set of agentic invariants — immutable history, parallel execution, the runaway guards, and the Gemini stop-bug guard — see the tool loop reference.
See also
- streamChat — the streaming counterpart; same orchestration, token-by-token output.
- generateObject — buffered structured output validated against a schema.
- Tool loop — the agentic loop's invariants in depth.