PricingSign inDownload

Blog / claude code

claude -p exited 0 but did nothing? Check what it was refused

A blocked tool call doesn't fail a claude -p run: the exit code is 0. Read permission_denials and is_error in the JSON result instead. Script included, tested.

On this page
  1. What the exit code tells you
  2. See what was refused
  3. A wrapper that fails loudly
  4. Let it finish what it was refused
  5. Max turns and budget
  6. When the yes is someone else's
  7. Frequently asked questions
  8. Why does claude -p exit 0 when a command was blocked?
  9. How do I check if a claude -p run failed?
  10. How do I see which commands Claude Code wanted to run?
  11. Can I approve a refused command and continue the same run?
  12. Try it with your team

Your nightly claude -p job ran, the log says it finished, the exit code is 0, and nothing changed. Most of the time the agent wanted to run a command, wasn't allowed to, and said so politely in its last message. From the outside that looks exactly like success.

We tested what claude -p returns in each case with Claude Code 2.1.318, and wrote a small wrapper that tells them apart.

What the exit code tells you#

What happenedExit codesubtypeis_error
Did the job0successfalse
A tool call was refused0successfalse
Hit --max-turns1error_max_turnstrue
API error (bad model, 404)1successtrue

Two things to take from it:

  • A refused tool call is not an error. The run completes normally, and the only trace is the agent's own sentence ("the rm notes.txt command needs your approval…") and a list in the JSON.
  • Don't check subtype alone. With a model name that doesn't exist, the run exited 1 with is_error: true, yet subtype still read success. Check is_error.

See what was refused#

Ask for JSON and read permission_denials:

bash
echo hello > notes.txt
claude -p "Delete notes.txt using rm." --output-format json > result.json
echo "exit: $?"
jq '{is_error, permission_denials}' result.json
exit: 0
json
{
  "is_error": false,
  "permission_denials": [
    {
      "tool_name": "Bash",
      "tool_input": { "command": "rm notes.txt", "description": "Delete notes.txt" }
    }
  ]
}

(We trimmed the tool_use_id field.) The file was still there.

A wrapper that fails loudly#

Save this as run-claude.sh. It prints the answer, exits 1 on an error and 2 when something was refused, and says which command it was:

bash
#!/bin/sh
# Usage: ./run-claude.sh "prompt" [more claude flags]
out=$(claude -p "$@" --output-format json < /dev/null)
code=$?
if [ -z "$out" ]; then echo "claude printed nothing (exit $code)" >&2; exit 1; fi
json() { printf '%s\n' "$out" | jq -r "$@"; }
json '.result // empty'
if [ "$code" -ne 0 ] || [ "$(json '.is_error')" = "true" ]; then
  echo "FAILED: $(json '[.subtype, .terminal_reason, (.api_error_status // empty | tostring)] | join(" ")')" >&2
  exit 1
fi
if [ "$(json '.permission_denials | length')" -gt 0 ]; then
  echo "BLOCKED: $(json '[.permission_denials[] | .tool_name + ": " + (.tool_input.command // .tool_input.file_path // "")] | join("; ")')" >&2
  exit 2
fi
bash
chmod +x run-claude.sh
./run-claude.sh "Delete notes.txt using rm."; echo "exit=$?"
I couldn't delete `notes.txt` because the `rm` command needs your approval and wasn't run. Approve it and I'll run it again, or run `rm notes.txt` yourself.
BLOCKED: Bash: ls -la notes.txt && rm notes.txt && ls notes.txt
exit=2

The agent's wording changes from run to run, and so can the exact command (this time it wrapped rm in two ls checks). The BLOCKED line shows you what it actually tried.

The other cases, as we ran them:

$ ./run-claude.sh "Reply with the single word ok."
ok                                            (exit 0)

$ ./run-claude.sh "List every file here, then read each one, then summarize." --max-turns 1
FAILED: error_max_turns max_turns             (exit 1)

$ ./run-claude.sh "Say ok." --model no-such-model
There's an issue with the selected model (no-such-model). It may not exist or you may not have access to it.
FAILED: success api_error 404                 (exit 1)

File edits are refused the same way. Without edit permission, a request to create a file comes back as a Write denial with the path:

$ ./run-claude.sh "Create hello.txt containing the word hi."
I couldn't create `hello.txt`. The write to `/home/you/project/hello.txt` was blocked because permission hasn't been granted.
BLOCKED: Write: /home/you/project/hello.txt

If a job is meant to change files, run it with --permission-mode acceptEdits, or allow the tools it needs with --allowedTools. If it isn't, a Write or Edit denial is a sign the prompt asked for more than you thought.

Three details in the script matter:

  • < /dev/null. Without it, a run with no piped input waits for stdin and prints Warning: no stdin data received in 3s, proceeding without it. In a script there is nothing to wait for. Our post on piped input that Claude ignored covers the other side of that warning.
  • printf, not echo. Under sh on many Linux systems, echo turns the \n inside the JSON into real line breaks and jq then fails to parse it. Our first version had exactly that bug; printf '%s\n' passes the text through unchanged.
  • The prompt goes first. Flags like --allowedTools take several values, so a prompt written after them is read as one more tool name. Keep the prompt right after -p.

Let it finish what it was refused#

Every result carries a session_id. Once you've decided the command is fine, resume that same session with just that command allowed:

bash
claude -p "Approved. Go ahead." --resume "$(jq -r .session_id result.json)" \
  --allowedTools 'Bash(rm notes.txt)'

It ran rm notes.txt and the file was gone. Allowing the exact command, rather than all of Bash(rm:*), keeps the next run as tight as this one.

If the same command is refused every night, it belongs in the job's allow list (or in .claude/settings.json). Our post on running Claude Code on a schedule shows where.

Max turns and budget#

--max-turns and --max-budget-usd stop a run that goes on too long, and both show up as is_error: true. Setting them is covered in limiting an unattended Claude Code run; the wrapper above already reports them.

When the yes is someone else's#

A refused command in a scheduled job usually waits until somebody reads the log. With Joxo, an agent's question reaches the person who can answer it on their phone or desktop, and the answer goes back into the session. See approving agent actions from your phone.

Frequently asked questions#

Why does claude -p exit 0 when a command was blocked?#

A refused tool call isn't an error: the agent carries on, explains that it needs approval, and the run completes normally. Use --output-format json and check that permission_denials is empty.

How do I check if a claude -p run failed?#

Use --output-format json and check is_error, plus the exit code. Don't rely on subtype alone: in our test an API error exited 1 with is_error true but subtype still "success".

How do I see which commands Claude Code wanted to run?#

Read permission_denials in the JSON result. Each entry has the tool name and its input, such as the Bash command.

Can I approve a refused command and continue the same run?#

Yes. Resume the session by its session_id with the command allowed: claude -p "Approved. Go ahead." --resume <id> --allowedTools 'Bash(rm notes.txt)'. Put the prompt before --allowedTools.