Dependency injection in Vibes — declare what your agent needs, inject it at run time, and access it everywhere inside tools, prompts, and validators without globals.
Consider a support agent that queries a database. Without dependency injection you have two bad options:
Global variable — the database is a module-level singleton. This makes tests fragile (they share state), makes the dependency invisible in the type signature, and creates ordering problems at startup.
Closure — you close over the database when building the tool. This ties every tool to one specific database instance and makes it impossible to swap for a test double.
Vibes takes a third path: declare what you need as a type, inject the concrete instance at agent.run() time, and every callback inside the agent receives it automatically via RunContext.The result is a fully self-contained, trivially testable agent where the type checker verifies the dependency contract at every boundary.
Let’s build a customer lookup agent that needs a database to work. We’ll see the dependency flow from declaration → injection → usage in tools and prompts.
import { Agent, tool } from "jsr:@vibesjs/sdk";import { anthropic } from "@ai-sdk/anthropic";import { z } from "zod";// (1) Declare what the agent needs — just a TypeScript interfaceinterface SupportDeps { db: { getCustomer(id: string): Promise<{ name: string; plan: string } | null>; listTickets(customerId: string): Promise<{ id: string; summary: string }[]>; }; region: string;}// (2) Define tools typed to the same depsconst getCustomer = tool<SupportDeps>({ name: "get_customer", description: "Look up a customer by ID.", parameters: z.object({ customerId: z.string() }), execute: async (ctx, { customerId }) => { // (3) ctx carries ctx.deps.db const customer = await ctx.deps.db.getCustomer(customerId); if (!customer) return "Customer not found."; return `${customer.name} is on the ${customer.plan} plan.`; },});const listTickets = tool<SupportDeps>({ name: "list_tickets", description: "List open tickets for a customer.", parameters: z.object({ customerId: z.string() }), execute: async (ctx, { customerId }) => { const tickets = await ctx.deps.db.listTickets(customerId); if (tickets.length === 0) return "No open tickets."; return tickets.map((t) => `#${t.id}: ${t.summary}`).join("\n"); },});// (4) Create the agent — typed to SupportDepsconst supportAgent = new Agent<SupportDeps>({ model: anthropic("claude-sonnet-4-6"), systemPrompt: (ctx) => // (5) dynamic prompt uses deps too `You are a customer support agent in the ${ctx.deps.region} region.`, tools: [getCustomer, listTickets],});// (6) Inject the real database at run timeconst result = await supportAgent.run( "Look up customer C-42 and summarize their open tickets.", { deps: { db: productionDb, region: "us-east-1" } },);console.log(result.output);// "Customer Alice Smith (Pro plan) has 2 open tickets:// #101: Login fails after password reset// #104: Invoice PDF not loading"
Walk through the annotations:
SupportDeps is a plain TypeScript interface. No framework types, no decorators. Define it wherever makes sense in your codebase.
tool<SupportDeps> binds the tool to the deps type. TypeScript will catch any mismatch between the deps declared here and what you pass at agent.run() time.
ctx.deps.db is the injected database. The tool implementation never imports a global — it only uses what is given through ctx.
new Agent<SupportDeps>(...) declares the agent’s dependency contract. The second type parameter (TOutput) is omitted and defaults to string.
systemPrompt as a function receives the same RunContext. You can build dynamic prompts from runtime state without any closures or globals.
deps is passed in RunOptions. Swap in a test double here for testing — the agent code stays completely unchanged.
Every callback inside an agent — system prompts, tool execute functions, result validators, history processors — receives a RunContext<TDeps>. Its full interface is:
interface RunContext<TDeps = undefined> { deps: TDeps; // your injected dependencies usage: Usage; // tokens accumulated so far this run retryCount: number; // result validation retries so far toolName: string | null; // current tool name (null outside tool.execute) runId: string; // unique UUID for this run metadata: Record<string, unknown>; // per-run metadata from the caller toolResultMetadata: Map<string, Record<string, unknown>>; // metadata attached by tools attachMetadata(toolCallId: string, meta: Record<string, unknown>): void;}
How many result validation retries have occurred so far
toolName
string | null
Name of the currently executing tool; null outside tool.execute
runId
string
Unique identifier for this run (UUID)
metadata
Record<string, unknown>
Per-run caller metadata passed via RunOptions.metadata
toolResultMetadata
Map<string, Record<string, unknown>>
Metadata attached by tools via attachMetadata()
attachMetadata(id, meta)
void
Attach arbitrary metadata for a specific tool call ID
ctx.usage is a live snapshot. Read it inside a tool to check how many tokens have been consumed before making an expensive API call. Read it inside a result validator to enforce cost limits per run.
Tools receive RunContext<TDeps> as their first argument. Access anything you injected:
type Deps = { emailClient: EmailClient; featureFlags: FeatureFlags };const sendWelcome = tool<Deps>({ name: "send_welcome_email", description: "Send a welcome email to a new user.", parameters: z.object({ userId: z.string(), email: z.string().email() }), execute: async (ctx, { userId, email }) => { // Access deps — no imports, no globals if (!ctx.deps.featureFlags.welcomeEmailsEnabled) { return "Welcome emails are currently disabled."; } await ctx.deps.emailClient.send({ to: email, subject: "Welcome!", body: `Hi, your user ID is ${userId}.`, }); return `Welcome email sent to ${email}.`; },});
System prompts and instructions can be functions that receive RunContext. This gives you per-run personalization without any closures:
type Deps = { user: { name: string; plan: "free" | "pro" }; locale: string };const agent = new Agent<Deps>({ model: anthropic("claude-sonnet-4-6"), // Core persona — stable, changes with user data systemPrompt: (ctx) => { const { user } = ctx.deps; return `You are a helpful assistant for ${user.name}. They are on the ${user.plan} plan.`; }, // Ephemeral instructions — not recorded in message history instructions: (ctx) => { const { user, locale } = ctx.deps; return [ `Always respond in ${locale}.`, user.plan === "free" ? "Keep replies under 80 words." : "You may give detailed answers.", ].join(" "); },});
Use systemPrompt for persistent identity and instructions for per-run rules that should NOT accumulate in message history across multi-turn conversations.
Result validators also receive RunContext, so you can use runtime state to decide whether an output is acceptable:
const Schema = z.object({ totalCost: z.number(), items: z.array(z.string()) });type Deps = { maxBudget: number };const agent = new Agent<Deps, z.infer<typeof Schema>>({ model: anthropic("claude-sonnet-4-6"), outputSchema: Schema, maxRetries: 2, resultValidators: [ (ctx, output) => { if (output.totalCost > ctx.deps.maxBudget) { throw new Error( `Total cost ${output.totalCost} exceeds budget ${ctx.deps.maxBudget}. Please reduce the item list.` ); } return output; }, ],});// Budget is injected at run time — the same agent works for any budgetconst result = await agent.run("Plan a team lunch for 8 people.", { deps: { maxBudget: 150 },});
If the validator throws, Vibes sends the error message back to the model as feedback and retries (up to maxRetries times). The model can then produce a revised answer that satisfies the constraint.
The deps pattern makes testing straightforward: swap the real database for an in-memory mock. No patching, no module mocking — just pass different deps.
import { TestModel } from "jsr:@vibesjs/sdk/testing";// Build a minimal fake that satisfies the interfaceconst mockInventory: InventoryDeps["inventory"] = { async lookup(sku) { const fixtures: Record<string, { name: string; stock: number; price: number }> = { "SKU-7291": { name: "Wireless Headset Pro", stock: 42, price: 89.99 }, }; return fixtures[sku] ?? null; },};const mockDeps: InventoryDeps = { inventory: mockInventory, currencySymbol: "USD",};// TestModel lets you script exactly what the model will do — no API callsconst testModel = new TestModel([ { // First turn: model calls the lookup tool toolCalls: [{ toolName: "lookup_product", args: { sku: "SKU-7291" } }], }, { // Second turn: model returns final structured output toolCalls: [ { toolName: "final_result", args: { sku: "SKU-7291", productName: "Wireless Headset Pro", quantity: 5, unitPrice: 89.99, totalPrice: 449.95, available: true, }, }, ], },]);const result = await quoteAgent .override({ model: testModel }) .run("Quote me 5 units of SKU-7291.", { deps: mockDeps });console.log(result.output.productName); // "Wireless Headset Pro"console.log(result.output.totalPrice); // 449.95
agent.override({ model: testModel }) bypasses the setAllowModelRequests(false) guard automatically, so your test suite can call override runs even when live model access is disabled.
The recommended testing pattern: inject test doubles via deps, script model behavior with TestModel, and assert on result.output. Never mock Vibes internals — the framework stays intact; only the external dependencies change.