Skip to content

2. Persistent state

Checkpoint 2 of 66 minutes

Persistent state

Save partial trip details, merge later updates, and answer from durable state.

Chat history is for the model; persistent state is data your code can read. usePersistentState(name, initial) resembles React state, but every write is stored in the current conversation’s Durable Object. The agent re-renders before each model call, so its instructions receive the latest state snapshot.

Only src/agents/field-trip.ts changes from cp/1-hello-agent. Add persistent trip state, the save_trip_brief tool, and instructions that include the saved brief:

src/agents/field-trip.ts+51−2

Changes from cp/1-hello-agent → cp/2-hooks-state

src/agents/field-trip.ts
===================================================================
--- a/src/agents/field-trip.ts cp/1-hello-agent
+++ b/src/agents/field-trip.ts cp/2-hooks-state
@@ -1,8 +1,57 @@
'use agent';
-import { useModel } from '@flue/runtime';
+import { useModel, usePersistentState, useTool } from '@flue/runtime';
+import * as v from 'valibot';
+// The trip brief the agent remembers for this conversation.
+type TripBrief = {
+ city?: string;
+ startDate?: string; // YYYY-MM-DD
+ endDate?: string; // YYYY-MM-DD
+ headcount?: number;
+ budget?: string;
+ interests?: string[];
+};
+
export function FieldTrip() {
useModel('cloudflare/@cf/google/gemma-4-26b-a4b-it');
- return `You are FieldTrip, a helpful team-offsite planner. You help groups plan memorable offsites by understanding their destination, dates, headcount, budget, and interests.`;
+
+ // Durable, per-conversation state (stored in this conversation's Durable Object).
+ // Shaped like React's useState, but it survives restarts and redeploys.
+ const [brief, setBrief] = usePersistentState<TripBrief>('brief', {});
+
+ // Tools can write state. The write commits together with the tool call.
+ useTool({
+ name: 'save_trip_brief',
+ description:
+ 'Save or update the offsite trip brief. Call this whenever the user states or changes the city, dates, headcount, budget, or interests. Only include fields the user mentioned; they are merged into the saved brief.',
+ input: v.object({
+ city: v.optional(v.string()),
+ startDate: v.optional(v.pipe(v.string(), v.isoDate())),
+ endDate: v.optional(v.pipe(v.string(), v.isoDate())),
+ headcount: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))),
+ budget: v.optional(v.string()),
+ interests: v.optional(v.array(v.string())),
+ }),
+ async run({ data }) {
+ const updates = Object.fromEntries(
+ Object.entries(data).filter(([, value]) => value !== undefined),
+ ) as TripBrief;
+ setBrief((previous) => ({ ...previous, ...updates }));
+ return { output: { saved: updates } };
+ },
+ });
+
+ // The agent re-renders before every model call, so these instructions
+ // always reflect the latest saved brief.
+ const hasBrief = Object.keys(brief).length > 0;
+ return `You are FieldTrip, a helpful team-offsite planner. You help groups plan memorable offsites by understanding their destination, dates, headcount, budget, and interests.
+
+Rules:
+1. If the user's message contains ANY trip detail (city, dates, headcount, budget, interests), your FIRST action is to call \`save_trip_brief\` with those fields. Do this before writing any reply.
+2. Answer questions about the trip from the saved brief below. If a detail is missing, ask for it.
+3. Keep replies short: at most 120 words unless the user asks for more detail.
+
+## Saved trip brief
+${hasBrief ? JSON.stringify(brief, null, 2) : '(nothing saved yet)'}`;
}

No route, config, or migration changes are needed. State uses the existing FlueFieldTripAgent Durable Object.

Trip dates are filled automatically in the commands and their copy buttons.

Terminal window
npm run deploy
sleep 5
npm run smoke -- https://field-trip-agent.<subdomain>.workers.dev cp2-live "We're planning an offsite in Lisbon from <START> to <END> for 14 people, budget around 400 EUR per person. We like food, history and something outdoorsy."
npm run smoke -- https://field-trip-agent.<subdomain>.workers.dev cp2-live "Update: two more people are joining, so 16 now."
npm run smoke -- https://field-trip-agent.<subdomain>.workers.dev cp2-live "What's my headcount, city and budget? One line."

Look for a full save_trip_brief call, then a second call containing only {"headcount":16}. The final answer should retain Lisbon and the budget.

Optional depth check: prove isolation with a different conversation:

Terminal window
npm run smoke -- https://field-trip-agent.<subdomain>.workers.dev cp2-live-other "What's my saved headcount?"

The fresh conversation must not inherit cp2-live state.

Save your workers.dev subdomain to open this test ↗https://field-trip-agent.<subdomain>.workers.dev/?id=cp2-live

This opens the same conversation as the smoke test. If you repeat this checkpoint, before copying its commands.

Verification gate

Prove it works

  • The first message calls save_trip_brief with the supplied details.
  • The update saves only headcount: 16 without erasing other fields.
  • The final query returns 16, Lisbon, and 400 EUR per person.
  • Optional: a different conversation ID has no saved brief.
Optional mini quizCheck durable state3 questions instant feedback
1Where is the trip brief stored?
2Why use the setter updater form?
3When may the setter be called?
Recovery lane

Get back on track in under a minute

If a dev server is running, stop it first. Uncommitted work is stashed and the current commit gets a backup branch before anything moves.

Option 01

Restart this exercise

Return to cp/1-hello-agent, rebuild this exercise, then use its Live deploy verification tab. Stop any running dev server with Ctrl+C before switching.

Terminal
git stash push --include-untracked -m "workshop recovery"
git fetch origin
git branch -f workshop-backup HEAD
git switch --no-track -C workshop origin/cp/1-hello-agent
npm ci
Option 02

Catch up with the room

Jump to cp/2-hooks-state, then use this checkpoint's Live deploy verification tab. Stop any running dev server with Ctrl+C before switching.

Terminal
git stash push --include-untracked -m "workshop recovery"
git fetch origin
git branch -f workshop-backup HEAD
git switch --no-track -C workshop origin/cp/2-hooks-state
npm ci

Next: Connect Open-Meteo tools →