Common Patterns

Agent Structure

Keep stable runtime objects separate from request-local state.

Build agents from explicit TypeScript values. A built agent is an immutable runtime configuration: id, model, instructions, static context, tools, hooks, observers, memory, and defaults. It should be safe to import from routes, jobs, tests, and Studio when its tools do not capture the current request.

When tools or context need the current user, tenant, request, transaction, or permission state, create a scoped agent in a factory or runner. Do not hide request state in module-level variables.

Stable Agent Module

Use a shared built agent when every registered tool is context-free, read-only, or already enforces its own context through injected services.

// src/ai/support-agent.ts
import { AgentBuilder } from "@anvia/core";
import { OpenAIClient } from "@anvia/openai";
import { supportTools } from "./support-tools";

const client = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY });
const model = client.completionModel("gpt-5.5");

export const supportAgent = new AgentBuilder("support", model)
  .name("Support Agent")
  .description("Answers support questions and uses support tools.")
  .instructions(
    "Answer support questions clearly. Ask for missing details before guessing.",
  )
  .tools(supportTools)
  .defaultMaxTurns(3)
  .build();

The stable agent id is part of the runtime contract. It appears in traces, Studio, sessions, nested-agent tools, and workflows that reference the agent.

Request Boundary

Keep user input, auth state, tenant data, message history, trace metadata, route-specific limits, and application error mapping at the call site or runner.

import { Message } from "@anvia/core";
import { supportAgent } from "./support-agent";

export async function runSupportTurn(input: SupportTurnInput) {
  const history = await input.conversations.loadMessages(input.conversationId);

  const response = await supportAgent
    .prompt([...history, Message.user(input.message)])
    .withTrace({
      name: "support-chat",
      userId: input.user.id,
      metadata: {
        tenantId: input.user.tenantId,
        conversationId: input.conversationId,
      },
    })
    .maxTurns(2)
    .send();

  await input.conversations.append(input.conversationId, response.messages);

  return response.output;
}

This keeps the agent reusable. A background job can call the same agent with a different trace name, a test can replace history with fixtures, and Studio can run the built agent without product-route state.

Scoped Agent Factory

Use a factory when tools or context need current user data, tenant data, feature flags, service handles, or a transaction.

import type { CompletionModel } from "@anvia/core";
import { AgentBuilder } from "@anvia/core";
import { createSupportTools } from "./support-tools";

type SupportAgentScope = {
  userId: string;
  tenantId: string;
  plan: "free" | "pro" | "enterprise";
  services: {
    orders: OrdersService;
    tickets: TicketsService;
  };
};

export function createSupportAgent(model: CompletionModel, scope: SupportAgentScope) {
  return new AgentBuilder("support", model)
    .instructions("Answer support questions clearly. Use tools for account data.")
    .tools(
      createSupportTools({
        userId: scope.userId,
        tenantId: scope.tenantId,
        orders: scope.services.orders,
        tickets: scope.services.tickets,
      }),
    )
    .context(`Current customer plan: ${scope.plan}`, "customer-plan")
    .defaultMaxTurns(3)
    .build();
}

The factory should receive explicit dependencies. Avoid reading the current user, request, tenant, or transaction from global state inside the agent module.

Shared Configuration Helper

If stable and scoped agents share most behavior, extract a small builder helper.

import type { AgentBuilder, CompletionModel } from "@anvia/core";

function configureSupportBehavior(builder: AgentBuilder<CompletionModel>) {
  return builder
    .name("Support Agent")
    .description("Answers support questions and performs safe support actions.")
    .instructions("Answer clearly. Ask before guessing. Use tools for account data.")
    .defaultMaxTurns(3);
}

export function createScopedSupportAgent(model: CompletionModel, scope: SupportAgentScope) {
  return configureSupportBehavior(new AgentBuilder("support", model))
    .tools(createSupportTools(scope))
    .context(`Current customer plan: ${scope.plan}`, "customer-plan")
    .build();
}

Prefer factories and helpers over mutating a built agent. Build once for stable configuration, or build per request when the tool set captures request-local state.

Decision Table

Put it hereWhen
AgentBuilderstable identity, instructions, static context, context-free tools, default limits, observers
scoped agent factoryrequest-scoped tools, tenant-aware context, feature-flagged tools, transaction-bound services
runnervalidation, auth, history, trace metadata, persistence, error mapping
prompt requestcurrent input, one-off max turns, tool concurrency, request trace metadata
tool factoryuser id, tenant id, service handles, permission scope
route or jobtransport parsing, response shape, job retry policy, caller-specific timeouts