Updating state
Follow a change from a tool call through a reducer to a validated stored value.
The model requests an action. Tool code decides what update to submit, and a reducer computes the next value. Keel validates that value before storing it. Each step has a distinct role: requesting work, performing it, and checking that the resulting state follows the block's contract.
set-tone
{ "tone": "detailed" }input.safeParse(args)
valid → executeresult → feeds
payload → commitsetTone(state, event)
→ nextStateschema.safeParse
(nextState){ "tone": "detailed" }
reduce event emittedThis guide continues the profile example from
Defining blocks. We'll change the user's answer
preference from "concise" to "detailed".
Define the change in a reducer
The profile block declares the values it accepts and the event that changes
them. These fields belong inside defineBlock in
src/shared/state/profile.block.ts:
schema: z.object({
tone: z.enum(["concise", "detailed"]),
}),
initial: { tone: "concise" },
reducers: {
setTone: (state, event: { tone: "concise" | "detailed" }) => ({
...state,
tone: event.tone,
}),
},setTone receives the current value and an event payload, then returns a new
value. It does not mutate the original object. Keep reducers pure: network
requests, timestamps, and other side effects belong in tool code.
The reducer's TypeScript annotation helps check application code. At runtime, the block's schema validates the resulting state before it is stored. Tool input validation is a separate check, defined next.
Connect a tool with feeds
Create a tool that accepts a preference and returns the reducer's payload:
// src/shared/tools/set-tone.tool.ts
import { z } from "zod";
import { defineTool } from "@keel-dev/core";
import { profileBlock } from "@app/shared/state/profile.block.ts";
const toneInput = z.object({
tone: z.enum(["concise", "detailed"]),
});
export const setToneTool = defineTool({
id: "set-tone",
description: "Save the user's preferred answer style",
access: "public",
input: toneInput,
feeds: { block: profileBlock, event: "setTone" },
execute: (raw) => {
const input = toneInput.parse(raw);
return { result: { tone: input.tone } };
},
});Keel validates the model's arguments against input before calling
execute. The current defineTool API types that callback's argument as
unknown; parsing with toneInput inside it also gives this code a typed
value to work with.
The feeds binding connects the successful tool result to the block:
| Field | Meaning |
|---|---|
block: profileBlock | Apply the update to this block in the current thread. |
event: "setTone" | Use this named reducer. |
result: { tone: input.tone } | Pass this value as the reducer's event payload. |
access: "public" makes this example tool available without additional
access restrictions. It does not replace the agent's write declaration.
Give the agent write access
Import the tool and block into your assistant's agent file:
import { profileBlock } from "@app/shared/state/profile.block.ts";
import { setToneTool } from "@app/shared/tools/set-tone.tool.ts";Add them to the existing defineAgent configuration:
writes: [profileBlock],
tools: [setToneTool],Append to these lists if your agent already has other tools or writable
blocks. Keep its reads declaration if it also needs to see the stored
preference. Write access and read access are declared separately.
For a feeds tool, defineAgent checks that its target block is declared in
writes and its reducer exists. A missing declaration is a configuration
error before any turn runs.
Follow one successful update
Suppose the user asks, “Please remember that I prefer detailed answers,” and
the model requests set-tone with:
{ "tone": "detailed" }- The runtime checks tool access and validates the arguments.
- Tool code returns
{ result: { tone: "detailed" } }. - The
feedsbinding submits that result toprofileBlock'ssetTonereducer. - The reducer computes the next state from the writer's current value.
- The block schema validates the next state, then the store saves it.
- Keel emits a
reduceevent containing the new value and stored revision.
The stored profile is now:
{ "tone": "detailed" }The tool result is also returned to the model. The reduce event is the
runtime's record that the state update was applied.
Use commit for a separate payload
Sometimes the tool's response and the desired state update have different
shapes. Use a returned commit to choose the update payload explicitly.
As an alternative to the feeds binding above, remove feeds and use this
callback with the same toneInput schema:
execute: (raw) => {
const input = toneInput.parse(raw);
return {
result: { requestedTone: input.tone },
commit: {
block: "profile",
event: "setTone",
payload: { tone: input.tone },
},
};
},feeds | commit | |
|---|---|---|
| Declared in | The tool definition | The value returned by execute |
| Reducer payload | The entire result | The explicit payload |
| Useful when | The result already matches the update | The update differs from the response or is conditional |
| Required access | Target block in writes | Target block in writes |
Choose one path for the same update. If a tool both declares feeds and
returns commit, the runtime attempts both, in that order. They are separate
updates, not one atomic transaction.
The commit.block name is optional when the agent writes exactly one block.
Naming it explicitly makes the target clear as your agent grows.
One writer can apply multiple updates
Within a turn, the first open of a block obtains its writer. Every later
open of the same block receives a reader with no apply method.
The runtime retains the writer acquired for tool-driven updates and reuses it for subsequent updates to the same block. Each reducer receives the writer's latest value, and each accepted update is awaited before the tool result returns. One writer means one write handle, not one event.
If the runtime's byproduct path receives a reader because the writer was
already taken this turn, the update is refused with a writer-taken defect
and the refusal is appended to the tool's result. The openBlock function
that implements this rule is runtime-internal — its per-turn context type is
not part of the application-facing API. Application code updates state
through tools; it does not open writers directly.
This ownership rule is scoped to a turn. It does not by itself provide a store-wide lock across independent turns or processes.
Know when the new value is visible
An accepted update is stored when the reducer is applied. It does not wait for the turn's final answer. Previously applied updates are not automatically rolled back if the turn later fails.
The model's state view was rendered when the turn began; a write does not automatically refresh that view during the turn. The model can receive the tool's response immediately, while the next turn renders the updated stored value. See Reading state.
For this preference-setting tool, leave the block's ttlMs unset. A fresh
TTL on a feeds block can skip tool execution and return its cached value,
even when the tool arguments change. See Storage and caching.
Understand failure behavior
| Failure | What the runtime does | What reaches the model |
|---|---|---|
| Invalid tool arguments | Refuses execution and emits tool-input-invalid. | A rejection reason asking for corrected arguments. |
| Tool execution throws | Retries according to retries, then emits tool-error if attempts are exhausted. | Error feedback by default; onError: "fail" ends the turn instead. |
| Reducer throws or produces invalid state | Refuses the update and emits tool-error. Invalid reducer output does not replace the stored value. | The tool result with STATE COMMIT REFUSED (…): reason appended. |
| Writer is already taken | Refuses the update and emits writer-taken. | The tool result with the refusal appended. |
| A returned commit targets an undeclared block or unknown reducer | Refuses the update and emits tool-error. | The tool result with the refusal appended. |
A refused commit is never silent
A refused state commit is appended to the tool's own result as
STATE COMMIT REFUSED (…): reason, so the model sees that the write did
not land and can react — retry differently or answer honestly without it.
Under onError: "fail", a refused commit fails the whole turn instead.
A response such as saved: true inside your own result payload still
proves nothing by itself; the reduce event is the runtime's record that
the update was applied.
The same feedback loop that returns tool errors to the model for correction carries these commit refusals. See Failure and recovery for the loop and its limits.
Verify the update in Studio
- Open a fresh thread in Studio. Run an initial turn and
check that CONTEXT contains
User prefers concise answers. - Ask the assistant to remember a preference for detailed answers. With a
scripted model, add a scripted
set-tonecall with{ tone: "detailed" }. - Inspect the tool call and its arguments in MODEL, then the
reduceevent in STATE. Confirm that the resulting profile containstone: "detailed"and check for any defects. - Send another message in the same thread. Inspect CONTEXT to confirm
that the new turn received
User prefers detailed answers.
You have now checked the request, the actual state change, and its visibility to a later turn.
Next: Repairing state.
