Deuz SDK 2.0 is out — stores, guardrails, handoffs, and zero-config MCP. What is new in 2.0
Deuz SDK

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, so try/catch works.

Reach for streamChat instead when:

  • A human is waiting. Time-to-first-token is the number they feel; generateText has 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:

OptionTypeDefaultWhen you want it
modelLanguageModelAlways. From a provider factory, e.g. createAnthropic(...)('claude-opus-4-8').
messagesMessage[]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.
promptstring1.9. One-shot calls. Shorthand for exactly one user turn. See Prompts.
instructionsstring1.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.
temperaturenumberproviderYou want more or less variance. Silently dropped (with a warning) on reasoning models that reject it.
maxOutputTokensnumberproviderYou need a hard cap on spend or length. Watch for finishReason: 'length'.
topPnumberproviderNucleus sampling. Same reasoning-model caveat as temperature.
stopSequencesstring[]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.
signalAbortSignalThe 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.
maxRetriesnumber2Flaky upstream. Pre-first-byte only — see Retries.
timeoutnumber | { ttftMs?, totalMs?, stepMs?, toolMs? }ttft 60s / total 300sYou 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.
capabilitiesPartial<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.
headersRecord<string, string>A gateway needs a custom header.
depsDependenciesin-memory defaultsTests, 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) => voidPer-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) => voidPer-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).

OptionTypeDefaultWhen you want it
toolsToolSetA 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').
maxStepsnumber1Always, for real tool use. Counts model turns, not tool calls.
stopWhenStopCondition | 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.
maxToolConcurrencynumber5Your tools hit a rate-limited API and 5 at once is too many, or they are cheap and you want more.
onStepFinish(step: StepResult) => voidProgress 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.
activeToolsstring[]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' | CompactionPolicyoffLong 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.
approvalResponsesToolApprovalResponse[]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.
maxVerifyAttemptsnumber3Cap 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> };
}
FieldWhenNotes
textalwaysFinal 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).
usagealwaysToken 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.
finishReasonalways'stop' | 'length' | 'tool_calls' | 'content_filter' | 'error' | 'aborted'.
response.messagesalwaysOnly the new turns this call produced. Append them to your prior messages to continue.
stepswith toolsPer-step breakdown. undefined on a single-turn call — that absence is how you tell the two shapes apart.
toolCalls / toolResultswith toolsConvenience: the last tool-calling step's calls and results.
pendingApprovalsclient-mode approval breakCalls awaiting a verdict. Resume by calling again with approvalResponses.
warningswhen any were producedNon-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.deuzwhen the loop has something to reportstoppedBy (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.
runIdwith sessionThe durable run id — pass it to resumeFromCheckpoint.
memorywith memory extraction onResolves 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.
observationwith an observer or tracerawait 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.

generate.ts
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.

weather.ts
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 turn

Zod 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.

read-steps.ts
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:

ConditionStops whenNeeds
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 usddeps.priceProvider — without one it warns once and never fires
durationExceeds(ms)the loop has run at least msnothing (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 (approveToolCall supplied) — the loop asks your function per gated call and runs it only on true. Returning false or throwing denies it; the denial becomes an is_error tool 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 in result.pendingApprovals. Collect verdicts from your user, then call generateText again with the appended history plus approvalResponses. 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_error tool result fed back to the model, which is usually able to recover. Look for toolResults[i].isError.
  • A user abort. It resolves with finishReason: 'aborted' and whatever partial usage accumulated. A TimeoutError, 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 tools with no execute) cannot be auto-run — the loop stops and returns them in toolCalls so 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_error tool_result listing the real tool names, and the loop continues rather than breaking out with a dangling tool_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 reported finish: 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 streamChat if partial output has value to you.
  • No consume() and no lazy start. It is a promise; calling it is starting it.
  • No live events. onStepFinish and onUsage are the only progress hooks; there is no per-token callback. streamChat's fullStream is 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

On this page