Managing State — Agentuity Documentation

Managing State

Request and thread state for stateful agents

Agentuity gives you three closely related state surfaces:

  • ctx.state for temporary calculations within a single handler execution
  • ctx.session.state for request-scoped session data
  • ctx.thread.state for conversation context that persists across requests

State Scopes

ScopeLifetimeCleared WhenAccessExample Use Case
RequestSingle handler executionAfter response sentctx.stateTiming, temp calculations
SessionSingle requestAfter response sentctx.session.stateData needed in session.completed listeners
ThreadMultiple requestsInactivity expiration or destroy()ctx.thread.stateConversation history

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)
    const startTime = Date.now();
    ctx.state.set('startTime', startTime);
 
    // 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() - startTime;
 
    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 other background hooks
  • 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.

handler: async (ctx, input) => {
  ctx.state.set('startTime', Date.now());
  ctx.session.state.set('requestType', 'chat');
 
  ctx.session.addEventListener('completed', (eventName, session) => {
    const requestType = session.state.get('requestType') as string | undefined;
    ctx.logger.info('Session completed', {
      requestType,
      sessionId: session.id,
    });
  });
 
  return { ok: true, message: input.message };
}

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

Thread Lifecycle

Use ctx.thread.destroy() when you want to end a conversation immediately. That fires the thread's destroyed event and clears its persisted state.

ctx.thread.addEventListener('destroyed', async (eventName, thread) => {
  ctx.logger.info('Thread destroyed', { threadId: thread.id });
});

Thread data also expires after inactivity based on the active thread provider. Treat the destroyed listener as the explicit teardown hook you control with ctx.thread.destroy(), not as the only place to archive important data.

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('')}`;
  }
};
 
const app = await createApp({});
 
getThreadProvider().setThreadIDProvider(userThreadProvider);
 
export default app;

createApp() initializes the runtime services, so call getThreadProvider() after createApp() returns and before exporting the app.

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',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message }),
});
 
const threadId = response.headers.get('x-thread-id');
 
if (threadId) {
  localStorage.setItem('threadId', threadId);
}
 
// Subsequent requests: send thread ID header
const storedThreadId = localStorage.getItem('threadId');
const headers = new Headers({ 'Content-Type': 'application/json' });
 
if (storedThreadId) {
  headers.set('x-thread-id', storedThreadId);
}
 
const nextResponse = await fetch('/api/chat', {
  method: 'POST',
  headers,
  body: JSON.stringify({ message: nextMessage }),
});

State Size Limits

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 skipped persistence
  • 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