Planning puts the agent's intended steps in front of the person before any of them run. The person edits, accepts, or sends the plan back, and one decision about the whole replaces a decision about each action. If the agent leaves the plan mid-run, it says so against what was agreed.
When to Use It
The work is long or consequential enough to review its shape once, up front
Approving a plan can replace dozens of per-action approvals
Deviations from an agreed plan need to be visible
When Not To
Tasks are trivial; asking for a plan on a two-step task is theater
You cannot pause execution; a plan that authorizes nothing should not look like consent
The person cannot evaluate the steps; invest in the preview, not the ask
Three views
The exchange between the human, the agent and the system the agent acts on; who holds each part of it; and the component that implements it.
01 · the interaction
02 · who holds what
01Plan stepsThe steps the AI intends to take, in order, in words you can read
02EditYou reshape the plan before any work starts
03AcceptYou approve the whole plan at once
04Reject & reviseYou send the plan back with a note on what to change
05Deviation noteThe AI tells you when it leaves the plan mid-run
03 · the component · code: beta
vendor presets are recreations, not vendor assets
THE LIVE PLAYGROUND NEEDS A WIDER SCREEN — IT'S WAITING ON DESKTOP.
THE ANATOMY ABOVE AND THE CODE BELOW ARE THE SAME COMPONENT.
Do / Don't
Do
For consequential work, make the plan block: nothing runs until the person accepts it.
Don't
Show a step list that authorizes nothing and let it read as consent.
A good-looking plan is not a good plan, and a narrated plan is not an approved one. Decide whether your step list blocks or narrates, and say which.
Do
Keep plans reviewable: a handful of steps a human can actually judge.
Don't
Emit forty micro-steps nobody reads, or three steps too coarse to mean anything.
Step lists too fine to read or too coarse to review both defeat the point of externalizing intent.
Do
Surface deviations against the approved plan as they happen.
Don't
Silently execute a different plan than the one the human accepted.
The plan the human approved is not always the plan that runs — deviation notes keep the contract honest.
Every pair cites a documented tension or principle boundary — no free-floating taste.
Get the Code
Namespaced install (add the registry to components.json once):
/**
* PlanPreview — headless core.
* Pattern: showing intended steps for review and edit before the agent acts.
* Anatomy: Steps · Edit · Accept · Reject-revise · Deviation note
*
* The config's `gate` flag captures the Claude Code vs. Codex divergence:
* gate=true → the plan blocks execution until accepted (plan as contract);
* gate=false → the plan is a live trace and Accept/Revise are absent.
*/
import * as React from 'react';
import {
createEvent,
type EventDraft,
type InteractionEventHandler,
type PlanAcceptedPayload,
type PlanProposedPayload,
type PlanRevisedPayload,
type PlanSnapshot,
} from '@/lib/interaction-contract';
export type { PlanSnapshot } from '@/lib/interaction-contract';
export type PlanState = 'proposed' | 'editing' | 'accepted' | 'revising' | 'executing';
export type StepStatus = 'pending' | 'active' | 'done' | 'skipped';
export interface PlanStep {
id: string;
text: string;
status: StepStatus;
}
export interface PlanPreviewConfig {
/** Does the plan block execution until accepted? */
gate: boolean;
/** Can the human edit steps before accepting? */
allowEdit: boolean;
}
export const defaultConfig: PlanPreviewConfig = { gate: true, allowEdit: true };
/**
* Lifecycle (gate=true):
* proposed ⇄ editing → accepted → executing
* proposed → revising
* accepted → editing (an edit makes a new version; Done without an edit returns to accepted)
* Execution starts only on START, which the host sends through `start()` or the
* controlled `state="executing"` prop. ACCEPT never starts it.
*/
export type PlanEvent =
| { type: 'ACCEPT' }
| { type: 'REVISE'; direction?: string }
| { type: 'EDIT' }
| { type: 'CANCEL_EDIT' } // leave editing (the Edit toggle); edits made so far stay
| { type: 'UPDATE_STEP'; id: string; text: string }
| { type: 'START' } // host only: run the accepted plan
| { type: 'ADVANCE' } // execution progress: the current step is done, the next one is active
| { type: 'DEVIATE'; note: string };
export interface MachineState {
state: PlanState;
planId: string;
version: number;
steps: PlanStep[];
/** The last snapshot the person accepted; null until the first accept. */
accepted: PlanSnapshot | null;
revisionDirection: string | null;
deviations: string[];
}
export function planSnapshot(s: Pick<MachineState, 'planId' | 'version' | 'steps'>): PlanSnapshot {
return Object.freeze({
planId: s.planId,
version: s.version,
steps: Object.freeze(s.steps.map((st) => Object.freeze({ id: st.id, text: st.text }))),
});
}
export function initialPlanState(
steps: Array<Pick<PlanStep, 'id' | 'text'>>,
config: PlanPreviewConfig,
planId: string,
version = 1,
): MachineState {
return {
// A non-gating plan is already executing when it appears: that's the point
state: config.gate ? 'proposed' : 'executing',
planId,
version,
steps: steps.map((s, i) => ({ id: s.id, text: s.text, status: i === 0 && !config.gate ? 'active' : 'pending' })),
accepted: null,
revisionDirection: null,
deviations: [],
};
}
/** True while the plan on screen is the version the person accepted. */
function isAcceptedVersion(s: MachineState): boolean {
return s.accepted !== null && s.accepted.version === s.version;
}
export function reducer(s: MachineState, e: PlanEvent): MachineState {
switch (e.type) {
case 'ACCEPT':
return s.state === 'proposed' || s.state === 'editing'
? { ...s, state: 'accepted', accepted: planSnapshot(s) }
: s;
case 'REVISE':
return s.state === 'proposed' ? { ...s, state: 'revising', revisionDirection: e.direction ?? null } : s;
case 'EDIT':
return s.state === 'proposed' || s.state === 'accepted' ? { ...s, state: 'editing' } : s;
case 'CANCEL_EDIT':
if (s.state !== 'editing') return s;
return { ...s, state: isAcceptedVersion(s) ? 'accepted' : 'proposed' };
case 'UPDATE_STEP': {
if (s.state !== 'editing') return s;
const target = s.steps.find((st) => st.id === e.id);
if (!target || target.text === e.text) return s;
return {
...s,
// The first change to an accepted plan makes a new version, which needs its own accept
version: isAcceptedVersion(s) ? s.version + 1 : s.version,
steps: s.steps.map((st) => (st.id === e.id ? { ...st, text: e.text } : st)),
};
}
case 'START': {
if (s.state !== 'accepted') return s;
const first = s.steps.findIndex((st) => st.status === 'pending');
return {
...s,
state: 'executing',
steps: s.steps.map((st, i) => (i === first ? { ...st, status: 'active' } : st)),
};
}
case 'ADVANCE': {
if (s.state !== 'executing') return s;
const idx = s.steps.findIndex((st) => st.status === 'pending' || st.status === 'active');
if (idx === -1) return s;
return {
...s,
steps: s.steps.map((st, i) =>
i === idx ? { ...st, status: 'done' } : i === idx + 1 ? { ...st, status: 'active' } : st,
),
};
}
case 'DEVIATE':
return s.state === 'executing' ? { ...s, deviations: [...s.deviations, e.note] } : s;
}
}
/**
* The contract events one transition produces (spec 2026-10-04 §1). `prev` is
* null on mount. Every event carries the plan id as its correlationId.
*/
export function planEvents(
prev: MachineState | null,
next: MachineState,
config: PlanPreviewConfig = defaultConfig,
): EventDraft[] {
const correlationId = next.planId;
if (prev === null) {
const payload: PlanProposedPayload = {
planId: next.planId,
version: next.version,
steps: planSnapshot(next).steps,
gate: config.gate,
};
return [{ type: 'plan.proposed', actor: 'agent', correlationId, payload }];
}
const out: EventDraft[] = [];
if (next.version > prev.version) {
const payload: PlanRevisedPayload = { planId: next.planId, version: next.version, reason: 'edited' };
out.push({ type: 'plan.revised', actor: 'human', correlationId, payload });
}
if (prev.state !== 'revising' && next.state === 'revising') {
const payload: PlanRevisedPayload = {
planId: next.planId,
version: next.version,
reason: 'requested',
direction: next.revisionDirection,
};
out.push({ type: 'plan.revised', actor: 'human', correlationId, payload });
}
if (next.accepted && next.accepted !== prev.accepted) {
const payload: PlanAcceptedPayload = next.accepted;
out.push({ type: 'plan.accepted', actor: 'human', correlationId, payload });
}
return out;
}
interface PlanPreviewContext extends MachineState {
config: PlanPreviewConfig;
send: (e: PlanEvent) => void;
/** Host only: run the accepted plan. Does nothing unless the state is `accepted`. */
start: () => void;
}
const Ctx = React.createContext<PlanPreviewContext | null>(null);
export function usePlanPreview(): PlanPreviewContext {
const ctx = React.useContext(Ctx);
if (!ctx) throw new Error('PlanPreview parts must be used inside <PlanPreview.Root>');
return ctx;
}
export interface RootProps {
steps: Array<Pick<PlanStep, 'id' | 'text'>>;
/** Stable id for this plan; carried on every snapshot. Defaults to a generated id. */
planId?: string;
/** Version of the plan as proposed. Defaults to 1. */
version?: number;
config?: Partial<PlanPreviewConfig>;
/**
* Controlled execution: pass `executing` to start the accepted plan (the same
* as calling `start()`). It has no effect until the person has accepted.
*/
state?: PlanState;
/** Called with the frozen plan the person accepted, once per accepted version. */
onAccept?: (plan: PlanSnapshot) => void;
onRevise?: (direction: string | null) => void;
/** One agent run; stamped on every contract event. Defaults to a generated id. */
runId?: string;
/** Contract events: plan.proposed, plan.revised, plan.accepted. Correlation id: the plan id. */
onEvent?: InteractionEventHandler;
children: React.ReactNode;
}
function Root({
steps,
planId,
version,
config: partial,
state: controlled,
onAccept,
onRevise,
runId,
onEvent,
children,
}: RootProps) {
const config = React.useMemo(() => ({ ...defaultConfig, ...partial }), [partial]);
const fallbackId = React.useId();
const [machine, send] = React.useReducer(reducer, undefined, () =>
initialPlanState(steps, config, planId ?? fallbackId, version),
);
const start = React.useCallback(() => send({ type: 'START' }), []);
React.useEffect(() => {
if (controlled === 'executing' && machine.state === 'accepted') start();
}, [controlled, machine.state, start]);
const prevAccepted = React.useRef<PlanSnapshot | null>(machine.accepted);
const prevState = React.useRef<PlanState>(machine.state);
React.useEffect(() => {
if (machine.accepted && machine.accepted !== prevAccepted.current) onAccept?.(machine.accepted);
prevAccepted.current = machine.accepted;
if (prevState.current !== machine.state && machine.state === 'revising') onRevise?.(machine.revisionDirection);
prevState.current = machine.state;
}, [machine.accepted, machine.state, machine.revisionDirection, onAccept, onRevise]);
const fallbackRun = React.useId();
const run = runId ?? fallbackRun;
const emit = React.useRef(onEvent);
emit.current = onEvent;
const prevMachine = React.useRef<MachineState | null>(null);
React.useEffect(() => {
const drafts = planEvents(prevMachine.current, machine, config);
prevMachine.current = machine;
for (const d of drafts) emit.current?.(createEvent(run, d));
}, [machine, config, run]);
const value = React.useMemo(() => ({ ...machine, config, send, start }), [machine, config, start]);
return (
<Ctx.Provider value={value}>
<div
role="group"
aria-label="Agent plan"
data-pattern="plan-preview"
data-part="root"
data-state={machine.state}
data-gate={config.gate}
data-version={machine.version}
>
{children}
</div>
</Ctx.Provider>
);
}
function Steps({ children }: { children: (step: PlanStep, index: number) => React.ReactNode }) {
const { steps, state } = usePlanPreview();
return (
<ol data-part="steps" data-state={state}>
{steps.map((s, i) => (
<li key={s.id} data-part="step" data-status={s.status}>
{children(s, i)}
</li>
))}
</ol>
);
}
/** Inline editor for a step — renders children only while editing is possible. */
function StepEditor({ step }: { step: PlanStep }) {
const { state, config, send } = usePlanPreview();
if (!config.allowEdit || state !== 'editing') return null;
return (
<input
data-part="step-editor"
aria-label={`Edit step: ${step.text}`}
value={step.text}
onChange={(e) => send({ type: 'UPDATE_STEP', id: step.id, text: e.target.value })}
/>
);
}
type TriggerProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'>;
/** Absent entirely when the plan is a trace, not a gate. */
function Accept(props: TriggerProps) {
const { state, config, send } = usePlanPreview();
if (!config.gate) return null;
return (
<button
type="button"
data-part="accept"
disabled={state !== 'proposed' && state !== 'editing'}
onClick={() => send({ type: 'ACCEPT' })}
{...props}
/>
);
}
function Revise({ direction, ...props }: TriggerProps & { direction?: string }) {
const { state, config, send } = usePlanPreview();
if (!config.gate) return null;
return (
<button
type="button"
data-part="revise"
disabled={state !== 'proposed'}
onClick={() => send({ type: 'REVISE', direction })}
{...props}
/>
);
}
function Edit(props: TriggerProps) {
const { state, config, send } = usePlanPreview();
if (!config.allowEdit || !config.gate) return null;
return (
<button
type="button"
data-part="edit"
aria-pressed={state === 'editing'}
disabled={state !== 'proposed' && state !== 'editing' && state !== 'accepted'}
onClick={() => send({ type: state === 'editing' ? 'CANCEL_EDIT' : 'EDIT' })}
{...props}
/>
);
}
/** How departures from the approved plan surface mid-run. */
function DeviationNote() {
const { deviations, state } = usePlanPreview();
if (deviations.length === 0) return null;
return (
<div data-part="deviation" data-state={state} role="status" aria-live="polite">
{deviations.map((d, i) => (
<p key={i}>{d}</p>
))}
</div>
);
}
export const PlanPreview = Object.assign(Root, { Steps, StepEditor, Accept, Revise, Edit, DeviationNote });
patterns/plan-preview/plan-preview.tsx
/**
* PlanPreview — styled reference implementation with vendor presets.
* The gate/trace divergence is the preset: Claude Code's plan blocks execution
* (gate: true); Codex's plan narrates it (gate: false).
*/
import * as React from 'react';
import {
PlanPreview as Core,
usePlanPreview,
type PlanPreviewConfig,
type PlanSnapshot,
type PlanState,
type PlanStep,
} from './plan-preview-core';
import type { InteractionEventHandler } from '@/lib/interaction-contract';
export interface PlanPreviewPreset {
id: string;
label: string;
copy: { title: string; accept: string; revise: string; edit?: string };
machineConfig: Partial<PlanPreviewConfig>;
}
export const presets: Record<'neutral' | 'anthropic' | 'openai', PlanPreviewPreset> = {
neutral: {
id: 'neutral',
label: 'Neutral',
copy: { title: 'Proposed plan', accept: 'Accept plan', revise: 'Request changes', edit: 'Edit' },
machineConfig: { gate: true, allowEdit: true },
},
anthropic: {
id: 'anthropic',
label: 'Claude Code Plan Mode (recreation)',
copy: { title: "Claude's plan", accept: 'Yes, and approve the plan', revise: 'No, keep planning', edit: 'Edit plan' },
machineConfig: { gate: true, allowEdit: true },
},
openai: {
id: 'openai',
label: 'Codex task plan (recreation)',
copy: { title: 'Task plan', accept: '', revise: '' }, // no gate: accept/revise absent
machineConfig: { gate: false, allowEdit: false },
},
};
export interface PlanPreviewProps {
preset?: keyof typeof presets;
steps: Array<Pick<PlanStep, 'id' | 'text'>>;
planId?: string;
version?: number;
/** Pass `executing` to run the accepted plan. Accepting never starts it. */
state?: PlanState;
/** Receives the frozen plan the person accepted: `{ planId, version, steps }`. */
onAccept?: (plan: PlanSnapshot) => void;
onRevise?: (direction: string | null) => void;
/** One agent run; stamped on every contract event. */
runId?: string;
/** Interaction Contract events (see the Interaction Contract docs page). */
onEvent?: InteractionEventHandler;
}
/** Says which version is accepted, and when an edit has made a new one that needs accepting. */
function VersionNote() {
const { state, version, accepted, config } = usePlanPreview();
if (!config.gate) return null;
let text = '';
if (state === 'accepted') text = `Version ${version} accepted. Nothing runs until the host starts it.`;
else if (accepted && accepted.version !== version) text = `Version ${version} changed after acceptance. Accept it again before it runs.`;
else if (state === 'executing') text = `Running version ${version}.`;
return (
<p data-part="version-note" role="status" aria-live="polite" hidden={!text}>
{text}
</p>
);
}
export function PlanPreview({ preset = 'neutral', steps, planId, version, state, onAccept, onRevise, runId, onEvent }: PlanPreviewProps) {
const p = presets[preset];
return (
<Core
steps={steps}
planId={planId}
version={version}
state={state}
config={p.machineConfig}
onAccept={onAccept}
onRevise={onRevise}
runId={runId}
onEvent={onEvent}
>
<div className="ah-plan" data-preset={p.id}>
<p style={{ margin: '0 0 .6rem', fontSize: '.75rem', textTransform: 'uppercase', letterSpacing: '.08em', opacity: 0.6 }}>
{p.copy.title}
</p>
<Core.Steps>
{(step) => (
<>
<span>{step.text}</span>
<Core.StepEditor step={step} />
</>
)}
</Core.Steps>
<div className="ah-actions">
{p.copy.accept && <Core.Accept>{p.copy.accept}</Core.Accept>}
{p.copy.revise && <Core.Revise>{p.copy.revise}</Core.Revise>}
{p.copy.edit && <Core.Edit>{p.copy.edit}</Core.Edit>}
</div>
<VersionNote />
<Core.DeviationNote />
</div>
</Core>
);
}
Give this to your coding agent (shadcn MCP: npx shadcn@latest mcp init --client claude):
Add the @agentsandhumans registry to components.json:
{
"registries": {
"@agentsandhumans": "https://agentsandhumans.ai/api/registry/{name}.json"
}
}
Then install plan-preview (npx shadcn@latest add @agentsandhumans/plan-preview) and wire it into the flow where your agent agreeing on the shape of the work before the agent does it.
Shared ground is what two people know before one asks the other for help: the goal, the history, who matters. An agent has almost none, so it guesses. Appleton wants a screen that shows what it understood before it acts, so you can fix it.
Appleton's argument shifts the pressure: model capability is no longer the slow part, design decisions are. The patterns teams name now will be the defaults agentic products ship with, and reversing bad defaults in production is expensive.
Documented sightings (8) — anatomy-mapped observations from vendor materials
A dedicated planning interface helps developers decompose tasks, assign them to parallel agent sessions, and clarify execution order and dependencies before work begins.
Chart unlocks as the corpus grows — n = 0 mentions across 0 quarters.
AnthropicPlan mode
OpenAItask planning / plan tool
Cognition (Devin)Devin's plan
Tensions & Failure Modes
Plans are cheap: a good-looking plan is not a good plan
Granularity: step lists too coarse to review, too fine to read
Staleness: the plan the human approved is not always the plan that runs
What we are learning from the community
Readers who build with Planning add what they found: questions, use cases, critiques, other ways to solve it.
An editor reads each one before it shows here. Nothing here changes the pattern's status on its own.
This pattern has no contributions yet. The form below sends the first one.