Skip to content

Memory

Kuralle agents have two complementary memory axes:

  • Working memory — small, durable, human-readable blocks (e.g. a USER profile) that the agent maintains with a tool and that are injected into the system prompt every turn. Persists across sessions. This is the right tool for "remember this caller's name / preferences / account".
  • Semantic recall — cross-session facts extracted via memory.extract (e.g. factsExtractor()), read two ways: memory.preload injects the facts slug automatically every turn, and a search_memory tool lets the model query any declared extractor on demand. Best for "remember durable facts about this user across sessions".

This guide focuses on working memory.

import { defineAgent, createRuntime } from '@kuralle-agents/core';
import { FilePersistentMemoryStore } from '@kuralle-agents/core';
const agent = defineAgent({
id: 'support',
model,
instructions: 'You are a helpful support agent.',
memory: {
workingMemory: {
store: new FilePersistentMemoryStore(), // where blocks persist
autoLoad: [{ scope: 'user', key: 'USER' }],
},
},
});

That's it. When the user shares something durable ("my plan is Pro", "call me Sam"), the agent calls the built-in memory_block tool to record it; on the next session for the same user, that block is loaded back into the prompt and the agent just knows it. You do not need to instruct the agent to use memory — Kuralle injects the directive automatically.

memory: {
workingMemory: {
store, // PersistentMemoryStore — required (see resolution below)
autoLoad: [{ scope: 'user', key: 'USER', template: '...' }],
defaultCharLimit: 10_000, // max chars per block (default 10_000)
scanForInjection: true, // reject prompt-injection in writes (default true)
},
}
OptionTypeDefaultMeaning
storePersistentMemoryStoreresolved (see below)Where blocks are persisted.
autoLoadWorkingMemoryBlockSpec[][{ scope: 'user', key: 'USER' }, { scope: 'agent', key: 'MEMORY' }]Which blocks to load at session start and inject into the prompt.
defaultCharLimitnumber10_000Per-block character cap; writes over the limit are rejected.
scanForInjectionbooleantrueScan block writes for prompt-injection patterns and reject matches.

Each entry declares one block to load and expose:

{
scope: 'user', // 'user' | 'agent' | 'shared'
key: 'USER', // the block name within its scope
template: '## About the user\n- name:\n- plan:', // optional seed (see below)
}
  • key — the block's name. autoLoad: [{ scope: 'user', key: 'USER' }] exposes a single per-user block named USER. You can declare several (e.g. a USER profile plus a agent/MEMORY block).
  • template — optional starter content shown when the block is empty. It is not persisted on read — it only seeds the prompt so the model knows the shape to fill in, and is saved the first time the agent writes. Great for a structured profile (- name:\n- timezone:\n- plan:).

The store keys every block by (scope, owner, key). The owner is resolved automatically:

scopeOwnerUse it for
userthe request's userIdFacts about the end-user (name, preferences, account). Shared across that user's sessions.
agentthe agent's idKnowledge global to the agent across all its users (house style, learned FAQs).
sharedthe request's userIdA second per-user namespace, separate from the USER profile.

When workingMemory is set, Kuralle does three things each turn:

  1. Loads the autoLoad blocks for the resolved owner and injects them as a ## Working memory section in the system prompt.
  2. Registers a memory_block tool (actions: view / add / replace / remove).
  3. Injects a directive telling the model to store durable facts proactively, answer from the blocks first, and not announce saves.

So the agent reads memory for free and writes it on its own — no extra prompt engineering required.

store resolves in this order: workingMemory.storeHarnessConfig.defaultWorkingMemoryStore → the Node file-store default (only if FilePersistentMemoryStore is imported) → an error.

StorePackagePersists toRuns on
InMemoryPersistentMemoryStore@kuralle-agents/coreprocess memory (ephemeral)Node + Workers
FilePersistentMemoryStore@kuralle-agents/core$KURALLE_MEMORY_DIR or ~/.kuralle/memories/<scope>/<owner>/<KEY>.mdNode
PostgresPersistentMemoryStore@kuralle-agents/postgres-storea working_memory_blocks tableNode (Workers via Hyperdrive)
RedisPersistentMemoryStore@kuralle-agents/redis-storewm:<scope>:<owner>:<key> keysNode + Workers (Upstash REST)
SqlPersistentMemoryStore@kuralle-agents/cf-agentthe Durable Object's embedded SQLiteCloudflare Workers

Route or layer backends with the composite stores in core:

import { RoutedPersistentMemoryStore, TieredPersistentMemoryStore } from '@kuralle-agents/core';
// Route by scope: per-user blocks in Postgres, per-agent in Redis.
const routed = new RoutedPersistentMemoryStore({
user: postgresStore,
agent: redisStore,
default: postgresStore,
});
// Or a read-through cache over a durable store.
const tiered = new TieredPersistentMemoryStore({ cache: inMemoryStore, durable: postgresStore });

The chat router reads userId from the request body and forwards it, so working memory is per-user automatically:

Terminal window
curl -X POST localhost:8787/api/chat \
-H 'content-type: application/json' \
-d '{ "message": "call me Sam", "sessionId": "s1", "userId": "user-42" }'

Pass a FilePersistentMemoryStore (or a Postgres/Redis store) to the agent, or set HarnessConfig.defaultWorkingMemoryStore once on createRuntime.

@kuralle-agents/cf-agent wires a SqlPersistentMemoryStore over the Durable Object's embedded SQLite as the default working-memory store — zero config. Just set memory.workingMemory on your agent; blocks persist in the DO. userId comes from the request body.

The DO-SQLite store is scoped to the Durable Object instance. If you address one DO per conversation, a user-scope block only spans that conversation. To get true cross-session per-user memory on CF, either address the DO by userId (so a user's sessions share one DO) or use an external store (PostgresPersistentMemoryStore / RedisPersistentMemoryStore via Upstash) so memory is shared across DOs.

For cross-session fact memory, enable memory.preload and memory.extract with the built-in factsExtractor():

import { defineAgent, factsExtractor } from '@kuralle-agents/core';
const agent = defineAgent({
id: 'support',
model,
memory: {
preload: { enabled: true, tokenBudget: 500 },
extract: [factsExtractor()],
extraction: { trigger: { tokens: 2000 } },
},
});

Working memory and extracted facts are independent — use both: working memory for the always-in-context profile, extracted facts for LLM-merged cross-session recall keyed by userId.

memory.preload only ever loads the facts slug. Declare more extractors and their values are written every turn but never reach the prompt unless the agent asks for them:

import { defineAgent, factsExtractor, defineExtractor } from '@kuralle-agents/core';
import { z } from 'zod';
const dietaryProfile = defineExtractor({
name: 'Dietary Profile',
scope: 'user',
instructions: 'Allergies and dietary restrictions this person stated about themselves.',
schema: z.object({ allergies: z.array(z.string()), avoids: z.array(z.string()) }),
});
const agent = defineAgent({
id: 'support',
model,
memory: {
preload: { enabled: true, tokenBudget: 500 },
extract: [factsExtractor(), dietaryProfile],
},
});

Declaring memory.extract automatically registers a search_memory tool the model can call mid-turn — Letta's core/archival split: preload is core memory (always in context), search_memory is archival (queried on demand). Its slug argument is a z.enum built from your extract list, the same pattern memory_block's block argument uses, so an undeclared extractor cannot be expressed, let alone read. Matching is literal, and a query that matches nothing returns { results: [] } — no fallback to showing everything, unlike preload.

No extra config is needed; the tool is withheld under the same conditions the rest of user-scoped memory withholds under (no userId on the run).