Agents

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

ScopeLifetimeCleared WhenAccessExample Use Case
RequestSingle requestAfter response sentctx.stateTiming, temp calculations
ThreadUp to 1 hourExpiration or destroy()ctx.thread.stateConversation 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 evals
  • ctx.session.state: Accessible via session in 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

IDFormatLifetimePurpose
Thread IDthrd_<hex>Up to 1 hour (shared across requests)Group related requests into conversations
Session IDsess_<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:

RequirementValue
PrefixMust start with thrd_
Length32-64 characters total
CharactersAfter 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 prefix

Custom 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:

  1. Check header: Looks for signed x-thread-id header
  2. Check cookie: Falls back to signed atid cookie
  3. Generate new: Creates a new random thread ID if neither exists
  4. 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.state for request-scoped data, ctx.thread.state for 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 destroyed event 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: The push() method with maxRecords handles 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

Need Help?

Join our DiscordCommunity 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!