Skip to main content

AI Agents

An agent is a module-owned contract the runtime uses to dispatch an LLM call with a deterministic tool surface, feature-gated access, and an explicit mutation posture. Agents live in each module's ai-agents.ts at the module root and are discovered by the generator.

Writing an ai-agents.ts

// packages/core/src/modules/customers/ai-agents.ts
import type { AiAgentDefinition } from '@saasframe/ai-assistant'

export const accountAssistant: AiAgentDefinition = {
id: 'customers.account_assistant',
moduleId: 'customers',
label: 'Account Assistant',
description: 'Read-only CRM explorer for people, companies, deals.',
systemPrompt: '...composed from PromptTemplate sections...',
allowedTools: [
'search.entities',
'attachments.download',
'meta.describe_agent',
'customers.get_person',
'customers.list_deals',
'customers.get_activities',
],
taskPlan: { enabled: true },
readOnly: true,
mutationPolicy: 'read-only',
requiredFeatures: ['customers.view'],
acceptedMediaTypes: ['image', 'pdf'],
domain: 'customers',
keywords: ['crm', 'people', 'companies', 'deals'],
suggestions: [
{ label: 'Show recent deals', prompt: 'Show recent deals' },
],
}

export default [accountAssistant]

After adding or editing ai-agents.ts, run:

yarn generate

The generator aggregates every module's agents into apps/saasframe/.saasframe/generated/ai-agents.generated.ts.

AiAgentDefinition fields

FieldRequiredNotes
idyesMust match <moduleId>.<agent> (lowercase, digits, underscores).
moduleIdyesUsed for SF_AI_<MODULE>_MODEL resolution and generator grouping.
label / descriptionyesShown in the agent picker and playground.
systemPromptyesThe composed system prompt the runtime feeds to the model.
allowedToolsyesWhitelist. Tools not listed here are never exposed — even when registered.
readOnlyno (default true)When true, every isMutation: true tool is filtered out BEFORE the model sees it.
mutationPolicynoread-only | confirm-required | destructive-confirm-required.
requiredFeaturesnoACL features gated at the dispatcher route.
acceptedMediaTypesnoSubset of image | pdf | file.
executionModeno (default chat)chat or object. See below.
outputnoStructured-output schema when executionMode === 'object'.
defaultProvidernoProvider id this agent prefers (e.g. 'openai', 'anthropic'). Must match a registered provider id. Omit for generic shipped agents so SF_AI_PROVIDER controls the runtime default. Set this only when an agent's defaultModel intentionally requires a specific provider; paired provider/model defaults fail closed if the provider is not configured. Phase 1 of spec 2026-04-27-ai-agents-provider-model-baseurl-overrides.
defaultModelnoOptional agent-level model pin fed through the factory (see Overview). Omit for generic shipped agents so SF_AI_MODEL controls the runtime default. Accepts a plain id (claude-haiku-4-5) or a slash-qualified <provider>/<model> shorthand (e.g. openai/gpt-5-mini). The slash prefix must match a registered provider id; DeepInfra-style ids that contain slashes are kept intact via the registry-membership guard.
maxStepsnoDeprecated. Use loop.maxSteps instead. Honored as an alias when loop is omitted.
loopnoAgentic loop controls — stopWhen, prepareStep, repairToolCall, onStepFinish, activeTools, toolChoice, and budget (maxToolCalls, maxWallClockMs, maxTokens). See Agentic loop controls below.
executionEngineno (default stream-text)'stream-text' (default streamText dispatch) or 'tool-loop-agent' (opt into Vercel Experimental_Agent). See Choosing an execution engine.
allowRuntimeOverrideno (default true)Permit per-call <ModelPicker> + ?provider=&model=&loopBudget=... overrides. Renamed from allowRuntimeModelOverride.
taskPlanno{ enabled: true } opts the agent into the visible planning helper. The registry exposes meta.update_task_plan automatically; omit it from allowedTools. CRM/customer agents enable this by default, other shipped agents do not.
resolvePageContextnoAsync callback invoked when the request carries { entityType, recordId }. Returns extra prompt text appended to systemPrompt.
suggestionsnoStarter prompts shown by generic chat launchers before the first message.
keywords / domain / dataCapabilitiesnoMetadata for the agent picker and search.

Provider and model configuration

defaultProvider and defaultModel

Most agents should omit both fields and inherit the runtime default from SF_AI_PROVIDER / SF_AI_MODEL. Set them only when an agent intentionally needs a preferred provider and model without overriding the global envs:

export const merchandisingAssistant: AiAgentDefinition = {
id: 'catalog.merchandising_assistant',
moduleId: 'catalog',
// ...
defaultProvider: '<provider-id>',
defaultModel: '<model-id>', // or slash-qualified: '<provider-id>/<model-id>'
}

defaultProvider must match a registered provider id (anthropic, google, openai, deepinfra, groq, together, fireworks, azure, litellm, ollama). When defaultProvider is paired with defaultModel, the provider/model pair is atomic: if that provider is not configured, the factory fails before any upstream call. A provider-only preference can still fall through to the next configured provider because no provider-specific model id is at risk.

Slash-qualified defaultModel values (e.g. openai/gpt-5-mini) consume the provider axis in one go: the slash prefix names the provider and the remainder names the model, so defaultProvider is effectively overridden by the slash prefix. DeepInfra-style ids that already contain slashes (e.g. meta-llama/Llama-3.3-70B-Instruct-Turbo) are kept intact via the registry-membership guard.

allowRuntimeOverride

By default every agent shows the <ModelPicker> in the chat composer, allowing operators to select a different provider + model at runtime, and accepts per-call loop overrides (?loopBudget=...). Set allowRuntimeOverride: false to disable both for this agent:

export const auditAgent: AiAgentDefinition = {
id: 'audit.audit_assistant',
moduleId: 'audit',
// ...
allowRuntimeOverride: false, // always uses the server-side resolution chain; no per-call loop overrides
}

When false, the runtime also rejects ?provider= / ?model= / ?loopBudget= query parameters sent to the chat dispatcher route — the server-side resolution chain (steps 2–7 in Overview → Model resolution order) still applies.

The field was renamed from allowRuntimeModelOverride in Phase 4 of spec 2026-04-28-ai-agents-agentic-loop-controls because its scope now covers loop overrides as well. The runtime accepts both names — allowRuntimeOverride wins when both are set. Migrate at your convenience; the deprecated alias remains for one minor version.

Runtime model picker (<ModelPicker>)

The <AiChat> component automatically renders a <ModelPicker> dropdown in the composer toolbar for every agent where allowRuntimeModelOverride !== false. The dropdown is populated by GET /api/ai_assistant/ai/agents/:agentId/models and shows all providers and models configured on the server side. The user's selection is persisted to localStorage under the key om-ai-model-picker:<agentId> and forwarded to the chat dispatcher as ?provider=&model= query parameters. If the tenant allowlist later hides the stored model, <AiChat> replaces the stale selection with the first still-allowed model from the server response so the dispatcher never receives a now-forbidden picker value.

The picker is stateless on the server — it never stores the user's selection in the database or the session. When the component unmounts or the agent is changed, the selection reverts to the default. This is by design (R6 mitigation): the picker exposes only models from the curated registry; free-form baseURL input is intentionally absent.

// Embed AiChat — ModelPicker is wired automatically
import { AiChat } from '@saasframe/ui/ai'

<AiChat agent="customers.account_assistant" />

To embed the picker standalone (e.g. in a custom toolbar):

import { ModelPicker } from '@saasframe/ui/ai'
import type { ModelPickerValue } from '@saasframe/ui/ai'

<ModelPicker
providers={providers} // ModelPickerProvider[] from the /models endpoint
value={pickerValue} // ModelPickerValue | null
onChange={handleChange} // (value: ModelPickerValue | null) => void
defaultProviderId="anthropic" // highlighted as default
defaultModelId="claude-haiku-4-5"
/>

The providers prop is an array of ModelPickerProvider objects, each containing a providerId, label, and models: ModelPickerProviderModel[]. The component is fully stateless — the parent owns the value state and must persist it if needed.

Tool packs

Tools are declared per module with defineAiTool and registered under a tool pack. Every tool advertises whether it mutates:

import { defineAiTool } from '@saasframe/ai-assistant'
import { z } from 'zod'

export const getPerson = defineAiTool({
name: 'customers.get_person',
description: 'Return a single person by id with linked addresses and tags.',
isMutation: false,
requiredFeatures: ['customers.view'],
inputSchema: z.object({ personId: z.string().uuid() }),
async handler(args, ctx) {
const em = ctx.container.resolve('em')
// ... load, scope by tenant, return a serializable object
return { person: { /* ... */ } }
},
})

Shipped packs

PackTools (examples)When to use
search.*search.entities, search.customersCross-entity fulltext/vector search
attachments.*attachments.download, attachments.listRead attachment metadata and fetch blobs
meta.*meta.describe_agent, meta.update_task_planSelf-description and, when taskPlan.enabled, safe visible task-plan updates
customers.*customers.get_person, customers.list_deals, customers.update_deal_stageCRM read + a single curated mutation
catalog.*catalog.list_products, catalog.update_product, catalog.bulk_update_products, catalog.apply_attribute_extraction, catalog.update_product_media_descriptionsMerchandising demo tools (read + four D18 writes)
Mutation tools never bypass readOnly

When agent.readOnly === true (the default), the runtime strips any tool with isMutation: true from the model's tool surface. To unlock writes you MUST set readOnly: false AND declare a non-read-only mutationPolicy. Operators can then further downgrade — but never escalate — via the tenant settings page.

Default policy for write-capable agents

Agents shipped in packages/core that whitelist any isMutation: true tool default to mutationPolicy: 'confirm-required', not read-only. The intent: writes are allowed but every one needs explicit user approval through the mutation approval card. Per-tenant overrides can downgrade to read-only to lock writes back down without a redeploy. They cannot escalate above what the agent declares.

Visible task plans

For operator-facing chat agents, set taskPlan: { enabled: true } to expose the read-only meta.update_task_plan helper. The runtime injects prompt guidance telling the model to call it before domain tools on tool-using turns, and <AiChat> renders those agent-authored steps as the compact live plan above raw tool-call details.

This is a code-level agent contract, not a tenant setting in AI Agents. CRM/customer agents ship with it enabled by default; other agents stay quiet unless their AiAgentDefinition or an aiAgentExtensions entry opts in.

Keep labels short and user-facing:

taskPlan: { enabled: true },
allowedTools: ['catalog.search_products', 'catalog.get_product']

Prompt guidance should say that task-plan labels are progress UI, not hidden reasoning. Do not put chain-of-thought, scratchpad notes, or XML thinking tags in the plan. When you know which tool a step maps to, include toolName so the runtime can update that planned row from pending to running and done as the tool lifecycle streams.

Replacing or disabling a registered agent

Modules can replace or disable an agent registered by another module via three paths: extra aiAgentOverrides / aiToolOverrides exports on the module's existing ai-agents.ts / ai-tools.ts, inline on a ModuleEntry in apps/<app>/src/modules.ts, or programmatically via applyAiAgentOverrides({ ... }) / applyAiToolOverrides({ ... }) at boot. null disables; a definition replaces. See Overrides for the full contract, file layout, and resolution order.

Extending a registered agent

When a downstream module only needs to patch tools, prompt text, or starter prompts on an existing agent, export aiAgentExtensions instead of replacing the full agent:

import { defineAiAgentExtension } from '@saasframe/ai-assistant'

export const aiAgentExtensions = [
defineAiAgentExtension({
targetAgentId: 'catalog.catalog_assistant',
deleteAllowedTools: ['catalog.old_stats'],
appendAllowedTools: ['example.catalog_stats'],
appendSystemPrompt: 'Use example.catalog_stats when the operator asks for catalog metrics.',
deleteSuggestions: ['Old catalog stats'],
appendSuggestions: [
{ label: 'Show catalog stats', prompt: 'Show catalog stats' },
],
}),
]

Extensions run after replacement/disable overrides. They cannot resurrect a disabled agent; an extension whose targetAgentId is missing is skipped with a warning. Patch order is deterministic: replace* first, delete* second, append* last.

Supported patch fields:

FieldEffect
replaceAllowedTools / deleteAllowedTools / appendAllowedToolsReplace, remove, or append tool names in the agent whitelist.
taskPlanOpt the agent into or out of the visible planning helper, for example { enabled: true }. The registry adds/removes meta.update_task_plan automatically.
replaceSystemPrompt / appendSystemPromptReplace the prompt or append an extra prompt paragraph.
replaceSuggestions / deleteSuggestions / appendSuggestionsReplace, remove by label/prompt, or append starter prompts.
suggestionsBackward-compatible alias for appendSuggestions.

Concretely:

  • customers.account_assistantconfirm-required (whitelists customers.update_deal_stage + comment/activity tools)
  • catalog.merchandising_assistantconfirm-required (whitelists the four D18 mutation tools)
  • catalog.catalog_assistantread-only (genuinely read-only — no mutation tools)

Chat vs object execution mode

ModeHelperOutputWhen to use
chat (default)runAiAgentTextStreamed text + UI parts<AiChat> sheets, conversational assistants, multi-turn sessions
objectrunAiAgentObjectValidated JSON matching an output.schemaSingle-shot structured extraction, batch background enrichment, headless callers

Object-mode agents declare a Zod output.schema:

import { z } from 'zod'

export const attributeExtractor: AiAgentDefinition = {
id: 'catalog.attribute_extractor',
moduleId: 'catalog',
// ...
executionMode: 'object',
output: {
schemaName: 'CatalogAttributeExtraction',
schema: z.object({
productId: z.string().uuid(),
attributes: z.array(z.object({
key: z.string(),
value: z.string(),
})),
}),
},
}

The runtime delegates to Vercel AI SDK's generateObject / streamObject and returns the parsed result.

End-to-end example

// 1. Define the agent
// packages/core/src/modules/customers/ai-agents.ts
export const accountAssistant: AiAgentDefinition = { /* as above */ }

// 2. Define or whitelist the tools
// packages/core/src/modules/customers/ai-tools/get-person.ts
export const getPerson = defineAiTool({ /* as above */ })

// 3. Embed the chat UI
// packages/core/src/modules/customers/backend/customers/people/[id]/page.tsx
import { AiChat } from '@saasframe/ui/ai'

<AiChat
agent="customers.account_assistant"
pageContext={{ entityType: 'customers:person', recordId: params.id }}
/>

Where the runtime reads each field

  • checkAgentPolicy in lib/agent-policy.ts enforces requiredFeatures, tool whitelisting, readOnly/mutationPolicy, and acceptedMediaTypes.
  • resolveAiAgentTools in lib/agent-tools.ts filters the registered tool pack down to the agent's allowedTools and wires the mutation interceptor.
  • createModelFactory in lib/model-factory.ts picks the LLM per the resolution order.
  • runAiAgentText / runAiAgentObject in lib/agent-runtime.ts compose the final request.
  • tool-loader.ts registers every tool's full typed shape — isMutation, displayName, loadBeforeRecord(s), tags — so the agent settings UI badges mutation tools correctly and the mutation interceptor fires.

When the runtime invokes a tool handler it injects the matching AiToolDefinition into the McpToolContext.tool slot, so handlers can build an AiToolExecutionContext for createAiApiOperationRunner without losing route-gate coverage.

Using the Vercel AI SDK natively

Most code should go through runAiAgentText / runAiAgentObject so policy, tool whitelisting, model resolution, mutation approvals, and tenant scoping are enforced for you. There are two escape hatches when you genuinely need raw AI SDK access — one fully bypassed, one wrapped.

Option A — Raw AI SDK (no agent wrapper)

Use this only for internal scripts, migrations, evaluation harnesses, or non-tenant contexts. Reuse the framework's model factory so provider keys, env overrides, and tenant settings still flow through.

import { generateText, generateObject, streamText, streamObject } from 'ai'
import { z } from 'zod'
import { createModelFactory } from '@saasframe/ai-assistant/modules/ai_assistant/lib/model-factory'

const factory = createModelFactory({ moduleId: 'catalog' })
const model = await factory.resolve()

// Text
const { text } = await generateText({ model, prompt: 'Summarize this...' })

// Structured object
const { object } = await generateObject({
model,
schema: z.object({ summary: z.string(), tags: z.array(z.string()) }),
prompt: 'Extract summary and tags from ...',
})

// Streaming
const stream = streamText({ model, prompt: '...' })
for await (const chunk of stream.textStream) { /* ... */ }

Optionally reuse the registered tool packs (still without agent policy):

import { resolveAiAgentTools } from '@saasframe/ai-assistant/modules/ai_assistant/lib/agent-tools'

const tools = await resolveAiAgentTools({
allowedTools: ['search.entities', 'attachments.list'],
container,
authContext,
})
const { text } = await generateText({ model, tools, prompt: '...', maxSteps: 5 })

What you lose by going raw:

  • checkAgentPolicyrequiredFeatures, allowedTools whitelist, readOnly/mutationPolicy, acceptedMediaTypes are not enforced.
  • The mutation approval flow — prepareMutation + ai_pending_actions + approval cards do not run; never call mutation tools this way.
  • Page-context resolution — resolvePageContext and tenant-scoped system-prompt composition are skipped.
  • Audit trail — runs are not associated with an agent id, so playground/settings overrides and per-agent telemetry do not apply.
  • Per-tenant prompt and model overrides set in the AI Assistant settings UI.
  • Attachment-to-model bridging (image/PDF parts produced by acceptedMediaTypes).

When you want most of the wrapper's guarantees but need to reach into the raw AI SDK call (custom providerOptions, experimental_* flags, telemetry, additional onStepFinish logic, custom abortSignal plumbing, etc.), pass a generateText callback to runAiAgentText (or a generateObject callback to runAiAgentObject). The callback name mirrors the AI SDK function it ultimately invokes, so the call site stays coherent with the underlying SDK. The runtime still:

  • runs checkAgentPolicy (features, tool whitelist, mutation policy, media types),
  • resolves the model via createModelFactory and tenant overrides,
  • builds the tool map from allowedTools,
  • composes the system prompt (including resolvePageContext),
  • routes any mutation tool calls through prepareMutation + the approval contract,
  • composes the effective loop config from the agent's declaration, per-call overrides, and wrapper defaults,

and then hands the fully prepared options bag (PreparedAiSdkOptions) to your callback so you can call generateText / streamText (text runtime) or generateObject / streamObject (object runtime) directly. The streaming variants (streamText / streamObject) are reached from inside the same callback — the name describes which runtime family you are overriding, not which exact AI SDK function you must call.

import { runAiAgentText } from '@saasframe/ai-assistant'
import { streamText } from 'ai'

const response = await runAiAgentText({
agentId: 'customers.account_assistant',
container,
authContext,
messages,
generateText: async ({
model, tools, system, messages,
// loop primitives — see MUST rules below before omitting any
stopWhen,
prepareStep,
onStepFinish,
onStepStart,
onToolCallStart,
onToolCallFinish,
experimental_repairToolCall,
activeTools,
toolChoice,
abortSignal,
}) => {
return streamText({
model,
tools,
system,
messages,
stopWhen,
prepareStep,
onStepFinish,
onStepStart,
experimental_onToolCallStart: onToolCallStart,
experimental_onToolCallFinish: onToolCallFinish,
experimental_repairToolCall,
...(activeTools !== undefined ? { activeTools } : {}),
...(toolChoice !== undefined ? { toolChoice } : {}),
abortSignal,
experimental_telemetry: { isEnabled: true, functionId: 'customers.account_assistant' },
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral' } },
},
})
},
})

The matching hook on runAiAgentObject is named generateObject and additionally receives the resolved output.schema and the object-mode subset of loop primitives (maxSteps, onStepFinish, onStepStart, abortSignal).

MUST rules — security-critical fields you MUST NOT drop:

  • prepareStepSECURITY-CRITICAL. The wrapper-owned PrepareStepFunction re-asserts the tool allowlist and mutation-approval wrapping on every step. Dropping it means mutation tools are no longer routed through prepareMutation on steps 2+; the approval contract no longer applies. Never omit prepareStep unless you are deliberately removing mutation-approval guardrails for that call (and understand the consequences).
  • stopWhen — dropping this removes the agent's loop policy and the R3 step-count fallback, turning the call into a single model invocation that never executes tool calls.
  • tools, system, messages — dropping any of these bypasses the corresponding guardrail (tool allowlist, prompt composition, message history).

What you still lose with the generateText / generateObject callbacks:

  • Custom transports (e.g. swapping the HTTP client) move you outside the model factory's provider routing; per-tenant API key overrides set in settings will not apply.
  • UI streaming parts (AiUiPart tags consumed by <AiChat>) are produced by the default runtime path; if your callback returns a stream that does not emit those parts, the embedded chat UI degrades to plain text.

Pick Option B by default when you "just need one extra AI SDK option" — it keeps every other guardrail in place. Reach for Option A only when you are deliberately working outside the agent contract.

Agentic loop controls

The runtime always runs a tool-using loop (streamText with stopWhen). Spec 2026-04-28-ai-agents-agentic-loop-controls promotes that loop from "one step-count cap" to a first-class part of the agent contract — declarative loop block, per-call overrides, per-tenant operator budgets, runtime LoopTrace, and an opt-in ToolLoopAgent engine.

Resolution chain — per loop axis

Each axis (stopWhen, prepareStep, onStepFinish, repairToolCall, activeTools, toolChoice, maxSteps, budget) walks the same precedence the provider/model resolver uses:

  1. Per-request HTTP query (?loopBudget=tight) — gated by allowRuntimeOverride.
  2. Caller override — runAiAgentText({ loop }) / runAiAgentObject({ loop }).
  3. Per-tenant settings override — ai_agent_runtime_overrides.loop_* (see Settings → Loop policy overrides).
  4. SF_AI_<MODULE>_LOOP_* env shorthands (MAX_STEPS, BUDGET_TOKENS, BUDGET_MS).
  5. Agent definition — agent.loop (plus the deprecated agent.maxSteps alias).
  6. Wrapper default (stepCountIs(10) for chat, unset for object).

Operator-tightening only: an override can never widen what code declared, and an env value can never widen what the operator allowed.

The loop block

import type { AiAgentDefinition } from '@saasframe/ai-assistant'

export const accountAssistant: AiAgentDefinition = {
// ...
loop: {
maxSteps: 12,
// Halt the loop the moment the mutation tool fires so the approval card surfaces right away.
stopWhen: [{ kind: 'hasToolCall', toolName: 'customers.update_deal_stage' }],
// Per-step model and/or active-tool reshaping. Composed with the wrapper-owned prepareStep.
prepareStep: buildAccountAssistantPrepareStep(),
// Repair common malformed tool-call shapes instead of failing the turn.
repairToolCall: async ({ toolCall, error, repair }) => repair(toolCall),
// Hard ceilings — operators can tighten further per tenant.
budget: { maxToolCalls: 12, maxWallClockMs: 60_000, maxTokens: 80_000 },
},
allowRuntimeOverride: true,
}

stopWhen accepts an array; the runtime translates { kind: 'stepCount', count } to stepCountIs(count) and { kind: 'hasToolCall', toolName } to hasToolCall(toolName). { kind: 'custom', stop } accepts a raw SDK predicate but is rejected when supplied via the JSON-only tenant override path.

Security guarantee — wrapper-owned prepareStep

The mutation-approval contract requires that every isMutation: true tool call is intercepted before execution. The runtime composes its own PrepareStepFunction ahead of yours:

  1. Re-narrows the per-step tool surface to agent.allowedTools (∩ loop.activeTools when set).
  2. Re-checks the agent policy gate.
  3. Re-wraps every mutation tool's handler through prepareMutation so writes land in ai_pending_actions.
  4. Then invokes your loop.prepareStep on top of the guarded state.

mergeStepOverrides rejects user prepareStep returns that strip the prepareMutation wrappers with AgentPolicyError code loop_violates_mutation_policy. You cannot bypass mutation approvals from inside prepareStep — this is the security guarantee ToolLoopAgent also honors (the wrapper wires prepareStep at agent construction; see Choosing an execution engine).

Object-mode caveat

streamObject / generateObject accept maxSteps, onStepFinish, onStepStart, and abortSignal. They silently ignore prepareStep, repairToolCall, activeTools, toolChoice, and stopWhen on most providers. The runtime applies what the SDK accepts and emits one dev-time warning per agent listing the dropped primitives — never a silent drop.

LoopTrace and debug surfaces

Every completed turn emits an SSE loop-finish event carrying a LoopTrace:

  • Steps with the model id used per step (prepareStep swaps are visible here).
  • Tool calls per step with input/output shape and repairToolCall attempts.
  • Total tokens summed from usage fields.
  • The stop reason (stepCountIs / hasToolCall / loop_budget_exceeded / finish-reason:stop|tool-calls|length / aborted).

<AiChat debug> and the Playground Debug panel render the trace. When the per-tenant kill switch is active, <AiChat> renders a LoopDisabledBanner and the dispatcher collapses the agent to a single model call.

Canonical reference

customers.deal_analyzer (+ sibling customers.deal_analyzer_tool_loop for the 'tool-loop-agent' engine) in packages/core/src/modules/customers/ai-agents.ts exercises every primitive — see Demo: customers.deal_analyzer agentic loop below.

Choosing an execution engine

The executionEngine field on AiAgentDefinition selects the underlying Vercel AI SDK dispatch strategy. The default ('stream-text') is unchanged from previous releases; 'tool-loop-agent' is opt-in.

Feature'stream-text' (default)'tool-loop-agent'
SDK primitivestreamText(...)ToolLoopAgent (Experimental_Agent)
prepareStepSupported — passed to streamTextSupported — wired at construction (settings.prepareStep), NOT via prepareCall
stopWhenSupportedSupported — wired at construction
repairToolCallSupported (experimental_repairToolCall)Present in ToolLoopAgentSettings in current SDK, but behaviour parity across SDK versions is not guaranteed — prefer 'stream-text' when repair logic correctness is critical
Mutation-approval contractGuaranteed — buildWrapperPrepareStep wraps all mutation toolsGuaranteed — same buildWrapperPrepareStep is wired at construction; prepareMutation runs identically
Multi-agent handoff (future)Not plannedSDK-native; agents on 'tool-loop-agent' receive this feature first
prepareCall hookN/APer-turn narrowing of model, tools, stopWhen, activeTools, providerOptions only — prepareStep is not in the pick list

Key rule for 'tool-loop-agent': the wrapper-owned prepareStep (mutation-approval enforcement) MUST be wired at agent construction time via settings.prepareStep. It cannot be threaded through prepareCall because prepareStep is not in prepareCall's Pick list. The runtime handles this automatically — agent authors who use the generateText escape-hatch MUST NOT reconstruct a ToolLoopAgent without forwarding preparedOptions.prepareStep.

To opt in to the ToolLoopAgent backend for an agent:

export const catalogToolLoopAssistant: AiAgentDefinition = {
id: 'catalog.tool_loop_assistant',
moduleId: 'catalog',
executionEngine: 'tool-loop-agent',
// ...rest of the definition
}

Omitting executionEngine (or setting it to 'stream-text') leaves the existing dispatch path completely unchanged.

Demo: customers.deal_analyzer agentic loop

The customers.deal_analyzer agent in packages/core/src/modules/customers/ai-agents.ts is the canonical reference implementation that exercises every loop primitive in one place.

What it demonstrates

PrimitiveHow it is setEffect
Env/default model inheritancedefaultProvider and defaultModel omittedModel factory resolves the provider/model pair from env, tenant overrides, or runtime picker choices
Runtime model overrideallowRuntimeOverride: true<ModelPicker> visible in the chat composer toolbar
Loop step caploop.maxSteps: 12Prevents runaway multi-step loops
Stop-whenloop.stopWhen: [{ kind: 'hasToolCall', toolName: 'customers.update_deal_stage' }]Loop halts immediately after the mutation tool call; mutation card surfaces right away
Per-step tool shapingloop.prepareStep: buildDealAnalyzerPrepareStep()Step 0 keeps mutation tools inactive; later steps can propose the approved mutation path without overriding the model
Budgetloop.budget: { maxToolCalls: 12, maxWallClockMs: 60_000 }Hard wall-clock and tool-call ceiling
Both enginesexecutionEngine: 'stream-text' (default) + sibling customers.deal_analyzer_tool_loop with 'tool-loop-agent'Proves mutation gate holds for both dispatch paths (TC-AI-AGENT-LOOP-006)
uiPartsuiParts: ['saasframe:deal']Deal record cards auto-rendered in the chat

Key files

  • Tool: packages/core/src/modules/customers/ai-tools/deal-analyzer-pack.tscustomers.analyze_deals read-only tool (health score computation, bounded DB reads)
  • Agents: packages/core/src/modules/customers/ai-agents.tsdealAnalyzer + dealAnalyzerToolLoop definitions, buildDealAnalyzerPrepareStep()
  • UI widget: packages/core/src/modules/customers/widgets/injection/ai-deal-analyzer-trigger/<AiChat> embedded in Deals list :search-trailing slot
  • Injection table: packages/core/src/modules/customers/widgets/injection-table.tsdata-table:customers.deals.list:search-trailing spot
  • Tests: packages/core/src/modules/customers/__integration__/TC-AI-AGENT-DEAL-ANALYZER-001-007.spec.ts

Loop flow

  1. User opens the Deal Analyzer sheet on /backend/customers/deals (or the playground).
  2. Agent calls customers.analyze_deals (step 0, Sonnet) — returns deals ranked by health score.
  3. Agent identifies the highest-value stalled deal and calls customers.update_deal_stage (step 1, Haiku).
  4. loop.stopWhen fires — the loop halts and the mutation-preview card surfaces.
  5. Operator approves or rejects the stage move in the approval card.

The buildDealAnalyzerPrepareStep() function returns a PrepareStepFunction that creates a lazy model-factory singleton bound to null container (the factory only reads process.env and llmProviderRegistry, not the DI container). Step 0 resolves Sonnet for broad analysis; subsequent steps resolve Haiku for the cheaper mutation call.