Managing State — Agentuity Documentation

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

State Scopes

ScopeLifetimeCleared WhenAccessExample Use Case
RequestSingle requestAfter response sentctx.stateTiming, temp calculations
ThreadUp to 1 hourExpiration 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)
    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.

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

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
});

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