Build/Agents

Managing State

Request, thread, and session state for stateful agents

Agentuity provides three state scopes for managing data across requests:

  • request state for temporary calculations
  • thread state for conversation context
  • session state for user-level data

The Three State Scopes

ScopeLifetimeCleared WhenAccessExample Use Case
RequestSingle requestAfter response sentctx.stateTiming, temp calculations
ThreadUp to 1 hourExpiration or destroy()ctx.thread.stateConversation history
SessionSpans threadsIn-memory (server restart)ctx.session.stateUser preferences

Quick Example

import { createAgent, type AgentContext } 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: AgentContext, input) => {
    // REQUEST STATE: Cleared after this response
    ctx.state.set('startTime', Date.now());
 
    // THREAD STATE: Persists across requests (up to 1 hour)
    const messages = (ctx.thread.state.get('messages') as string[]) || [];
    messages.push(input.message);
    ctx.thread.state.set('messages', messages);
 
    // SESSION STATE: Persists across threads
    const totalRequests = (ctx.session.state.get('totalRequests') as number) || 0;
    ctx.session.state.set('totalRequests', totalRequests + 1);
 
    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.

Conversation Memory

import { createAgent, type AgentContext } 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: AgentContext, input) => {
    // Initialize on first request
    if (!ctx.thread.state.has('messages')) {
      ctx.thread.state.set('messages', []);
      ctx.thread.state.set('turnCount', 0);
    }
 
    const messages = ctx.thread.state.get('messages') as Message[];
    const turnCount = ctx.thread.state.get('turnCount') as number;
 
    // 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 });
    ctx.thread.state.set('messages', messages);
    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_...)
ctx.thread.state.set('key', value);
ctx.thread.state.get('key');
ctx.thread.state.has('key');
ctx.thread.state.delete('key');
 
// 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...
}

Session State

Session state (ctx.session.state) spans multiple threads, useful for user-level data that should persist across conversations.

handler: async (ctx, input) => {
  // Track user activity across threads
  const visits = (ctx.session.state.get('visits') as number) || 0;
  ctx.session.state.set('visits', visits + 1);
  ctx.session.state.set('lastActive', new Date().toISOString());
 
  ctx.logger.info('Session activity', {
    sessionId: ctx.sessionId,
    visits: visits + 1,
  });
 
  return { visits: visits + 1 };
}

Note: Session state is in-memory and cleared on server restart. For durable data, use KV storage.

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, type AgentContext } 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: AgentContext, input) => {
    const key = `chat_${ctx.thread.id}`;
    let messages: Message[] = [];
 
    // Load from KV on first access in this thread
    if (!ctx.thread.state.has('loaded')) {
      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 });
      }
      ctx.thread.state.set('messages', messages);
      ctx.thread.state.set('loaded', true);
    } else {
      messages = ctx.thread.state.get('messages') as Message[];
    }
 
    // 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);
      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 run cleanup logic:

handler: async (ctx, input) => {
  // Register cleanup handler once per thread
  if (!ctx.thread.state.has('cleanupRegistered')) {
    ctx.thread.addEventListener('destroyed', async (eventName, thread) => {
      const messages = thread.state.get('messages') as string[] || [];
 
      if (messages.length > 0) {
        // Save conversation before thread expires
        await ctx.kv.set('archives', thread.id, {
          messages,
          endedAt: new Date().toISOString(),
        }, { ttl: 604800 }); // 7 days
 
        ctx.logger.info('Archived conversation', {
          threadId: thread.id,
          messageCount: messages.length,
        });
      }
    });
 
    ctx.thread.state.set('cleanupRegistered', true);
  }
 
  // Process request...
}

Session Completion Events

Track when sessions (individual requests) complete:

ctx.session.addEventListener('completed', async (eventName, session) => {
  ctx.logger.info('Session completed', {
    sessionId: session.id,
    threadId: session.thread.id,
  });
});

For app-level event monitoring, 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 };
}

Best Practices

  • Use the right scope: Request for temp data, thread for conversations, session for user data
  • 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 state, save on completion
// Good: Bounded conversation history
const messages = ctx.thread.state.get('messages') as Message[];
if (messages.length > 50) {
  const archived = messages.slice(0, -50);
  ctx.waitUntil(async () => {
    await ctx.kv.set('archives', `${ctx.thread.id}_${Date.now()}`, archived);
  });
  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!