Review, annotate, and approve Claude Code plans in a local web UI.

caretcaret is a plugin that replaces the terminal plan-approval prompt with a local web UI. When your agent presents a plan, caret opens it in your browser so you can read it as rendered HTML, annotate passages inline (Google-Docs style), and approve or request changes. Your feedback flows straight back to the agent. A single local daemon is shared across concurrent sessions, so several in-flight plans can be reviewed from one browser tab via a switcher.
Want to develop caret rather than use it? Start with CONTRIBUTING.md.

bunx --no-cache @macintacos/caret@latest install
[!NOTE]
caretsupports macOS and Linux, where the review UI can run as a long-lived login service. Windows is best-effort and runscareton demand only. Seedoc/CONFIGURING.mdfor what differs on each platform and what to fall back on.
Assuming that you installed it as a long-lived service (the default), you can navigate to <http://caret.localhost:42718> to see the caret UI.
After installing:
/caret:demo. It presents a short demo plan that points at files in the repo you run it from, so you can exercise the whole flow before a real one arrives./caret:plan <what you want done> to plan a real change and review it in caret before the agent implements it.caret yourselfIf you'd rather caret not start at login, answer I'll run it myself when caret install asks. That removes the service if one is registered. Then, whenever you want the review UI up, run:
bunx @macintacos/caret@latest serve
This will keep the UI up at http://caret.localhost:42718 until the process is terminated.
Both are the install command with one flag:
bunx --no-cache @macintacos/caret@latest install --refresh # update
bunx --no-cache @macintacos/caret@latest install --uninstall # remove
--refresh restarts the caret service on the newest caret installed, never older than the one you ran; caret keeps a copy of it under ~/.local/state/caret/roots/.
caret's UI is designed to check for updates, at most once a day. When a newer caret is out, the review UI says so once: a toast on load that opens What's new (the skipped release notes or trunk commits), a mark on the settings button, and a Settings → Updates pane naming the version and the exact command to take it.
Turn the check off from that same pane, or by hand in config.toml:
[updates]
check = false
caret supports OpenCode v1 1.3.4 or later. See the OpenCode adapter for the by-hand equivalents, for pinning a version in OpenCode's plugin array (plugin on v1, plugins on v2), and for what each agent's install touches; the Claude Code adapter covers the hooks caret registers there.
caretWhenever your agent presents a plan, caret should intercept it and opens the plan in your browser instead of the terminal prompt. There you:
[!TIP] You don't have to wait to be intercepted:
/caret:plan <what you want done>asks the agent for a plan and routes it straight to the review UI. In OpenCode it runs on theplanagent, so switch tobuildto implement once you approve. A skill of your own can do the same with the plan-review toolcaretgives both agents —review_planin Claude Code (from the plugin's MCP server) andcaret_review_planin OpenCode. The tool is for plans only; seedoc/ARCHITECTURE.md.
caret runs with sensible defaults and needs no configuration. To tune it — the daemon port, the review timeout, the log level — it reads an optional config.toml and CARET_* environment variables. Every key, every variable, and their defaults are in doc/CONFIGURING.md. To open the review UI from another device, such as a phone or a laptop, see Reaching caret from another device. Only the machine that serves the review UI runs caret as a service; the other devices need only a browser and the login link.
doc/README.md maps the doc/ directory — start there and it routes you to the reference page that answers your question.
Two more live at the repo root:
caret locally: setup, the mise workflow, and where tests live./caret:doctor runs caret doctor: a one-shot, read-only check of your install — what's wrong, how to fix it, and the state it read that from. Always redacted, and it never contains plan, prompt, or feedback bodies, nor any log message, error or stack text.caret doctor --bundle archives the raw logs and review records when a maintainer needs them. That archive is not redacted, so caret asks before writing it.MIT — see LICENSE. Vendored third-party code and assets are itemized, with their licenses, in THIRD_PARTY_LICENSES.md.
mod/hooks/band.tsx 85 lines1// Claude Code's plan-waiting band. caret's CLI owns the link — host, port, login token —
2// and the band's on/off setting, so nothing here repeats caret's rules.
3import type { Hook, Register } from "claude-code";
4import { atom, read, update } from "claude-code";
5
6type Shown = { session: string; url: string; owner: string };
7type HookDollar = Parameters<Hook<"tool.call">>[0];
8
9const band = atom({ plugin: "caret", key: "band" } as const, null as Shown | null);
10
11// $.process.run takes no shell, and Windows cannot exec bin/caret's shebang. The timeout
12// must outlast band-link's review poll (src/commands/band-link.ts) plus its boot.
13const caret = ($: HookDollar, ...args: string[]) =>
14 $.process.run(["bash", `${$.plugin.root}/bin/caret`, ...args], { timeoutMs: 15_000 });
15
16// A band that breaks must never hold up the review: pass the event on.
17const passThrough = <E, R>(_: unknown, e: E, next: (e: E) => R) => next(e);
18
19const bandWhile = async <T,>(
20 $: HookDollar,
21 session: string,
22 signal: AbortSignal,
23 review: () => Promise<T>,
24): Promise<T> => {
25 // Waits overlap (a backgrounded review_plan, then another call): each clears only its own band.
26 const owner = crypto.randomUUID();
27 let settled = false;
28 const clear = async () => {
29 settled = true;
30 await update($, band, (cur) => (cur?.owner === owner ? null : cur));
31 };
32 // An interrupt aborts the signal; nothing promises it settles `review`.
33 const onAbort = () => void clear();
34 signal.addEventListener("abort", onAbort, { once: true });
35 // Not awaited: the review never waits on the band.
36 void caret($, "band-link", "--session", session).then(
37 async ({ stdout }) => {
38 const url = stdout.trim();
39 if (url && !settled) await update($, band, () => ({ session, url, owner }));
40 },
41 () => {},
42 );
43 try {
44 return await review();
45 } finally {
46 signal.removeEventListener("abort", onAbort);
47 await clear();
48 }
49};
50
51export const register: Register = (on) => {
52 on("tool.call", { tool: "mcp__plugin_caret_caret__review_plan" }, async ($, e, next) =>
53 // The `mcp-` prefix is src/commands/mcp.ts's session id; keep the two in sync.
54 bandWhile($, `mcp-${await $.session.id()}`, next.signal, () => next(e)),
55 ).catch(passThrough);
56 // caret's own PermissionRequest hook sits beneath this one and holds the plan until a decision.
57 on("classic.PermissionRequest", { tool_name: "ExitPlanMode" }, ($, e, next) =>
58 bandWhile($, e.session_id, next.signal, () => next(e)),
59 ).catch(passThrough);
60 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
61 const shown = await read($, band);
62 if (e.props.hasSurvey || shown === null) return next(e);
63 const { Box, Button, Link, Text } = $.ui.resolve(e);
64 return (
65 <Box gap={1}>
66 <Link href={shown.url}>
67 <Text bold color="inverseText" backgroundColor="warning">
68 {" 🥕 CARET PLAN WAITING FOR YOUR REVIEW "}
69 </Text>
70 </Link>
71 {/* A bare digit in an empty prompt presses a band Button. */}
72 <Button
73 hotkey="1"
74 plain
75 onPress={() =>
76 void caret($, "band-link", "--session", shown.session, "--open").catch(() => {})
77 }
78 >
79 open in browser
80 </Button>
81 </Box>
82 );
83 });
84};
85mod/band.d.ts 6 lines1declare module "claude-code" {
2 interface PluginState {
3 caret: { band: { session: string; url: string; owner: string } | null };
4 }
5}
6