State and Memory

Store app state explicitly with KV, databases, cookies, and service clients

State belongs to your app. Use cookies, auth sessions, or request headers to identify the current browser or user, then store durable state in KV or a database with keys you control. Nothing is stored automatically.

npm install hono @agentuity/keyvalue zod @agentuity/telemetry

Choose the State Boundary

Pick the right store before writing any code. The wrong boundary is the most common source of memory bugs.

What you needWhere it lives
browser session identitysigned cookie or your auth session
compact user or conversation memoryKey-Value Storage
relational user or entity dataDatabase
generated files, transcripts, or exportsDurable Streams
async job statusKV status record plus Queues

The examples on this page use KV because that data is compact and fetched by exact key.

Store Session Memory

This route creates a signed browser session cookie when one does not exist, then reads and writes typed memory under that session ID.

typescriptsrc/index.ts
import { KeyValueClient } from '@agentuity/keyvalue';
import { Hono } from 'hono';
import type { Context } from 'hono';
import { getCookie, setCookie } from 'hono/cookie';
import { z } from 'zod';
 
const memorySchema = z.object({
  displayName: z.string(),
  lastIntent: z.string(),
});
 
type UserMemory = z.infer<typeof memorySchema>;
 
const kv = new KeyValueClient();
const app = new Hono();
 
// Returns an existing session ID from the cookie, or creates and sets a new one
function getOrCreateSessionId(c: Context): string {
  const existing = getCookie(c, 'app_session');
 
  if (existing) {
    return existing;
  }
 
  const sessionId = crypto.randomUUID();
 
  setCookie(c, 'app_session', sessionId, {
    httpOnly: true,       // not readable from JS
    sameSite: 'Lax',      // safe for most single-domain apps
    secure: new URL(c.req.url).protocol === 'https:',
    maxAge: 60 * 60 * 24 * 30, // 30 days
  });
 
  return sessionId;
}
 
app.post('/api/memory', async (c) => {
  const sessionId = getOrCreateSessionId(c); 
  const body: unknown = await c.req.json();
  const memory = memorySchema.parse(body);
 
  await kv.set<UserMemory>('app-memory', sessionId, memory, { 
    ttl: 60 * 60 * 24 * 30, // match cookie lifetime
  });
 
  return c.json({ ok: true });
});
 
app.get('/api/memory', async (c) => {
  const sessionId = getOrCreateSessionId(c);
  const result = await kv.get<UserMemory>('app-memory', sessionId); 
 
  return c.json({
    memory: result.exists ? result.data : null,
  });
});
 
export default app;

KeyValueClient auto-reads AGENTUITY_SDK_KEY from the environment. Run agentuity dev locally and the key is injected automatically.

Choose the Key

Use the most stable identifier available. Authenticated apps should key memory by user ID. Anonymous apps can fall back to a signed cookie, device token, or conversation ID.

// Prefer authenticated user ID; fall back to anonymous session ID
function memoryKey(userId: string | null, sessionId: string): string {
  return userId ? `user:${userId}` : `session:${sessionId}`;
}
 
const key = memoryKey(userId, sessionId); 
 
await kv.set('app-memory', key, memory, { 
  ttl: 60 * 60 * 24 * 30,
});

Do not key durable user memory by request ID. Request IDs are useful for logs and tracing but do not identify the next request from the same user.

Validate on Read

KV stores raw JSON. Treat every read as a boundary: the value may have been written by older code, a migration script, or a manual test.

import { logger } from '@agentuity/telemetry';
 
const result = await kv.get<unknown>('app-memory', sessionId); 
 
if (!result.exists) {
  return null;
}
 
// safeParse returns { success: true, data } | { success: false, error: ValidationError }
const parsed = memorySchema.safeParse(result.data); 
 
if (!parsed.success) {
  // Log and discard stale or malformed records rather than crashing
  logger.warn('Dropping unrecognised memory record', {
    sessionId,
    issues: parsed.error.issues,
  });
  return null;
}
 
return parsed.data;

Inside @agentuity/hono route handlers, replace logger with c.var.logger so logs are scoped to the request span.

kv.get<T>() with a concrete type is fine when your app owns every write path. safeParse is the safer choice when the data shape can drift between deploys.

Keep Memory Small

Store summaries, preferences, IDs, and pointers. Put large transcripts in a database or durable stream, then store only the reference in KV.

interface ConversationPointer {
  readonly summary: string;
  readonly streamId: string;  // points to the full transcript
  readonly updatedAt: string;
}
 
await kv.set<ConversationPointer>('conversation-memory', sessionId, {
  summary: 'User prefers concise answers about billing issues',
  streamId: 'stream_01j...', 
  updatedAt: new Date().toISOString(),
});

A KV value holding a summary and a reference ID stays small and fast to read. The full transcript stays in the stream where it belongs.

Retention

Match TTL to the way state is used. Keys with no TTL override inherit the namespace default (7 days for auto-created namespaces). Pass ttl: null or ttl: 0 to store a key that never expires.

State typeSuggested TTL
anonymous browser session7 to 30 days
signed-in user preferencenull (no expiry) or database-backed
API cache entryminutes or hours
async job statuslong enough for the UI to poll and for support to review failures
generated output pointerno longer than the stream or file it points to
// Never expires
await kv.set('user-prefs', userId, prefs, { ttl: null }); 
 
// Expires after 30 days
await kv.set('app-memory', sessionId, memory, { ttl: 60 * 60 * 24 * 30 }); 
 
// Inherits namespace default (7 days if the namespace was auto-created)
await kv.set('cache', cacheKey, value);

See Key-Value Storage for namespace-level TTL defaults and key inspection.

Common Gotchas

SymptomCheck
memory never appears on the next requestthe cookie domain, sameSite, and secure flags match the deployed host
one user sees another user's statethe key includes the authenticated user ID or anonymous session ID
old values fail safeParseread with unknown, run safeParse, then migrate or drop stale records
chat history grows without boundstore a summary plus recent turns, not the full transcript
keys disappear unexpectedlythe per-key or namespace TTL is shorter than expected; check result.expiresAt on a read

Next Steps