Shared State
Create a two-way connection between your UI and agent state.
What is shared state?
Agentic Copilots maintain a shared state that seamlessly connects your UI with the agent's execution. This shared state system allows you to:
- Display the agent's current progress and intermediate results
- Update the agent's state through UI interactions
- React to state changes in real-time across your application
When should I use this?
Use shared state when you want to facilitate collaboration between your agent and the user. Updates flow both ways — the agent's outputs are automatically reflected in the UI, and any inputs the user updates in the UI are automatically reflected in the agent's execution.
The two directions
Shared state is a single object that both the UI and the agent can read and write. In practice, most apps split it into at least two conceptual slices:
- A UI-written slice (e.g. user profile, form inputs) that the agent reads on every turn and uses to tailor its behaviour.
- An agent-written slice (e.g. notes, a document, a plan) that the UI renders in real time as the agent produces it.
The shared-state-read-write showcase cell wires both sides against a
single AgentState schema with preferences (UI-written) and notes
(agent-written).
Reading agent state
The useAgent hook subscribes your component to state changes. Pass
UseAgentUpdate.OnStateChanged and every mutation the agent makes to
its state triggers a re-render — your UI is a reactive window into the
agent's world.
// Subscribe the component to agent state changes. Any time the agent
// mutates its state (e.g. via its `set_notes` tool) this hook fires,
// we re-render, and the sidebar panels reflect the new values.
const { agent } = useAgent({
agentId: "shared-state-read-write",
updates: [UseAgentUpdate.OnStateChanged],
});Once subscribed, the agent-authored slice of state is just data you
render. The NotesCard below is a plain presentational component — it
doesn't know about CopilotKit at all; it just receives notes as a
prop from the parent page.
// Read-side render: this card reflects the agent-authored `notes` slice
// of shared state. The parent page passes `state.notes` in; we never
// touch agent state ourselves — we just render it. The Clear button is
// a small write-back, exposed as an `onClear` prop.
export function NotesCard({ notes, onClear }: NotesCardProps) {
return (
<div
data-testid="notes-card"
className="w-full max-w-md p-6 bg-white rounded-2xl shadow-sm border border-[#DBDBE5] space-y-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-[#010507]">Agent notes</h2>
<p className="text-xs text-[#57575B] mt-1">
The agent writes here via its{" "}
<code className="font-mono text-[11px] text-[#010507]">
set_notes
</code>{" "}
tool. The UI re-renders from shared state.
</p>
</div>
{notes.length > 0 && (
<button
type="button"
onClick={onClear}
data-testid="notes-clear-button"
className="text-[10px] uppercase tracking-[0.14em] font-medium text-[#57575B] hover:text-[#FA5F67] border border-[#DBDBE5] hover:border-[#FA5F67] rounded-full px-2.5 py-1 transition-colors"
>
Clear
</button>
)}
</div>
{notes.length === 0 ? (
<div
data-testid="notes-empty"
className="text-sm text-[#838389] italic pt-1"
>
No notes yet. Ask the agent to remember something.
</div>
) : (
<ul
data-testid="notes-list"
className="list-disc list-inside space-y-1 text-sm text-[#010507]"
>
{notes.map((note, i) => (
<li key={i} data-testid="note-item">
{note}
</li>
))}
</ul>
)}
</div>
);
}Writing to agent state
Writes flow the other direction via agent.setState. Every call
replaces the named fields; on the agent's next turn, the new values are
visible to the backend (via middleware, prompt injection, or direct
reads from state) and influence the reply.
// WRITE: every edit in the sidebar goes straight into agent state.
// On the agent's next turn, `PreferencesInjectorMiddleware` reads this
// back out of state and adds it to the system prompt — so the UI's
// writes visibly steer the model.
const handlePreferencesChange = (next: Preferences) => {
agent.setState({
preferences: next,
notes, // preserve what the agent has written
} as RWAgentState);
};The form that generates those writes is, again, a plain controlled
component. Every onChange bubbles up to the parent, which calls
agent.setState — keeping the UI and the agent in lockstep.
// Write-side render: every edit here bubbles up through `onChange`, and
// the parent pipes it straight into `agent.setState({ preferences: ... })`.
// Nothing in this component knows about the agent directly — that's
// intentional: the card is a plain controlled form, and the agent state
// wiring lives one layer up.
export function PreferencesCard({ value, onChange }: PreferencesCardProps) {
const set = <K extends keyof Preferences>(key: K, v: Preferences[K]) =>
onChange({ ...value, [key]: v });
const toggleInterest = (interest: string) => {
const has = value.interests.includes(interest);
set(
"interests",
has
? value.interests.filter((i) => i !== interest)
: [...value.interests, interest],
);
};
return (
<div
data-testid="preferences-card"
className="w-full max-w-md p-6 bg-white rounded-2xl shadow-sm border border-[#DBDBE5] space-y-5"
>
<div>
<h2 className="text-xl font-semibold text-[#010507]">
Your preferences
</h2>
<p className="text-xs text-[#57575B] mt-1">
These are written into agent state. The agent reads them on every
turn.
</p>
</div>
<label className="block">
<span className="text-sm font-medium text-[#57575B]">Name</span>
<input
data-testid="pref-name"
type="text"
value={value.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. Atai"
className="mt-1 w-full border border-[#DBDBE5] rounded-xl px-3 py-2 text-sm text-[#010507] focus:border-[#BEC2FF] focus:outline-none focus:ring-2 focus:ring-[#BEC2FF33]"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-[#57575B]">Tone</span>
<select
data-testid="pref-tone"
value={value.tone}
onChange={(e) => set("tone", e.target.value as Preferences["tone"])}
className="mt-1 w-full border border-[#DBDBE5] rounded-xl px-3 py-2 text-sm text-[#010507] bg-white focus:border-[#BEC2FF] focus:outline-none focus:ring-2 focus:ring-[#BEC2FF33]"
>
<option value="formal">Formal</option>
<option value="casual">Casual</option>
<option value="playful">Playful</option>
</select>
</label>
<label className="block">
<span className="text-sm font-medium text-[#57575B]">Language</span>
<select
data-testid="pref-language"
value={value.language}
onChange={(e) => set("language", e.target.value)}
className="mt-1 w-full border border-[#DBDBE5] rounded-xl px-3 py-2 text-sm text-[#010507] bg-white focus:border-[#BEC2FF] focus:outline-none focus:ring-2 focus:ring-[#BEC2FF33]"
>
<option>English</option>
<option>Spanish</option>
<option>French</option>
<option>German</option>
<option>Japanese</option>
</select>
</label>
<div>
<span className="text-sm font-medium text-[#57575B]">Interests</span>
<div className="mt-2 flex flex-wrap gap-2">
{INTEREST_OPTIONS.map((interest) => {
const selected = value.interests.includes(interest);
return (
<button
key={interest}
type="button"
onClick={() => toggleInterest(interest)}
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
selected
? "bg-[#BEC2FF1A] text-[#010507] border-[#BEC2FF]"
: "bg-white text-[#57575B] border-[#DBDBE5] hover:bg-[#FAFAFC]"
}`}
>
{interest}
</button>
);
})}
</div>
</div>
<div className="pt-3 border-t border-[#E9E9EF]">
<div className="text-[10px] uppercase tracking-[0.14em] text-[#838389] mb-1.5">
Shared state
</div>
<pre
data-testid="pref-state-json"
className="bg-[#FAFAFC] border border-[#E9E9EF] rounded-lg p-2.5 text-xs text-[#010507] overflow-x-auto font-mono"
>
{JSON.stringify(value, null, 2)}
</pre>
</div>
</div>
);
}Going further
Two common extensions of the basic pattern:
- State streaming — stream partial state updates to the UI while a tool call is still running, so long-running outputs (documents, plans) appear token-by-token.
- Agent read-only context —
when you only need a one-way UI → agent channel,
useAgentContextpublishes read-only values to the agent without opening up write access.