Managing State
Request and thread state for stateful agents
Agentuity provides two state scopes for managing data across requests:
- Request state for temporary calculations within a single request
- Thread state for conversation context that persists across requests
Async Thread State
Thread state uses async lazy-loading for performance. State is only fetched when first accessed, eliminating latency for requests that don't use thread state. All ctx.thread.state methods are async and require await.
State Scopes
| Scope | Lifetime | Cleared When | Access | Example Use Case |
|---|---|---|---|---|
| Request | Single request | After response sent | ctx.state | Timing, temp calculations |
| Thread | Up to 1 hour | Expiration or destroy() | ctx.thread.state | Conversation history |
Threads and Sessions
Threads "wrap" sessions. Think of a thread as a conversation and a session as one message in that conversation. Each request creates a new session, but sessions within the same conversation share a thread.
State in Routes
Routes have the same state access via c.var.thread and c.var.session. Use c.var.thread.state for conversation context and c.var.session.state for request-scoped data. See HTTP Routes for route examples.
Quick Example
import { createAgent } from '@agentuity/runtime';
import { s } from '@agentuity/schema';
const agent = createAgent('StateDemo', {
schema: {
input: s.object({ message: s.string() }),
output: s.object({
response: s.string(),
requestTime: s.number(),
messageCount: s.number(),
}),
},
handler: async (ctx, input) => {
// REQUEST STATE: Cleared after this response (sync)
ctx.state.set('startTime', Date.now());
// THREAD STATE: Persists across requests (async, up to 1 hour)
const messages = (await ctx.thread.state.get<string[]>('messages')) || [];
messages.push(input.message);
await ctx.thread.state.set('messages', messages);
const requestTime = Date.now() - (ctx.state.get('startTime') as number);
return {
response: `Received: ${input.message}`,
requestTime,
messageCount: messages.length,
};
},
});
export default agent;Request State
Request state (ctx.state) holds temporary data within a single request. It's cleared automatically after the response is sent.
handler: async (ctx, input) => {
// Track timing
ctx.state.set('startTime', Date.now());
// Process request...
const result = await processData(input);
// Use the timing data
const duration = Date.now() - (ctx.state.get('startTime') as number);
ctx.logger.info('Request completed', { durationMs: duration });
return result;
}Use cases: Request timing, temporary calculations, passing data between event listeners.
Thread State
Thread state (ctx.thread.state) persists across multiple requests within a conversation, expiring after 1 hour of inactivity. Thread identity is managed automatically via cookies (or the x-thread-id header for API clients).
Conversation Memory
import { createAgent } from '@agentuity/runtime';
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import * as v from 'valibot';
interface Message {
role: 'user' | 'assistant';
content: string;
}
const agent = createAgent('ConversationMemory', {
schema: {
input: v.object({ message: v.string() }),
output: v.string(),
},
handler: async (ctx, input) => {
// Initialize on first request
if (!(await ctx.thread.state.has('messages'))) {
await ctx.thread.state.set('messages', []);
await ctx.thread.state.set('turnCount', 0);
}
const messages = await ctx.thread.state.get<Message[]>('messages') || [];
const turnCount = await ctx.thread.state.get<number>('turnCount') || 0;
// Add user message
messages.push({ role: 'user', content: input.message });
// Generate response with conversation context
const { text } = await generateText({
model: openai('gpt-5-mini'),
system: 'You are a helpful assistant. Reference previous messages when relevant.',
messages,
});
// Update thread state
messages.push({ role: 'assistant', content: text });
await ctx.thread.state.set('messages', messages);
await ctx.thread.state.set('turnCount', turnCount + 1);
ctx.logger.info('Conversation turn', {
threadId: ctx.thread.id,
turnCount: turnCount + 1,
});
return text;
},
});
export default agent;Thread Properties and Methods
ctx.thread.id; // Thread ID (thrd_...)
// All state methods are async
await ctx.thread.state.set('key', value);
await ctx.thread.state.get<T>('key');
await ctx.thread.state.has('key');
await ctx.thread.state.delete('key');
await ctx.thread.state.clear();
// Array operations with optional sliding window
await ctx.thread.state.push('messages', newMessage);
await ctx.thread.state.push('messages', newMessage, 100); // Keep last 100
// Bulk access (returns arrays, not iterators)
const keys = await ctx.thread.state.keys(); // string[]
const values = await ctx.thread.state.values<Message>(); // Message[]
const entries = await ctx.thread.state.entries<Message>(); // [string, Message][]
const count = await ctx.thread.state.size(); // number
// State status
ctx.thread.state.loaded; // Has state been fetched?
ctx.thread.state.dirty; // Are there pending changes?
// Reset the conversation
await ctx.thread.destroy();Resetting a Conversation
Call ctx.thread.destroy() to clear all thread state and start fresh:
handler: async (ctx, input) => {
if (input.command === 'reset') {
await ctx.thread.destroy();
return 'Conversation reset. Thread state cleared.';
}
// Continue conversation...
}ctx.state vs ctx.session.state
Both ctx.state and ctx.session.state are request-scoped and reset after each request. The difference:
ctx.state: General request state, accessible in agent event listeners and evalsctx.session.state: Accessible viasessionin completion event callbacks
For most use cases, use ctx.state. Use ctx.session.state only when you need data in session completion events. See Events & Lifecycle for more on event handlers.
Persistent Data
Neither ctx.state nor ctx.session.state persist across requests. For data that should survive across requests, use ctx.thread.state (up to 1 hour) or KV storage (durable).
Persisting to Storage
In-memory state is lost on server restart. For durability, combine state management with KV storage:
Load → Cache → Save Pattern
import { createAgent } from '@agentuity/runtime';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import * as v from 'valibot';
type Message = { role: 'user' | 'assistant'; content: string };
const agent = createAgent('PersistentChat', {
schema: {
input: v.object({ message: v.string() }),
stream: true,
},
handler: async (ctx, input) => {
const key = `chat_${ctx.thread.id}`;
let messages: Message[] = [];
// Load from KV on first access in this thread
if (!(await ctx.thread.state.has('kvLoaded'))) {
const result = await ctx.kv.get<Message[]>('conversations', key);
if (result.exists) {
messages = result.data;
ctx.logger.info('Loaded conversation from KV', { messageCount: messages.length });
}
await ctx.thread.state.set('messages', messages);
await ctx.thread.state.set('kvLoaded', true);
} else {
messages = await ctx.thread.state.get<Message[]>('messages') || [];
}
// Add user message
messages.push({ role: 'user', content: input.message });
// Stream response
const result = streamText({
model: openai('gpt-5-mini'),
messages,
});
// Save in background (non-blocking)
ctx.waitUntil(async () => {
const fullText = await result.text;
messages.push({ role: 'assistant', content: fullText });
// Keep last 20 messages to bound state size
const recentMessages = messages.slice(-20);
await ctx.thread.state.set('messages', recentMessages);
// Persist to KV
await ctx.kv.set('conversations', key, recentMessages, {
ttl: 86400, // 24 hours
});
});
return result.textStream;
},
});
export default agent;Key points:
- Load from KV once per thread, cache in thread state
- Use
ctx.waitUntil()for non-blocking saves - Bound state size to prevent unbounded growth
Write-Only Optimization
Thread state supports write-only operations without loading existing state. If you only call set(), delete(), or push() without any reads, the SDK batches these as a merge operation, avoiding the latency of fetching existing state.
Thread Lifecycle
Threads expire after 1 hour of inactivity. Use the destroyed event to archive data before expiration:
ctx.thread.addEventListener('destroyed', async (eventName, thread) => {
const messages = await thread.state.get<string[]>('messages') || [];
if (messages.length > 0) {
await ctx.kv.set('archives', thread.id, {
messages,
endedAt: new Date().toISOString(),
}, { ttl: 604800 });
}
});Similarly, track when sessions complete with ctx.session.addEventListener('completed', ...).
For app-level monitoring of all threads and sessions, see Events & Lifecycle.
Thread vs Session IDs
| ID | Format | Lifetime | Purpose |
|---|---|---|---|
| Thread ID | thrd_<hex> | Up to 1 hour (shared across requests) | Group related requests into conversations |
| Session ID | sess_<hex> | Single request (unique per call) | Request tracing and analytics |
handler: async (ctx, input) => {
ctx.logger.info('Request received', {
threadId: ctx.thread.id, // Same across conversation
sessionId: ctx.sessionId, // Unique per request
});
return { threadId: ctx.thread.id, sessionId: ctx.sessionId };
}Advanced: Custom Thread IDs
By default, thread IDs are generated automatically and managed via signed cookies. For advanced use cases, you can provide custom thread ID logic using a ThreadIDProvider.
Use cases:
- Integrating with external identity systems
- Multi-tenant applications where threads should be scoped to users
- Custom conversation management tied to your domain model
ThreadIDProvider Interface
import type { Context } from 'hono';
import type { Env, AppState } from '@agentuity/runtime';
interface ThreadIDProvider {
getThreadId(appState: AppState, ctx: Context<Env>): string | Promise<string>;
}Thread ID Format Requirements
Custom thread IDs must follow these rules:
| Requirement | Value |
|---|---|
| Prefix | Must start with thrd_ |
| Length | 32-64 characters total |
| Characters | After prefix: [a-zA-Z0-9] only |
// Valid thread IDs
'thrd_abc123def456789012345678901' // 32 chars - minimum
'thrd_' + 'a'.repeat(59) // 64 chars - maximum
// Invalid thread IDs
'thrd_abc' // Too short
'thrd_abc-def-123' // Dashes not allowed
'thread_abc123' // Wrong prefixCustom Provider Example
Create a provider that generates deterministic thread IDs based on authenticated users:
import { createApp, getThreadProvider } from '@agentuity/runtime';
import type { ThreadIDProvider } from '@agentuity/runtime';
const userThreadProvider: ThreadIDProvider = {
getThreadId: async (appState, ctx) => {
const userId = ctx.req.header('x-user-id');
if (userId) {
// Create deterministic thread ID from user ID
const data = new TextEncoder().encode(userId);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hex = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 27);
return `thrd_${hex}`;
}
// Fall back to random ID for unauthenticated requests
const arr = new Uint8Array(16);
crypto.getRandomValues(arr);
return `thrd_${Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('')}`;
}
};
await createApp({
setup: () => {
getThreadProvider().setThreadIDProvider(userThreadProvider);
return {};
}
});Default Thread ID Behavior
The built-in DefaultThreadIDProvider handles thread IDs automatically:
- Check header: Looks for signed
x-thread-idheader - Check cookie: Falls back to signed
atidcookie - Generate new: Creates a new random thread ID if neither exists
- Persist: Sets both the response header and cookie for future requests
The signing uses AGENTUITY_SDK_KEY (or defaults to 'agentuity' in development) with HMAC SHA-256 to prevent tampering.
Client-Side Thread Persistence
When building frontends, you can maintain thread continuity by storing and sending the thread ID:
// Client-side: store thread ID from response header
const response = await fetch('/api/chat', { method: 'POST', body: message });
const threadId = response.headers.get('x-thread-id');
localStorage.setItem('threadId', threadId);
// Subsequent requests: send thread ID header
const nextResponse = await fetch('/api/chat', {
method: 'POST',
headers: { 'x-thread-id': localStorage.getItem('threadId') },
body: nextMessage
});Signed Headers Required
The x-thread-id header must include the signature (format: threadId;signature). Use the exact value from the response header. Unsigned or tampered thread IDs are rejected.
State Size Limits
1MB State Limit
Thread and session state are limited to 1MB after JSON serialization. Values that exceed this limit are truncated with a warning. Non-JSON-serializable values (functions, circular references) are silently dropped.
To stay within limits:
- Store large data in KV storage instead of state
- Keep only recent messages (e.g., last 20-50)
- Store IDs or references rather than full objects
Best Practices
- Use the right scope:
ctx.statefor request-scoped data,ctx.thread.statefor conversations - Keep state bounded: Limit conversation history (e.g., last 20-50 messages)
- Persist important data: Don't rely on state for data that must survive restarts
- Clean up resources: Use
destroyedevent to save or archive data - Cache strategically: Load from KV once, cache in thread state, save on completion
- Watch state size: Stay under 1MB to avoid truncation
- Use
push()for arrays: Thepush()method withmaxRecordshandles sliding windows efficiently
// Good: Use push() with maxRecords for bounded history
await ctx.thread.state.push('messages', newMessage, 50); // Keeps last 50
// Alternative: Manual bounding when you need the full array
const messages = await ctx.thread.state.get<Message[]>('messages') || [];
if (messages.length > 50) {
const archived = messages.slice(0, -50);
ctx.waitUntil(async () => {
await ctx.kv.set('archives', `${ctx.thread.id}_${Date.now()}`, archived);
});
await ctx.thread.state.set('messages', messages.slice(-50));
}Next Steps
- Key-Value Storage: Durable data persistence with namespaces and TTL
- Calling Other Agents: Share state between agents in workflows
- Events & Lifecycle: Monitor agent execution and cleanup
Need Help?
Join our Community for assistance or just to hang with other humans building agents.
Send us an email at hi@agentuity.com if you'd like to get in touch.
Please Follow us on
If you haven't already, please Signup for your free account now and start building your first agent!