Storage and caching
Choose where state lives and when a tool-fed value needs to be refreshed.
A block defines a contract. A store holds its values for each thread. An optional TTL tells the runtime when a stored value is fresh enough to reuse instead of executing the tool that feeds it.
value: { "tone": "concise" }
version: 1
updatedAtMs: …value: { "tone": "detailed" }
version: 1
updatedAtMs: …Persistence and freshness answer different questions: will this value survive a restart, and should this tool fetch a new value now?
Values belong to threads
The runtime reads and writes a block using the current threadId and the
block's name. Two threads can share a profile definition while keeping
different preferences:
thread-1 / profile → { "tone": "concise" }
thread-2 / profile → { "tone": "detailed" }Each stored block record includes:
| Field | Meaning |
|---|---|
value | The stored data, validated by the block on renders and reducer updates. A TTL cache hit serves it without re-validation. |
version | A write revision incremented when a reducer update is saved. |
updatedAtMs | The timestamp used to calculate freshness. |
The write revision is separate from version on the block definition.
Changing a definition does not migrate existing records automatically.
Start with MemoryStore
createRuntime uses a new MemoryStore unless you supply another store.
To make the store explicit, add it to your existing runtime configuration:
import { createRuntime, MemoryStore } from "@keel-dev/core";
const store = new MemoryStore();
const runtime = createRuntime({
registry,
budgets,
store,
});Here, registry and budgets are the declarations from your application's
composition root. MemoryStore holds block values, conversation messages,
and turn records in the running process. Recreating the store or restarting
the process loses those values.
Keep data across restarts
For persistence, provide an implementation of the exported Store interface.
It covers more than block values:
| Methods | Responsibility |
|---|---|
getBlock, setBlock | Read and write block values with their revision and timestamp. |
appendMessage, listMessages | Store conversation history per thread. |
appendRecord, records | Store and retrieve turn records. |
claimThread, releaseThread | Serialize root turns: one at a time per thread. |
Every method is asynchronous — each returns a Promise — so a durable
adapter (Postgres, Redis, SQLite, …) implements the same interface the
in-memory store does. No dedicated database adapter ships yet; the contract
is the integration point.
Two contract details matter for adapter authors:
listMessages(threadId, window)returns the lastwindowmessages in chronological order, and awindow <= 0must return none — not everything. A naiveslice(-0)returns the whole list; that bug ships history the agent explicitly declined.claimThread(threadId)atomically claims the thread's single root-turn slot and returnsfalsewhen the thread is already claimed;releaseThreadfrees it. Make the claim atomic in a durable adapter — a unique-key insert, for example — so multiple runtime instances behind a load balancer stay serialized on the same thread.
Your storage layer also determines durability. The per-turn writer rule is not a database transaction; the thread claim serializes root turns but is not a general-purpose distributed lock.
Cache a tool-fed value
A TTL is useful for a tool that fetches a shared snapshot, such as exchange rates. Define a block with a freshness period:
// src/shared/state/rates.block.ts
import { z } from "zod";
import { defineBlock } from "@keel-dev/core";
const ratesSchema = z.object({
usdToEur: z.number().positive(),
});
export const ratesBlock = defineBlock({
name: "rates",
version: 1,
schema: ratesSchema,
initial: { usdToEur: 1 },
ttlMs: 60_000,
reducers: {
refresh: (_state, rates: z.infer<typeof ratesSchema>) => rates,
},
readers: {
assistant: (state) => `USD to EUR: ${state.usdToEur}`,
},
});The initial rate is a placeholder for this example, not a fetched quote. An initial value alone does not create a stored record or a cache hit.
Connect a tool using feeds:
// src/shared/tools/fetch-rates.tool.ts
import { z } from "zod";
import { defineTool } from "@keel-dev/core";
import { ratesBlock } from "@app/shared/state/rates.block.ts";
export const fetchRatesTool = defineTool({
id: "fetch-rates",
description: "Fetch the current USD to EUR rate",
access: "public",
input: z.object({}),
feeds: { block: ratesBlock, event: "refresh" },
execute: async () => {
// Fixed data for the walkthrough. Replace with your data source.
return { result: { usdToEur: 0.92 } };
},
});Declare ratesBlock in the agent's writes and include fetchRatesTool in
its tools. Add a reads grant if the agent also needs the rate rendered
into its context. See Updating state and
Reading state for those declarations.
Follow the freshness check
ageMs < ttlMs
→ skip execute
→ return stored value
→ emit "cached"execute()
→ reducer
→ validate next state
→ store + timestampAfter checking tool access and input, the runtime looks for the feeds
block's stored record. It computes:
ageMs = clock.now() - stored.updatedAtMs;
fresh = ageMs < block.ttlMs;| State of the record | What happens |
|---|---|
| No stored record | The tool executes. A successful update stores the result. |
Younger than ttlMs | Execution is skipped and the stored value is returned. |
Exactly at or older than ttlMs | The tool can execute again. |
ttlMs omitted or null | This cache check is disabled. |
A cache hit emits a tool event with phase cached and returns a result shaped
like this to the model:
{
"servedFromState": "rates",
"value": { "usdToEur": 0.92 }
}The hit does not run the tool, apply a reducer, or refresh the last-write timestamp. Expiry does not delete the value or trigger a background fetch; the next eligible tool call can refresh it. If that fetch fails before an update is applied, the previous value remains in the store.
Choose caching boundaries deliberately
The cache is keyed by thread and block, not tool arguments. If a tool accepts a currency pair, two different pairs can still hit the same block. Use this cache only when the block represents the shared snapshot the tool is intended to serve.
Multiple tools feeding the same block share its freshness. The shortcut
applies to feeds; returning a commit alone does not activate it.
Avoid TTL caching on commands such as setting a preference, since a fresh
value could skip the requested change.
The cache hit reads stored.value directly. It does not pass that value
through the block's read-time repair and validation path. Validate data when
seeding or migrating storage; a fresh but invalid raw record can otherwise
be returned as a cached tool result.
Use a consistent clock
The runtime stamps every successful write with its clock, and the freshness
check reads the same clock. The default is RealClock — wall time via
Date.now() — so in production a block's age is real elapsed time.
The Clock interface is one method, { now(): number }. A test that needs
to control time injects its own implementation:
import { createRuntime, type Clock } from "@keel-dev/core";
let nowMs = 0;
const clock: Clock = { now: () => nowMs };
const runtime = createRuntime({ registry, budgets, store, clock });
// Later in the test: nowMs += 60_000; — the next freshness check sees the age.Use the same time basis for timestamps and freshness checks. When manually seeding a fresh record, pass the runtime clock's current time:
await store.setBlock("thread-1", "rates", { usdToEur: 0.92 }, 1, Date.now());The default timestamp for setBlock and seedRaw is zero. Under the real
clock, a zero-timestamped record is decades old — always stale — so a seed
without an explicit timestamp never produces a cache hit.
Read-time repair does not refresh the timestamp.
Verify caching in Studio
Run turns that call fetch-rates repeatedly in the same thread:
- Begin without a stored rates record. Inspect the tool execution and the
resulting
reduceevent in Studio. - Ask again before 60 seconds have elapsed. Check for a
cachedevent and theservedFromStateresult; there should be no new reducer update. - Wait past the 60-second TTL and ask again. Confirm that the tool executes and saves a new revision.
- Repeat in another thread without a seeded record to verify that it has its own first fetch.
A scripted ModelAdapter that requests the tool on every step makes these
calls predictable in a test. If you use a live model, inspect whether it
actually requested the tool each time.
Next: Model.
