# 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

> [!NOTE]
> **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 handler execution | After response sent | `ctx.state` | Timing, temp calculations |
| Session | Single request | After response sent | `ctx.session.state` | Data needed in `session.completed` listeners |
| Thread | Multiple requests | Inactivity expiration or `destroy()` | `ctx.thread.state` | Conversation history |

> [!TIP]
> **Threads and Sessions**
> Think of a *thread* as the conversation and a *session* as one request within that conversation. Each request creates a new session, but sessions within the same conversation share a thread.

> [!NOTE]
> **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](/routes/http#request-context) for route examples.

### Quick Example

```typescript
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.

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

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

```typescript
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:

```typescript
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](/agents/events-lifecycle) for more on event handlers.

```typescript
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 };
}
```

> [!NOTE]
> **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](/services/storage/key-value) (durable).

## Persisting to Storage

In-memory state is lost on server restart. For durability, combine state management with KV storage:

### Load → Cache → Save Pattern

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

> [!TIP]
> **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

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

```typescript
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](/agents/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 |

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

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

```typescript
// 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:

```typescript
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('')}`;
  }
};

getThreadProvider().setThreadIDProvider(userThreadProvider);

await createApp({});
```

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

```typescript
// 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
});
```

> [!WARNING]
> **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

> [!WARNING]
> **1MB State Limit**
> Thread and session state are limited to **1MB** after JSON serialization. If serialized state exceeds that limit, the runtime does not persist it. If serialization fails entirely, the runtime logs an error and skips persistence for that value.

To stay within limits:
- Store large data in [KV storage](/services/storage/key-value) 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

```typescript
// 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](/services/storage/key-value): Durable data persistence with namespaces and TTL
- [Calling Other Agents](/agents/calling-other-agents): Share state between agents in workflows
- [Events & Lifecycle](/agents/events-lifecycle): Monitor agent execution and cleanup