Anything camy's agent wants to do that carries real risk — running a command on your machine, writing a file, sending a message, publishing something — pauses first. That pause is a checkpoint. You clear it by approving, denying, or answering it, from wherever you happen to be: the terminal that hit it, the full-screen app, the web, or a different terminal entirely.
camy approvals # what's waiting
camy approvals show ID... # the full checkpoint, before you decide
camy approvals approve ID... # approve
camy approvals deny ID... # deny
camy approvals answer ID TEXT... # answer a question/choice/formThose five commands clear a checkpoint the same way no matter where it surfaced. Each one's reference page carries its full flag list.
A checkpoint pauses one of four kinds of thing:
- an approval — a plain yes/no, most often a local command or file write
- a question — free text
- a choice — pick from a list (one or several, depending on the card), or type your own answer when the card allows it
- a form — a few fields, filled in one at a time
Nothing runs on a timeout. A checkpoint nobody decides is never approved and
never denied — it stays pending until someone decides it, or until the
server's own expires_at passes and it is recorded as expired. Stopping the
turn it belongs to cancels it too. When the turn was paused on approvals,
the camy app says how many it cancelled.
One carve-out: a local command the CLI can itself verify is read-only and
confined to your project root, or one covered by a grant you added with
camy local trust or by the exact grant
a records, is answered automatically on a turn camy.ai has marked eligible
for auto-answering (a verified read may also arrive with no card at all);
otherwise it gets a card like any other — see
The local bridge.
ID accepts the typed short id camy approvals prints in its list
(ap_789a), a bare prefix of at least 4 hex characters of the checkpoint
id, or the full id. The list lengthens a short id past four characters only
when two pending checkpoints would otherwise share it. --ids=hex prints the
older untyped 8-character form instead, for this release.
- A prefix that matches nothing is passed straight to the API by
approve,denyandanswer, which report it as not found;showinstead fails locally with "no pending checkpoint …". - A prefix that matches more than one pending checkpoint is a usage error asking for a longer one.
camy approvals
camy approvals --web
camy approvals show ap_789acamy approvals groups what is waiting by
what the decision is — NEEDS AN ANSWER, WANTS TO RUN SOMETHING, WANTS TO
SEND SOMETHING — one line per checkpoint: a typed short id (ap_789a),
what it wants in plain words, the detail, where the bite would land and how
bad (⌂ this machine · low · reversible,
✉ leaves camy · high · not undoable), and how long ago it arrived.
Repeats of the same question collapse into one row with a count and a date
range, and the list ends in the verbs that apply to its rows.
With nothing pending it prints "no approvals waiting — the leash is slack"
and exits 0.
An approval you already gave can stay on camy.ai's list while its tool
runs. Nothing is left to decide on it, so the list leaves it out and its
header counts it instead (1 approved, still running); when nothing else is
pending, the empty list says so. camy.ai sends at most 200 decisions
at once. When the list comes back full, the count in its header carries a
+ (200+ waiting), and a note under the list says more may be waiting and
that camy approvals --web has every one.
The list also shows other decisions waiting on you that are not
checkpoints, such as a card or an agent run asking to go ahead. They sit
under NEEDS AN ANSWER with — where the id would be, because show,
approve, deny, and answer act only on checkpoints.
A checkout hold, the card Camy raises before it pays on a site ("Camy needs your OK before paying"), names the amount and the merchant in its row's detail when camy.ai sends them. The amount comes first, so a column cut short loses the merchant name before the amount:
49.99 USD · Acme (acme.com)
Two holds with different totals never collapse into one row.
--web opens For You at camy.ai, where approvals wait, in your browser
instead of printing the list.
camy approvals show prints the
checkpoint in full — the complete prompt, not the truncated summary from the
list — so you can read exactly what you'd be approving. Read a checkpoint
you don't recognize with show rather than deciding from the list line
alone.
camy approvals approve a1b2c3d4
camy approvals deny a1b2c3d4
camy approvals answer a1b2c3d4 use the staging databaseapprove,
deny, and
show take one or more ids:
camy approvals approve ap_789a ap_2c40Each id is tried in turn, and one that fails doesn't stop the rest; the
command then exits on the worst code any of them produced.
answer takes exactly one id plus one
or more words of free text, joined with spaces and sent as the answer.
On a choice checkpoint, answer reads your text against the card's
options. A number (2, or 1,3 on a card that takes several), an option's
id, or its label, in any case, is sent as that pick. Other text is sent as a
free-text answer, but only when the card takes one. A number that isn't one
of the options, two picks on a card that takes one, or free text on a card
that takes none exits 2 before anything is sent, and the error lists the
options:
camy approvals answer ap_789a 2You fill a form's fields one at a time on the
approval card: live in a turn, or opened from the
full-screen app's /approvals picker. camy approvals answer sends your
text as a single answer.
approve acts only on a checkpoint that asks for a yes or no. On one that
wants an answer, including an agent's question that camy.ai lists as an
approval, it exits 2 and points you at answer. An agent run's escalation is
decided on camy.ai, not from here: approve and deny exit 2 before sending
anything and point you at For You, and show offers camy approvals --web
in place of the verbs. When approve or deny meets a checkpoint that
stands for another kind of decision, such as a phone action, and camy.ai
can't decide it from here, it exits 1 with the same pointer; nothing was
decided.
approve, deny, and answer print a success line only when camy.ai took
the decision. Otherwise they print what actually happened, with no success
line, and exit 1: for approve and answer, the checkpoint had already
expired or been cancelled; someone had already denied, answered, or approved
it, in the app, on the web, or in another terminal, so yours changed
nothing; or it was approved but the write it released didn't run. A
decision that lost the race to another surface says so on stderr:
camy: that checkpoint was already decided somewhere else — your approval changed nothing
camy approvals lists what's still waiting
Denying a checkpoint that had already expired or been cancelled still succeeds, since nothing runs either way. When the checkpoint still waits on an approval, the command exits 4 and names the checkpoint it waits on when camy.ai says which, the same handle a fail-closed turn gives. When the response was recorded but the paused turn couldn't be restarted, the success line stands and a warning follows it on stderr.
approve prints ✓ approved ap_789a — the turn resumes only when a paused
turn continues, and ✓ approved ap_789a otherwise, such as for a connector
write. deny prints
○ rejected ap_789a — nothing happens; the agent moves on, shortened to
— nothing happens when no paused turn continues.
The full-screen app's /approvals picker reads
camy.ai's answer the same way. A question there gets an answer box, never a
yes, and a bulk approve skips questions and says so. A checkpoint settled
somewhere else leaves the list with camy.ai's account as its note, and one
camy.ai still holds open stays.
camy approvals deny always exits 0 on success: it succeeded at telling the
agent no. Exit code 8 (checkpoint denied) is a different signal — within the
approvals surface it comes only from a live turn whose approval card was
denied: answered no at its own prompt, or denied somewhere else while that
prompt was up. See Exit codes for every command
that can return 8.
approve and answer both take --wait:
camy approvals approve a1b2c3d4 --wait
camy approvals answer a1b2c3d4 "use option B" --wait --chat 9f8e7d6cIt stays attached after responding and streams the resumed turn to your
terminal, instead of just confirming the checkpoint was cleared. --wait
follows one turn, so approve --wait takes a single id; naming several is a
usage error (exit 2).
--wait attaches to the chat named by --chat ID. Without it, it attaches
to the chat the checkpoint belongs to (for a delegated agent's checkpoint,
the chat that delegated it), and when camy can't read that, to the last chat
you were in on this profile. With none available, it prints a note and exits
0 without streaming anything.
An approval that continues no paused turn, such as a connector write, has
nothing to stream: approve --wait prints
nothing to stream — this decision doesn't resume a turn and exits 0.
Attaching takes a moment. The resume is spawned on the server asynchronously, so the CLI waits up to 120 seconds before it trusts that the turn is actually idle rather than just not-yet-resumed.
It then keeps waiting on a "paused" state, up to a total of ten minutes from
the approve — the turn may be waiting on a different checkpoint, possibly
one being decided in your other open session, and it says so once. Budget up
to about ten minutes worst case before --wait either finishes or gives up.
If the checkpoint's own outcome reaches a terminal state — completed,
failed, rejected, expired, cancelled — --wait reports it and exits
accordingly: 0 for completed, 1 otherwise. It only ever reports an outcome
it can tie back to the approval it's actually waiting on — a different
step's result landing on the same chat around the same time is never read
as this one finishing.
If the turn never resumes within that window, --wait exits 1. When it
never found any result it could tie to this approval, it says the turn did
not resume; when it saw one but couldn't tell whether it belonged to this
approval or some other step, it says that instead of guessing. Either way
the approval was not undone; only this CLI process gave up watching. Use
camy chats show ID to see what actually
happened.
When a checkpoint pauses a chat you're watching live — in a terminal or the
full-screen app — it draws as a rounded card in the amber the CLI reserves for the leash.
The header row names what's being asked — APPROVAL, QUESTION, CHOICE,
or FORM, with the tool or action in plain words — and carries the
checkpoint's id at the right; a divider, the body, then labelled rows:
where the bite lands (lane), how bad (risk), where it came from
(from), and when it was asked. The question is never inside the frame:
it is its own line beneath the card, with its keys, and
[y/N/o(pen web)] is unchanged.
The body shows the summary, capped at a few lines, with a
"… +N more — o opens the full card" marker when it runs long. For a local
command it shows the verbatim command instead, wrapped but never
truncated, so nothing risky can hide past a cutoff. A command card can also
state how this machine confines commands, the same sentence
camy --version prints, but only once per connection: the first command
card you actually see may not carry it if an earlier one was answered for
you, so camy --version is the dependable place to read it. A local file
write shows the verbatim path the same way, then what the write would actually change:
when the file already exists, a diff against the copy on disk,
capped with an "o opens the full card" note if it runs long, or a line
saying the file already has these contents, or why no diff could be read.
When the file is new there's nothing to diff against, so the card shows its
line count and size instead. Whenever no diff is shown — a new file, or a
diff that couldn't be read — a preview of the proposed content follows.
A local edit, which changes part of an existing file, gets the same body:
the path, then the diff the edit would make against the copy on disk, under
the same cap. When that diff can't be computed, for example because a
search block no longer matches the file, the card says why where it can,
and shows the proposed search-and-replace blocks instead. A write or edit
to a file that runs on its own, such as a git hook, a CI workflow,
package.json, or a Makefile, carries a warning line under the path.
What answers it depends on the kind:
| Kind | Prompt | What counts |
|---|---|---|
| Approval | approve? [y/N/o(pen web)] |
y/yes approves; o prints a link to the checkpoint's chat at camy.ai, or to For You when it has no chat (a clickable hyperlink where the terminal supports one), and asks again; anything else, including nothing typed, denies. |
Approval, a local run_command card |
y run · N deny · a always · o web |
as above, plus a — see below. |
| Approval, a connector write | approve? [y/N/a(lways for this tool)/o(pen web)] |
as above, plus a: it approves and tells Camy to stop asking before that tool runs in that connection, which you can undo in Connections. A destructive tool's card doesn't offer a, and a typed a there denies. |
| Question | answer (empty rejects): |
anything typed answers; nothing typed rejects. |
| Choice | pick (1 or 1,3) or type — empty rejects: |
a number or comma-separated numbers picks by position, and an option's id or label picks it too; other text is sent as free text; nothing typed rejects. The prompt offers only what the card takes: pick (1) on a card that takes one pick, and no or type on one that takes no free text. A number that isn't an option, two picks on a one-pick card, or text the card can't take is refused with the options listed, and the card asks again. |
| Form | one prompt per field | a required field re-prompts if left blank; an optional field may be left blank. |
Prompts read /dev/tty directly, never stdin — piping input at a
camy chat turn (echo y | camy chat "...") can
never answer a checkpoint, by design. Every piece of server text shown on a
card — the summary, choice labels, field descriptions — is sanitized before
it reaches your terminal.
Your answer is judged by what camy.ai says came of it. The ✓ line appears
only for an answer camy.ai took, and says — the turn resumes only when a
paused turn does. When the checkpoint was settled somewhere else while its
card was up, for example approved on the web before you typed n, camy
prints camy.ai's account instead and the turn keeps streaming:
too late — it was already approved and is running; your denial changed nothing
a approves the checkpoint and grants this exact command for this project,
and records a dated family grant beside it for commands of the same shape —
see Trust. It is offered only when the command
isn't destructive or content-unvetted. Where it isn't offered, a is not a
no-op:
- On a destructive command it denies the checkpoint outright.
- On a content-unvetted one — a script run through an interpreter — it approves this one run without granting anything.
- On a connector write it does what the connector row above says.
- On any other card that isn't a local
run_command, it falls through to the same web-link-and-reprompt aso.
Some cards carry an extra block: an offer for a standing build grant, a single acceptance that would cover every further action in a class of tools for the rest of the build, with no card for any of them. When a card carries one, it lists exactly what's on offer:
- which actions the grant would cover, named tool by tool
- which read-only checks never need approval either way, grant or no grant
- that anything else — publishing, deploying, deleting a file, paying — still gets its own card regardless
Answering from the CLI never grants the standing authorization: y here
approves only the one action in front of you. The standing grant is offered
only on Camy's own approval card, in Camy's app or at camy.ai, not on any
card camy draws in your terminal.
Outside a real interactive session a checkpoint is never prompted: it's left
pending, and the command that hit it fails closed. That covers --no-input,
machine mode (--json/--jq/--template, even on a real TTY), and a
process with no controlling terminal.
camy --no-input chat "clean up the build directory"That exits 4, with the checkpoint id on stderr in human mode and in the
checkpoint_id field of the JSON error object in machine mode. This is not
a failure in the ordinary sense — it's the documented way a risky action
defers to a human. Clear it out of band:
camy approvals approve <checkpoint id>Add --wait to see the full turn finish in the same process instead of just
clearing the checkpoint:
camy approvals approve <checkpoint id> --waitA temporary chat (--temp) can never hold an approval at all. Hitting a
checkpoint there still exits 4, but with no checkpoint id, since a temp chat
has nothing for camy approvals to attach to later.
Every checkpoint prompt — the single-line approval/question/choice line and
each form field — waits 120 seconds on /dev/tty. A timeout is not a
decision: it leaves the checkpoint pending, never an implicit approve and
never an implicit deny. No flag turns this wait into an approval, and there
is none that approves everything automatically.
A checkpoint that pauses a local action — a command, file write, or file edit on your machine — can only actually run on a machine that itself witnessed the approval. That's true even when the checkpoint is cleared somewhere else:
- Approving or answering a local checkpoint with
camy approvalsand no--waitclears the checkpoint, but nothing executes in that one-shot process. It prints a note that the command runs in your other open camy session instead — the camy app, meaningcamywith no arguments for the full-screen surface orcamy --inlinefor the classic scrollback one — wherever that session's socket is still live. Add--waitto run it right there instead. - An approval made from the web or from a different device works the same way: it clears the checkpoint, but a local command, write, or edit still needs a live camy session on the machine it targets.
- When the camy app picks the resumed turn back up, it shows the card again
— "approved elsewhere · run it here?" — rather than executing silently.
Only a session that itself witnessed a decision (a keystroke on a card, a
trusted auto-run, a read-only command the CLI verified itself,
approve --wait, or a live re-confirm like this one) is allowed to run a local command, write, or edit, so you confirm it once more, there. - Any other session — the REPL,
camy chat attach, a one-shotcamy chat, or anything headless — has no way to draw that re-confirmation, so it refuses the call outright and says so.
This is deliberate: a server telling a CLI process to execute something is never enough on its own.
A JSON array, one object per pending checkpoint or other decision, with repeats never collapsed. The shape is deliberately scrubbed — it drops the server's internal replay data — and carries these fields:
{
"checkpoint_id": "...",
"family": "checkpoint",
"subject_id": "...",
"chat_id": "...",
"kind": "approval",
"tool_name": "local__run_command",
"prompt": "...",
"parameters": {"argv": ["npm", "test"], "cwd": "."},
"status": "pending",
"created_at": "2026-09-03T12:00:00Z",
"expires_at": "2026-09-03T12:05:00Z"
}parameters holds the checkpoint's tool arguments as camy.ai stores them.
For a local command that includes its argv and cwd; for other kinds,
such as connector writes, it is the checkpoint's own argument envelope. A
checkpoint's family is "checkpoint" and its subject_id is its
checkpoint_id. A row that is a different kind of decision carries its own
family and subject_id, an empty checkpoint_id, and null chat_id,
tool_name, parameters, status and created_at; kind, prompt and
expires_at are filled from the decision. The checkpoint verbs can't act on
it, so filter on family before piping checkpoint_id into approve.
status is pending for a checkpoint still waiting on you, or executing
for an approval already given whose tool is still running; the human list
leaves executing rows out. A checkout hold's row also carries merchant and
amount when camy.ai sends them, and no other row has either key:
{
"merchant": "Acme (acme.com)",
"amount": {"value": "49.99", "currency": "USD"}
}The full, unfiltered server row for that one checkpoint. It is not the same shape as the list; don't assume the two match field for field.
camy approvals approve a1b2c3d4 --json{"ok": true, "checkpoint_id": "a1b2c3d4...", "action": "approve", "runs_locally": true}deny omits runs_locally — there's nothing to run — and reports
"action": "reject", the wire word for a denial, not deny. runs_locally
is true only for a local (local__) checkpoint: it is the field a script
checks to decide whether it also needs --wait, or a run on the machine
that holds the session, to see the command actually execute. The object
also carries resume_state and camy.ai's message whenever camy.ai reports
one: "pending" when the turn is resuming, "unconfirmed" when the
response was recorded but the turn couldn't be restarted. Only
"unconfirmed" prints the warning on stderr in human mode.
When camy.ai reports them, the object also carries status, the state
camy.ai settled the checkpoint in, and resumed, whether a paused turn
continues; resumed is false for a decision that continues no turn, such
as a connector write. already_resolved: true means another surface had
decided it first.
With two or more ids, approve, deny, and show print an array instead,
one object per id, each with the ref you typed, the full id when it
resolved, ok, and error when that id failed.
With --wait, the resumed turn streams as NDJSON, in the same event shapes
any camy chat / camy chat attach --json stream uses. When the turn
streams here, that stream is the whole output.
If instead the CLI finds the chat idle and the checkpoint resolved somewhere
else, it prints one extra object, on a single line so the whole output stays
NDJSON, and stops. A completed checkpoint prints this — ran_elsewhere is
always true on this path:
{"type": "done", "chat_id": "...", "checkpoint_id": "a1b2c3d4...", "ran_elsewhere": true}Any other terminal outcome prints this instead:
{"type": "error", "code": "checkpoint_rejected", "chat_id": "...", "checkpoint_id": "a1b2c3d4...", "message": "..."}code is checkpoint_ followed by the outcome: failed, rejected,
expired, or cancelled. checkpoint_uncorrelated is a fifth code in the
same object shape, but it isn't an outcome — it means --wait gave up
without ever being able to tell which result, if any, was this approval's.
An approval that continues no paused turn prints one object and stops, without attaching:
{"type": "done", "checkpoint_id": "a1b2c3d4...", "status": "approved", "resumed": false}When a turn under --json pauses on a checkpoint, in a
camy chat turn or one that --wait streams, the
checkpoint event carries what a script needs to decide without a second
camy approvals show:
{"type": "checkpoint", "id": "...", "kind": "approval", "summary": "...", "tool_name": "vm_exec", "risk_level": "high", "prompt": "...", "description": "Run: npm test", "expires_at": "2026-09-26T17:33:51Z"}Every key above is always present, as a string that may be empty.
description is the command or the arguments you'd be approving. A choice
checkpoint adds choices ([{id, label, description}]), and a form adds
fields ([{key, title, type, required}], with description and enum
when a field has them). Anything that looks like a secret is redacted from
the event's text.
A card camy.ai replays whose status is no longer pending, such as an
approval already given whose tool is still running, is never drawn and
never exits 4. The stream reports it once, and only for a card this process
didn't answer itself:
{"type": "checkpoint_replayed", "id": "...", "status": "executing"}executing means the tool hasn't finished; attach again for its outcome.
- The local bridge — what a local
run_command/write_file/edit_filecheckpoint actually authorizes, trust grants, and the destructive floor - Exit codes — the full frozen table, including 4 and 8
- Chat — where a live checkpoint card is drawn mid-turn
- Scripting with camy — the stdout/stderr and
--jsoncontract this document assumes