keel
State

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.

1 · Tool call
set-tone
{ "tone": "detailed" }
2 · Input check
input.safeParse(args)
valid → execute
3 · Tool code
result → feeds
payload → commit
4 · Reducer
setTone(state, event)
→ nextState
5 · State check
schema.safeParse
(nextState)
6 · Store
{ "tone": "detailed" }
reduce event emitted
Valid next state → store the update
Invalid next state → emit a defect; keep the previous value
Two checks: validate the requested input, then validate the state it produces.

This 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:

FieldMeaning
block: profileBlockApply 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" }
  1. The runtime checks tool access and validates the arguments.
  2. Tool code returns { result: { tone: "detailed" } }.
  3. The feeds binding submits that result to profileBlock's setTone reducer.
  4. The reducer computes the next state from the writer's current value.
  5. The block schema validates the next state, then the store saves it.
  6. Keel emits a reduce event 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 },
    },
  };
},
feedscommit
Declared inThe tool definitionThe value returned by execute
Reducer payloadThe entire resultThe explicit payload
Useful whenThe result already matches the updateThe update differs from the response or is conditional
Required accessTarget block in writesTarget 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

FailureWhat the runtime doesWhat reaches the model
Invalid tool argumentsRefuses execution and emits tool-input-invalid.A rejection reason asking for corrected arguments.
Tool execution throwsRetries 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 stateRefuses 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 takenRefuses the update and emits writer-taken.The tool result with the refusal appended.
A returned commit targets an undeclared block or unknown reducerRefuses 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

  1. Open a fresh thread in Studio. Run an initial turn and check that CONTEXT contains User prefers concise answers.
  2. Ask the assistant to remember a preference for detailed answers. With a scripted model, add a scripted set-tone call with { tone: "detailed" }.
  3. Inspect the tool call and its arguments in MODEL, then the reduce event in STATE. Confirm that the resulting profile contains tone: "detailed" and check for any defects.
  4. 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.