keel
State

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.

One block definition
thread-1 / profile
value: { "tone": "concise" }
version: 1
updatedAtMs: …
thread-2 / profile
value: { "tone": "detailed" }
version: 1
updatedAtMs: …
Same contract. Separate values, revisions, and timestamps.

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:

FieldMeaning
valueThe stored data, validated by the block on renders and reducer updates. A TTL cache hit serves it without re-validation.
versionA write revision incremented when a reducer update is saved.
updatedAtMsThe 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:

MethodsResponsibility
getBlock, setBlockRead and write block values with their revision and timestamp.
appendMessage, listMessagesStore conversation history per thread.
appendRecord, recordsStore and retrieve turn records.
claimThread, releaseThreadSerialize 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 last window messages in chronological order, and a window <= 0 must return none — not everything. A naive slice(-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 returns false when the thread is already claimed; releaseThread frees 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

Tool call → check the block's freshness
Fresh · reuse
ageMs < ttlMs
  → skip execute
  → return stored value
  → emit "cached"
Missing or expired · execute
execute()
  → reducer
  → validate next state
  → store + timestamp
A hit preserves the timestamp. Expiry permits another fetch; it does not delete data.

After 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 recordWhat happens
No stored recordThe tool executes. A successful update stores the result.
Younger than ttlMsExecution is skipped and the stored value is returned.
Exactly at or older than ttlMsThe tool can execute again.
ttlMs omitted or nullThis 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:

  1. Begin without a stored rates record. Inspect the tool execution and the resulting reduce event in Studio.
  2. Ask again before 60 seconds have elapsed. Check for a cached event and the servedFromState result; there should be no new reducer update.
  3. Wait past the 60-second TTL and ask again. Confirm that the tool executes and saves a new revision.
  4. 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.