> Premium standalone component (part of no design system, works in any React + Tailwind app) from AI Canvas. Index of every component and install setup: https://aicanvas.me/llms.txt

# Checkpoint

An agent checkpoint card that pauses for a decision on a question, command or plan, then collapses into a one-line receipt with undo, edit-before-allow and a risk grade.

- Category: AI Agent UI
- Built with: Motion, Tailwind CSS
- License: Premium (proprietary, not in the public repo)
- Page: https://aicanvas.me/components/agent-checkpoint

## Install

```bash
npx shadcn@latest add @aicanvas/agent-checkpoint
```

Requires Premium. Set your AI Canvas token as AICANVAS_TOKEN and register the @aicanvas registry with it, as described in the Install section of https://aicanvas.me/llms.txt. Without a Premium token the command writes a placeholder file instead of the component.

AI agents can also get its install command from the read-only AI Canvas MCP server (`npx -y @aicanvas/mcp`) and run it. Setup: https://aicanvas.me/mcp

Peer dependencies:

```bash
npm install framer-motion
```

## Props

### CheckpointQuestion

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `prompt` | `string` |  | The question shown in 14px 600. |
| `options` | `string[]` |  | Up to three option chips; keys 1 to 3 pick them. |
| `allowCustom?` | `boolean` | `true` | Adds a "Something else" chip that reveals a text input. |

### AgentCheckpoint

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `mode?` | `'question'\|'command'\|'plan'` |  | Which decision the card asks for. Left out, it follows the data: questions, then steps, else command. With no data props at all the card plays its demo, with a mode switch above it. |
| `title?` | `string` |  | Header title next to the fingerprint mark. |
| `questions?` | `CheckpointQuestion[]` |  | Questions asked one at a time in question mode. |
| `command?` | `string` |  | Shell command shown in command mode; editable inline, the edited text is what gets approved. A new value starts a new request. |
| `cwd?` | `string` |  | Working directory shown on the first line of the command box. |
| `risk?` | `'read'\|'writes'\|'destructive'` |  | Risk grade chip; destructive turns the approve button red and requires two presses. |
| `why?` | `string` |  | One-sentence reason shown under the command. |
| `steps?` | `string[]` |  | Plan steps; each can be struck out before confirming. |
| `autoApproveSeconds?` | `number` |  | Draws a ring around the approve button that approves with reason 'timeout' when it completes; pauses while the card is hovered or focused. Left out or 0, nothing auto-approves; ignored when risk is destructive. |
| `labels?` | `{approve?: string, reject?: string}` |  | Overrides the approve and reject button labels in every mode. |
| `onApprove?` | `(payload: CheckpointPayload & {reason: 'user'\|'timeout'}) => void` |  | Receives exactly what the user agreed to: the edited command, the kept steps, or the answers. |
| `onReject?` | `(reason: 'user'\|'skip') => void` |  | Called on reject (or skip in question mode). Passing onApprove, onReject or onUndo disables the demo auto-reset and demo auto-approve. |
| `onUndo?` | `() => void` |  | Called when the user undoes a decision. Left out in host use, no Undo is offered. |
| `requestId?` | `string` |  | Give each ask its own id; a new id reopens a decided card even when the text is identical. |
| `className?` | `string` |  | Extra classes merged onto the outermost root element. |
| `tuning?` | `Partial<CheckpointTuning>` |  | Overrides any number in CHECKPOINT_TUNING (timings, sizes, springs); keys left out keep their defaults. |

## About

Checkpoint is the card an agent shows when it has to stop and ask before it continues: answer a short question, allow a shell command, or confirm a plan. The command can be edited in place before it is allowed, plan steps can be struck out, and the callback receives exactly what the user agreed to. A risk chip grades the command as read, writes or destructive, and a destructive command turns the button red and asks for a second press. An optional auto-allow ring runs around the button and pauses while the pointer or focus is inside the card. After the decision the card folds into a one-line receipt with a five second undo, so the conversation stays readable. Every prop is optional and the defaults reproduce the preview, with full keyboard control, reduced motion support and a matching light theme.

Use cases: AI coding agents, Chat assistants, Automation approvals.

## Source

The source is Premium and is not published. Preview it at https://aicanvas.me/components/agent-checkpoint.

## Remix prompt

The public part of the remix prompt: 1. Setup and 2. Constants. The rest of the build spec (state, markup, how it works) is part of Premium.

```markdown
# Agent Checkpoint
A human-approval card for AI agents: answer questions, allow a command, or confirm a plan.

## 1. Setup
// npm install framer-motion
File: components-workspace/agent-checkpoint/index.tsx  ·  'use client'  ·  export default function AgentCheckpoint()
import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
} from 'react'
import type * as React from 'react'
import {
  AnimatePresence,
  motion,
  useMotionValue,
} from 'framer-motion'
import type { MotionValue } from 'framer-motion'
import { RotateCcw } from '@/app/_icons/icons/rotate-ccw'
import { ChevronRight } from '@/app/_icons/icons/chevron-right'
import { Check } from '@/app/_icons/icons/check'
import { Pen2 } from '@/app/_icons/icons/pen-2'
import { X } from '@/app/_icons/icons/x'
import { ClockDuotone } from '@/app/_icons/icons/clock-duotone'
import { FingerprintDuotone } from '@/app/_icons/icons/fingerprint-duotone'
import { FolderDuotone } from '@/app/_icons/icons/folder-duotone'
import { CircleMinusDuotone } from '@/app/_icons/icons/circle-minus-duotone'

Styling is Tailwind CSS v4 (the bg-(--var) and text-(color:--var) syntax, pointer-coarse: and class-based dark:). The @/app/_icons imports are the AI Canvas icon set; without it, use any icons that accept the same size, weight and className props.

## 2. Constants
export type CheckpointTuning = {
  hoverMs: number
  stateMs: number
  slideInMs: number
  slideOutMs: number
  bodyExitMs: number
  receiptTextMs: number
  receiptTextDelayMs: number
  undoMs: number
  undoFadeMs: number
  demoResetMs: number
  destructiveArmMs: number
  ringFlashMs: number
  ringFadeMs: number
  touchResumeMs: number
  strikeMs: number
  expandMs: number
  headIconPx: number
  defaultAutoSeconds: number
  cardMaxW: number
  cardRadius: number
  receiptH: number
  receiptIconPx: number
  ringInset: number
  ringRadius: number
  ringStroke: number
  visibleSteps: number
  bodySwapY: number
  cardResizeMs: number
  bodySwapMs: number
  bodySwapExitMs: number
  layoutStiffness: number
  layoutDamping: number
  thumbStiffness: number
  thumbDamping: number
  lightMs: number
}

export const CHECKPOINT_TUNING: CheckpointTuning = {
  hoverMs: 150,
  stateMs: 200,
  slideInMs: 200,
  slideOutMs: 150,
  bodyExitMs: 120,
  receiptTextMs: 200,
  receiptTextDelayMs: 68,
  undoMs: 5000,
  undoFadeMs: 150,
  // Equals undoMs so the undo window closes before the demo card comes back.
  demoResetMs: 5000,
  destructiveArmMs: 3000,
  ringFlashMs: 128,
  ringFadeMs: 200,
  touchResumeMs: 1500,
  strikeMs: 200,
  expandMs: 280,
  headIconPx: 16,
  defaultAutoSeconds: 12,
  cardMaxW: 480,
  cardRadius: 24,
  receiptH: 40,
  receiptIconPx: 16,
  ringInset: 4,
  ringRadius: 20,
  ringStroke: 2,
  visibleSteps: 5,
  bodySwapY: 6,
  cardResizeMs: 280,
  bodySwapMs: 280,
  bodySwapExitMs: 200,
  layoutStiffness: 696,
  layoutDamping: 53,
  thumbStiffness: 696,
  thumbDamping: 53,
  lightMs: 600,
}

const QUESTION_KEY_MAX = 3

const EASE = [0.23, 1, 0.32, 1] as const

const layoutSpring = (t: CheckpointTuning) => ({
  type: 'spring' as const,
  stiffness: t.layoutStiffness,
  damping: t.layoutDamping,
  mass: 1,
})

const thumbSpring = (t: CheckpointTuning) => ({
  type: 'spring' as const,
  stiffness: t.thumbStiffness,
  damping: t.thumbDamping,
})

const COARSE_POINTER = '(pointer: coarse)'

function subscribeCoarsePointer(onChange: () => void) {
  if (typeof window === 'undefined' || !window.matchMedia) return () => {}
  const mq = window.matchMedia(COARSE_POINTER)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

function getCoarsePointer() {
  if (typeof window === 'undefined' || !window.matchMedia) return false
  return window.matchMedia(COARSE_POINTER).matches
}

function getCoarsePointerServer() {
  return false
}

// Reduced motion as an external store too: no hydration mismatch on the first paint.
const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'

function subscribeReduced(onChange: () => void) {
  if (typeof window === 'undefined' || !window.matchMedia) return () => {}
  const mq = window.matchMedia(REDUCED_QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

function getReduced() {
  return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}

function getReducedServer() {
  return false
}

type Mode = 'question' | 'command' | 'plan'

type Risk = 'read' | 'writes' | 'destructive'

type ApproveReason = 'user' | 'timeout'

type RejectReason = 'user' | 'skip'

type Question = {
  prompt: string
  options: string[]
  allowCustom?: boolean
}

type CheckpointPayload =
  | { mode: 'question'; answers: { prompt: string; answer: string }[] }
  | { mode: 'command'; command: string; cwd: string }
  | { mode: 'plan'; steps: { text: string; kept: boolean }[] }

type AgentCheckpointProps = {
  mode?: Mode
  title?: string
  questions?: Question[]
  command?: string
  cwd?: string
  risk?: Risk
  why?: string
  steps?: string[]
  autoApproveSeconds?: number
  labels?: { approve?: string; reject?: string }
  onApprove?: (payload: CheckpointPayload & { reason: ApproveReason }) => void
  onReject?: (reason: RejectReason) => void
  onUndo?: () => void
  className?: string
  tuning?: Partial<CheckpointTuning>
}

const DEFAULT_TITLE = 'Needs your OK'

const DEFAULT_COMMAND = 'npm run build -- --filter=web'

const DEFAULT_CWD = '~/aicanvas'

const DEFAULT_WHY = 'Verifies the change compiles before I open the PR.'

const DEFAULT_QUESTIONS: Question[] = [
  { prompt: 'Which package manager?', options: ['npm', 'pnpm', 'bun'] },
  { prompt: 'Run tests after install?', options: ['Yes', 'No'] },
]

const DEFAULT_STEPS = [
  'Read the failing test',
  'Reproduce locally',
  'Patch the date parser',
  'Add a regression test',
  'Run the suite',
  'Open the PR',
]

const NO_QUESTIONS: Question[] = []

const NO_STEPS: string[] = []

const MODES: { id: Mode; label: string }[] = [
  { id: 'question', label: 'Question' },
  { id: 'command', label: 'Command' },
  { id: 'plan', label: 'Plan' },
]

const THEME_VARS = [
  '[--ck-page:#EEEEF3] dark:[--ck-page:#0E0E0F]',
  '[--ck-panel:#F4F4FA] dark:[--ck-panel:#1B1B1C]',
  '[--ck-well:#EEEEF3] dark:[--ck-well:#0E0E0F]',
  '[--ck-raised:#DFDFE3] dark:[--ck-raised:#2D2D2E]',
  '[--ck-raised-hover:#CACACD] dark:[--ck-raised-hover:#373738]',
  '[--ck-lift:#FFFFFF] dark:[--ck-lift:#373738]',
  '[--ck-border:#DFDFE3] dark:[--ck-border:#2D2D2E]',
  '[--ck-border-strong:#CACACD] dark:[--ck-border-strong:#373738]',
  '[--ck-hairline:#DFDFE3] dark:[--ck-hairline:#2D2D2E]',
  '[--ck-rim:#CACACD] dark:[--ck-rim:#F4F4FA2E]',
  '[--ck-text:#1B1B1C] dark:[--ck-text:#F4F4FA]',
  '[--ck-body:#373738] dark:[--ck-body:#CACACD]',
  '[--ck-muted:#575759] dark:[--ck-muted:#9B9B9E]',
  '[--ck-faint:#7B7B7D]',
  '[--ck-selection:#A0A0A233] dark:[--ck-selection:#5E5E6333]',
  '[--ck-success:#047857] dark:[--ck-success:#34D399]',
  '[--ck-ai:#0369A1] dark:[--ck-ai:#7DD3FC]',
  '[--ck-attention:#B45309] dark:[--ck-attention:#FFB574]',
  '[--ck-red:#C8262C] dark:[--ck-red:#E5484D]',
  '[--ck-new:#6D28D9] dark:[--ck-new:#B79CFF]',
  '[--ck-primary:#1B1B1C] dark:[--ck-primary:#F4F4FA]',
  '[--ck-primary-hover:#373738] dark:[--ck-primary-hover:#FFFFFF]',
  '[--ck-on-primary:#F4F4FA] dark:[--ck-on-primary:#0E0E0F]',
  '[--ck-focus:#1B1B1C99] dark:[--ck-focus:#F4F4FA99]',
  '[--ck-focus-gap:#F4F4FA] dark:[--ck-focus-gap:#1B1B1C]',
  '[--ck-dot:#1B1B1C] dark:[--ck-dot:#F4F4FA]',
  '[--ck-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--ck-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
  '[--ck-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--ck-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
].join(' ')

const FOCUS =
  'outline-none focus-visible:ring-2 focus-visible:ring-(color:--ck-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--ck-focus-gap)'

// Touch reach via pseudo-element: buttons 40px, chips 48px, step badges 44px.
const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"

const HIT_BUTTON = `${HIT} pointer-coarse:after:-inset-1`

const HIT_CHIP = `${HIT} pointer-coarse:after:-inset-2`

const HIT_BADGE = `${HIT} pointer-coarse:after:-inset-2.5`

// 18px line + py-1 = 26px, and 26 + 2x9 = 44.
const HIT_TEXT = `${HIT} pointer-coarse:after:-inset-[9px]`

// Undo lives in the 40px receipt row under overflow-hidden, so 40 is its ceiling.
const HIT_UNDO = `${HIT} pointer-coarse:after:-inset-y-1`

const BTN = `${HIT_BUTTON} ${FOCUS} inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] leading-4 font-semibold transition-[background-color,color,border-color] duration-150`

const BTN_SECONDARY = `${BTN} border border-(color:--ck-border-strong) text-(color:--ck-text) hover:bg-(--ck-raised)`

const TITLE = 'min-w-0 truncate text-[15px] leading-6 font-semibold text-(color:--ck-text)'

const META = 'shrink-0 text-[12px] leading-4 tabular-nums text-(color:--ck-muted)'

const MONO = 'font-mono text-[12px] leading-[18px]'

type Tone = 'neutral' | 'attention' | 'success' | 'red'

const TONE_VAR: Record<Tone, string> = {
  neutral: 'var(--ck-dot)',
  attention: 'var(--ck-attention)',
  success: 'var(--ck-success)',
  red: 'var(--ck-red)',
}

const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, attention: 14, success: 10, red: 12 }

function timeStamp() {
  const d = new Date()
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}

/**
 * @typedef {object} CheckpointQuestion
 * @property {string} prompt The question shown in 14px 600.
 * @property {string[]} options Up to three option chips; keys 1 to 3 pick them.
 * @property {boolean} [allowCustom=true] Adds a "Something else" chip that reveals a text input.
 */

/**
 * @typedef {object} AgentCheckpointProps
 * @property {'question'|'command'|'plan'} [mode] Which decision the card asks for. Left out, it follows the data: questions, then steps, else command. With no data props at all the card plays its demo, with a mode switch above it.
 * @property {string} [title] Header title next to the fingerprint mark.
 * @property {CheckpointQuestion[]} [questions] Questions asked one at a time in question mode.
 * @property {string} [command] Shell command shown in command mode; editable inline, the edited text is what gets approved. A new value starts a new request.
 * @property {string} [cwd] Working directory shown on the first line of the command box.
 * @property {'read'|'writes'|'destructive'} [risk] Risk grade chip; destructive turns the approve button red and requires two presses.
 * @property {string} [why] One-sentence reason shown under the command.
 * @property {string[]} [steps] Plan steps; each can be struck out before confirming.
 * @property {number} [autoApproveSeconds] Draws a ring around the approve button that approves with reason 'timeout' when it completes; pauses while the card is hovered or focused. Left out or 0, nothing auto-approves; ignored when risk is destructive.
 * @property {{approve?: string, reject?: string}} [labels] Overrides the approve and reject button labels in every mode.
 * @property {(payload: CheckpointPayload & {reason: 'user'|'timeout'}) => void} [onApprove] Receives exactly what the user agreed to: the edited command, the kept steps, or the answers.
 * @property {(reason: 'user'|'skip') => void} [onReject] Called on reject (or skip in question mode). Passing onApprove, onReject or onUndo disables the demo auto-reset and demo auto-approve.
 * @property {() => void} [onUndo] Called when the user undoes a decision. Left out in host use, no Undo is offered.
 * @property {string} [className] Extra classes merged onto the outermost root element.
 * @property {Partial<CheckpointTuning>} [tuning] Overrides any number in CHECKPOINT_TUNING (timings, sizes, springs); keys left out keep their defaults.
 */
```

## Related

- [Agent Status](https://aicanvas.me/components/agent-status.md): A status pill that says what the agent is doing, whose move it is, time and cost, and opens to the step history and token bill.
- [Agent Composer](https://aicanvas.me/components/agent-composer.md): The message input an agent sits behind: a textarea that grows to six rows, inline slash commands, @file mentions as removable chips, a drag-and-drop attachment row, a model picker with each provider's logo, and a round send button whose glyph becomes a pulsing stop square while the agent works.
- [Thinking Trace](https://aicanvas.me/components/reasoning-trace.md): A single steady line shows what an agent is thinking right now, then settles into how long it thought, with every step one click away.
- [Permission Request](https://aicanvas.me/components/permission-scope.md): A permission card where an agent asks for access and you allow it once, for this chat or always for a pattern, then revoke it from the receipt.
- [Code Block](https://aicanvas.me/components/code-panel.md): Code an agent just wrote streams in line by line, then numbered markers tie its notes to the exact lines, with Copy and Apply.
