AgentContext is what your agent handler and agent-scoped event listeners receive. It combines request-scoped state, conversation state, platform services, observability, and metadata about the current agent run.
Context Properties
The context object passed to your agent handler contains the following properties:
interface AgentContext<TConfig = unknown, TAppState = Record<string, never>> {
// Lifecycle
waitUntil(promise: Promise<void> | (() => void | Promise<void>)): void;
// Observability
logger: Logger;
tracer: Tracer;
// Identifiers
sessionId: string;
current: {
name: string;
agentId: string;
id: string;
filename: string;
version: string;
description?: string;
inputSchemaCode?: string;
outputSchemaCode?: string;
};
// State
state: Map<string, unknown>; // Request-scoped scratch state
session: Session; // Request-scoped session object
thread: Thread; // Conversation-scoped state
// Agent and app config
config: TConfig; // Returned from this agent's setup()
app: TAppState; // Reserved app-level state. Defaults to {}
// Services
kv: KeyValueStorage;
stream: StreamStorage;
vector: VectorStorage;
sandbox: SandboxService;
queue: QueueService;
email: EmailService;
schedule: ScheduleService;
task: TaskStorage;
// Auth
auth: AuthInterface | null;
}Platform Services
Each service on the context object is documented on its own reference page:
| Service | Property | Reference |
|---|---|---|
| Key-Value Storage | ctx.kv | Storage APIs |
| Vector Search | ctx.vector | Storage APIs |
| Durable Streams | ctx.stream | Storage APIs |
| Message Queues | ctx.queue | Queue Service |
| Tasks | ctx.task | Task Service |
ctx.email | Email Service | |
| Schedules | ctx.schedule | Schedule Service |
| Sandbox | ctx.sandbox | Sandbox Service |
Database and object storage use Bun's native sql and s3 APIs rather than ctx.* properties.
Service Access
All services are available in agents (ctx.*), routes (c.var.*), and standalone scripts. See Accessing Services for the complete access pattern reference.
Key Properties Explained
Identifiers:
sessionId: Unique identifier for the current request. Sub-agent calls within that same request share it. This is the same value asctx.session.id.current: Metadata about the currently executing agent:name: The name passed tocreateAgent().agentId: Stays the same across deployments. Use for state keys (e.g.,${ctx.current.agentId}_counter).id: Changes with each deployment. Use when you need deployment-specific identifiers.filename: Relative path to the agent file.version: Changes when agent code changes. Use for cache keys or versioned storage.description?: Human-readable description fromcreateAgent()config.inputSchemaCode?: Source code for the input schema (if defined).outputSchemaCode?: Source code for the output schema (if defined).
Configuration:
config: Agent-specific configuration returned from the agent'ssetup()function. Fully typed based on what setup returns.app: Reserved app-level shared state. Defaults to{}, so prefer module-scoped dependencies, service objects, orregisterShutdownHook()in new code.
Agent Calling:
- Import agents directly:
import otherAgent from '@agent/other-agent/agent' - Call with:
await otherAgent.run(input)
For orchestration patterns, see Calling Other Agents.
State Management:
session: Request-scoped session object for the current execution and its sub-agent calls.thread: Conversation-scoped object for data that persists across related requests.state: In-memory scratchMapfor the current handler execution.
Example Usage:
import { createAgent } from '@agentuity/runtime';
import { s } from '@agentuity/schema';
const agent = createAgent('QueryProcessor', {
schema: {
input: s.object({ query: s.string() }),
output: s.object({
result: s.string(),
queryCount: s.number(),
}),
},
setup: async () => ({ prefix: 'Processed' }),
handler: async (ctx, input) => {
ctx.logger.info(`Session ID: ${ctx.sessionId}`);
await ctx.kv.set('cache', 'last-query', input.query);
ctx.state.set('startTime', Date.now());
const queryCount = (await ctx.thread.state.get<number>('queryCount')) ?? 0;
await ctx.thread.state.set('queryCount', queryCount + 1);
return {
result: `${ctx.config.prefix}: ${input.query}`,
queryCount: queryCount + 1,
};
},
});Background Tasks (waitUntil)
waitUntil(callback: Promise<void> | (() => void | Promise<void>)): void
Execute background tasks that don't block the response to the caller. Tasks complete after the main response is sent.
Parameters
callback: A Promise, or a function that returns either void (synchronous) or a Promise (asynchronous), to be executed in the background
Example
import { createAgent } from '@agentuity/runtime';
import { z } from 'zod';
const agent = createAgent('MessageReceiver', {
schema: {
input: z.object({ userId: z.string(), message: z.string() }),
output: z.object({ status: z.string(), timestamp: z.number() }),
},
handler: async (ctx, input) => {
const responseData = {
status: 'received',
timestamp: Date.now(),
};
// Schedule background tasks (async functions)
ctx.waitUntil(async () => {
// Log the message asynchronously
await logMessageToDatabase(input.userId, input.message);
});
ctx.waitUntil(async () => {
// Send push notification in the background
await sendPushNotification(input.userId, input.message);
});
// Can also use synchronous functions
ctx.waitUntil(() => {
// Update analytics synchronously
updateAnalyticsSync(input.userId, 'message_received');
});
// Return immediately without waiting for background tasks
return responseData;
},
});Use Cases
- Logging and analytics that don't affect the user experience
- Sending push notifications
- Database cleanup or maintenance tasks
- Third-party API calls that don't impact the response
- Background data processing or enrichment
Authentication (ctx.auth)
When @agentuity/auth middleware is configured, ctx.auth provides access to the authenticated user, organization, and API key context. It is null for unauthenticated requests, cron jobs, and agent-to-agent calls without auth propagation.
import { createAgent } from '@agentuity/runtime';
export default createAgent('protected-agent', {
handler: async (ctx) => {
if (!ctx.auth) {
return { error: 'Please sign in' };
}
const user = await ctx.auth.getUser();
ctx.logger.info('Request from %s', user.email);
// Check organization role
if (await ctx.auth.hasOrgRole('admin')) {
// Admin-only logic
}
// Check API key permissions (for API key auth)
if (ctx.auth.authMethod === 'api-key') {
if (!ctx.auth.hasPermission('data', 'read')) {
return { error: 'Insufficient permissions' };
}
}
return { userId: user.id };
},
});Available properties and methods:
getUser()returns the authenticated user (id, email, name, image, timestamps).getToken()returns the raw JWT token, or null.orgis the active organization context (id, slug, name, role, memberId), or null if no org is active.getOrg()returns the active organization context, or null.getOrgRole()returns the user's role in the active organization, or null.hasOrgRole(...roles)returns true if the user's org role matches one of the provided roles.authMethodindicates how the request was authenticated:'session','api-key', or'bearer'.apiKeyis the API key context when authenticated via API key, or null.hasPermission(resource, ...actions)checks whether the API key has all specified actions for a resource. Supports'*'wildcard.
For setting up authentication middleware and React client providers, see Adding Authentication.
Thread and Session State
The context provides three levels of state storage, each scoped differently.
ctx.thread persists across multiple requests in a conversation. Thread state uses async lazy-loading, so data is only fetched from storage on first read.
// Store conversation history across requests
const count = await ctx.thread.state.get<number>('messageCount') ?? 0;
await ctx.thread.state.set('messageCount', count + 1);
// Append to arrays efficiently without loading the full array
await ctx.thread.state.push('messages', { role: 'user', content: input.text });
// Keep a sliding window of the last 100 messages
await ctx.thread.state.push('messages', newMessage, 100);
// Merge first because setMetadata replaces the full metadata object
const metadata = await ctx.thread.getMetadata();
await ctx.thread.setMetadata({ ...metadata, userId: user.id, topic: 'support' });ctx.session is scoped to a single request-response cycle. Each HTTP request creates a new session within the same thread. Session state is a synchronous Map<string, unknown> and does not persist into later requests.
// Track timing for this request only
ctx.session.state.set('startTime', Date.now());
// Access the parent thread from the session
ctx.logger.info('Thread: %s, Session: %s', ctx.session.thread.id, ctx.session.id);
// Session metadata (stored unencrypted for querying)
ctx.session.metadata.requestType = 'chat';ctx.state is request-scoped, in-memory only, and cleared between requests. It is a synchronous Map<string, unknown> for passing data within a single handler execution.
ctx.state.set('processingStep', 'validation');
const step = ctx.state.get('processingStep');
if (typeof step === 'string') {
ctx.logger.info('Processing step', { step });
}For a complete chat example, see Chat with Conversation History.
When to use each:
| Scope | Persisted | Async | Use case |
|---|---|---|---|
ctx.thread.state | Yes, across requests | Yes (lazy-loaded) | Conversation history, user preferences |
ctx.session.state | No, request only | No (synchronous Map) | Request timing, data needed in session.completed listeners |
ctx.state | No | No (synchronous Map) | Passing data within the handler |
Session Interface
The Session object is available via ctx.session. It represents the current request's session within a thread. Session state is request-scoped only, even when the thread continues across later requests.
interface Session {
id: string; // Unique session identifier (same as ctx.sessionId)
thread: Thread; // Reference to the current thread
state: Map<string, unknown>; // Request-scoped state (synchronous)
metadata: Record<string, unknown>; // Unencrypted metadata for querying
// Lifecycle event: fires when the session completes
addEventListener(
eventName: 'completed',
callback: (eventName: 'completed', session: Session) => Promise<void> | void
): void;
removeEventListener(
eventName: 'completed',
callback: (eventName: 'completed', session: Session) => Promise<void> | void
): void;
}Thread Interface
The Thread object is available via ctx.thread. It persists across multiple sessions (requests) in a conversation.
interface Thread {
id: string; // Unique thread identifier
state: ThreadState; // Async lazy-loaded state
getMetadata(): Promise<Record<string, unknown>>;
setMetadata(metadata: Record<string, unknown>): Promise<void>; // Full replace
// Lifecycle event: fires when the thread is destroyed
addEventListener(
eventName: 'destroyed',
callback: (eventName: 'destroyed', thread: Thread) => Promise<void> | void
): void;
removeEventListener(
eventName: 'destroyed',
callback: (eventName: 'destroyed', thread: Thread) => Promise<void> | void
): void;
// Remove the thread and all its state
destroy(): Promise<void>;
empty(): Promise<boolean>;
}Call destroy() to clean up a thread when a conversation ends. This removes the thread's persisted state and fires the destroyed event.
Config Type Inference
When you define a setup() function, its return type automatically flows through to ctx.config:
import { createAgent } from '@agentuity/runtime';
export default createAgent('my-agent', {
setup: async () => ({
cache: new Map<string, string>(),
maxRetries: 3,
}),
handler: async (ctx) => {
// ctx.config is typed as { cache: Map<string, string>, maxRetries: number }
ctx.config.cache.set('key', 'value');
ctx.logger.info('Max retries: %d', ctx.config.maxRetries);
},
});ctx.config is the stable, documented place for agent-local setup results. The runtime types still include ctx.app, but it defaults to {}. Prefer module-scoped shared dependencies plus registerShutdownHook() for cleanup instead of new examples built around app-level setup state.