keel
State

Reading state

Turn stored values into focused views that your agents can use.

An agent doesn't automatically see everything your application stores. A reader turns a block's value into text, and the agent's read declaration selects which view enters its context. This lets you keep rich application data while giving each model only the information relevant to its work.

1 · Stored value
{
  "tone":
    "concise"
}
2 · Named reader
assistant(state)

state → text
3 · Rendered text
"User prefers
concise answers."
4 · Model context
instructions
render.profile
history
user message
The block stores the value. The reader decides how to present it to the model.

This guide uses the profile block from Defining blocks. We'll follow its value from storage to the model, then explore different views of the same block.

Start with a stored value

Suppose the current thread's profile contains:

{
  "tone": "concise"
}

That object belongs to your application. The model does not automatically receive its JSON representation. You decide how to describe it through a reader on the block.

Define the view

Inside profileBlock, the readers field declares named functions:

// Inside src/shared/state/profile.block.ts
readers: {
  assistant: (state) => `User prefers ${state.tone} answers.`,
},

The function receives the block's typed value and returns text. For the stored value above, the result is:

User prefers concise answers.

Use readers to select useful fields, summarize collections, and explain what a value means. Keep them focused on formatting state; fetch new information through tools and save it through updates.

The name assistant identifies a view. It is selected by the application's read declaration, rather than chosen by the model at runtime.

Connect the reader to an agent

First, define a read budget in src/budgets.ts. Add this entry to your existing budget configuration if it already exports readTokens:

export const readTokens = {
  profile: 128,
};

Then import the block and budget into your agent file:

import { profileBlock } from "@app/shared/state/profile.block.ts";
import { readTokens } from "@app/budgets.ts";

The @app/* alias resolves to src/* (a tsconfig paths entry that the scaffolded app, Studio, and keel dev all resolve), so shared declarations never need ../../.. chains.

Add this entry to the reads array inside defineAgent:

reads: [
  {
    block: profileBlock,
    reader: "assistant",
    budgetTokens: readTokens.profile,
  },
],
FieldWhat it controls
blockWhich block to read from the current thread.
readerWhich named function renders that value. If omitted, it defaults to the agent's id.
budgetTokensHow much context space this rendered view is allowed to use.

Declaring a read does not grant write access. An agent that changes the profile must separately declare it in writes and carry a tool that submits the update.

Follow the value into context

When Keel assembles the turn's context, it:

  1. Reads the block's value for the current thread, applying the block's optional repair function and validating stored data.
  2. Calls the selected reader with that value and its token budget.
  3. Checks the size of the returned text and records the render.
  4. Adds the text to a context section named render.profile.assistant.

The model receives the rendered sentence alongside its instructions, other state views, and conversation context. The section id follows render.<block>.<reader>, so two grants on the same block through different readers stay distinguishable. render.profile.assistant identifies the section for inspection; it is not an instruction you need to add to your prompt.

See Context for how the complete model input is assembled.

One block, different audiences

Different agents often need different details from the same data. Suppose a notes block stores:

{
  "notes": [
    "Send the draft on Friday.",
    "Include the latest figures."
  ]
}

Its readers could expose the content to an assistant and a count to a researcher. This example assumes a block schema with a notes: string[] field:

readers: {
  assistant: (state) =>
    state.notes.map((note, index) => `${index + 1}. ${note}`).join("\n"),
  researcher: (state) =>
    `${state.notes.length} note(s) on file.`,
},
Selected readerText supplied to the model
assistant1. Send the draft on Friday. followed by 2. Include the latest figures.
researcher2 note(s) on file.

Both read the same stored value. The assistant gets actionable details; the researcher gets a smaller view that avoids exposing unnecessary content. Readers control this state-to-context path. Keep tool outputs and other context sources equally focused if they can expose the same information.

Keep the view within its budget

A read budget limits the rendered text, not the size of the stored object. A block can hold many notes while its reader produces a short summary.

Readers receive the budget as their second argument:

readers: {
  researcher: (state, budgetTokens) => {
    const count = `${state.notes.length} note(s) on file.`;
    if (budgetTokens < 128) return count;

    const latest = state.notes.at(-1);
    return latest ? `${count}\nLatest: ${latest}` : count;
  },
},

This lets a reader choose how much detail to include. The runtime still checks the result; choosing a shorter view does not guarantee it fits. Keel currently estimates tokens from text length, rather than using a provider-specific tokenizer.

When a view exceeds its grant, Keel clips the output and includes a notice:

…[clipped at the 128-token read budget]

The render event records clipped: true, and the runtime emits a warning. Shorten the reader or increase its grant when you see this. Prefer producing a useful summary yourself, since clipping may cut through a sentence. The complete context must also fit the model's window.

When changes become visible

Readers run during context assembly at the beginning of a turn. If a tool updates a block later in that turn, the already assembled state view does not automatically refresh for subsequent model steps. The model can still receive the tool's result through the tool-call transcript.

The next turn reads the current stored value and renders it again. For example, after a successful preference update, the next turn in the same thread can receive User prefers detailed answers.

When a read cannot be completed as declared

SituationBehavior
The named reader does not existdefineAgent throws a configuration error.
No value is stored for the threadThe reader receives the block's initial value.
Stored data fails validation after optional repairThe reader receives initial.
Rendered text exceeds the read budgetKeel clips it with a notice and emits a warning.
Reader or repair code throwsContext assembly fails and the turn settles as a typed render-failure defect. No raw exception escapes runTurn; the turn is failed and refunded, not crashed. Keep these functions defensive so a bad value degrades to a smaller view instead of failing the turn.

Read Repairing state for handling older or invalid stored values.

Inspect in Studio

Try this with the profile block and the tool from Updating state:

  1. Start a fresh thread in Studio and run a turn. Inspect STATE, then find render.profile.assistant in CONTEXT. Check that it contains the initial concise preference.
  2. With a live model, ask the assistant to remember that you prefer detailed answers. Confirm that the tool actually updated the block in STATE. A scripted model needs a scripted tool call for this step.
  3. Send another message in the same thread. Inspect CONTEXT again: the new turn should contain the detailed preference.

This distinguishes the stored update from the text a particular turn received. If a preference looks stale, check both before changing the prompt.

Next: Updating state.