Headless mode: what changes when there's no one to ask
What a harness does when a tool needs an approval nobody is there to give — and why the exit code is the last thing an unattended run should be gated on.
The prompt that nobody answers
In interactive mode, by default, the operator is part of the loop, and there are many things the human does just during an ordinary run:
A tool call is waiting for approval, a developer checks the path and approves it
The agent runs a command and they see it's incorrect so kill the process
The agent asks which of the two authentication flows they meant to use, and the person responds
Those are not defined in configuration. It's the human who's there, having more impact than we assume.
Now imagine you remove this person. During an ordinary run on the same harness, those very moments will happen too, and the harness will handle them based on its default policy. This is not about the flag itself (which is a letter on most of the harnesses, or a subcommand on one), but who decides and what happens with the run after their decision.
The four answers to "there is nobody to ask"
Let's explore the space of possible outcomes in case of such a decision being negative, looking at five harnesses. I've requested an action that would require pre-approval to be performed from scratch in a directory on three of those, and observed their reaction; for the two remaining ones, I'll rely on their official docs.
There are four behaviours in total, all of them valid, each weighing different trade-offs:
The first one is the harness not proceeding with the tool call but completing the run anyway. That's what Claude Code does, outputting a message about not having permissions to perform the requested write action and about non-interactive sessions being unable to ask, and terminating normally. Antigravity has a similar posture — it soft-denies a tool that would require pre-approval if it can't get approval for it. It completes the run with exit code 0, writing a message to stderr.
The second behaviour is the harness not starting the run at all. This is what Cursor does — it refuses to perform an action in a non-trusted directory, returning an empty stdout and not changing anything on the disk, with exit code 1 and an stderr message about needing to pass the flag. Codex acts the same way for actions outside git repositories — it informs about the directory not being trusted and about lacking the skip-git-repo-check flag.
The third behaviour is the harness limiting the scope right from the start. When you run
codex exec, for example, it prints a header with its settings at the beginning of the run. I sawapproval: neverandsandbox: read-onlythere — a read-only sandbox being the documented default for the exec subcommand. So there's nothing to approve because nothing is allowed, and any requested write operation ended up being blocked by a read-only workspace.The fourth one is the harness allowing everything. Kimi's print mode operates under auto policy, which means it doesn't ask for a human's approval. Only static deny rules are in place, so everything else is permitted; the most permissive option, so the one to be cautious with as Kimi is also the only harness that has an empty sandbox cell in the reference table.
Two of these are not distinguishable by the exit code — both don't do anything and return 0.
Exit 0 is not "it worked"
So:
{
"subtype": "success",
"is_error": false,
"stop_reason": "end_turn",
"num_turns": 2,
"terminal_reason": "completed",
"permission_denials": [
{ "tool_name": "Write",
"tool_input": { "file_path": "/private/tmp/headless-check/cc-was-here.txt",
"content": "ok\n" } }
]
}Zero doesn't mean it's done. Claude Code's result record shows exit code 0, success subtype, is_error: false, and a permission_denials entry for the blocked write operation — but no file was created. Same for Codex — it returns zero with just one stdout line about the refusal, nothing written. Antigravity also says it will return zero for a soft-denied tool.
Zero is a success code, so "prevented" and "completed" look the same. So if you have a pipeline that checks only the exit status, both will be green.
That said, an exit code still conveys some information — non-zero usually means the harness was unable to run the task at all (incorrect flag, unknown model, untrusted directory, killed process), while a task failing during an otherwise successful run is a separate thing one level down.
So, again, make sure you assert on the artefact — that it's there, files are created, tests pass, PR is opened, commit landed on the branch. If there's no artefact, parse the structured output (which some harnesses provide and tell you exactly what they refused to do) instead of looking at the exit status.
The report is the only thing you get
But before we move on, let me emphasise that every harness can emit non-prose output, and this is the only way a run can be observed when it's unattended:
As text — either for the person or as part of a log
As JSON — a single envelope with the result and metadata at the end (identifiers, timestamps, figures for token or cost on some harnesses, denial record on some)
As stream JSON — line-separated JSON objects during the run so you can follow tool calls in real time instead of examining the summary after the run
The harnesses also give us some honesty signals — permission_denials in Claude Code's results, Antigravity's status field with values like SUCCESS and ERROR, or explicit turn.failed events in Codex's JSONL stream. We can check those to find out what happened instead of checking if the process terminated.
But just as a precaution, here's another finding from the same experiment — in an approval-blocked run on Claude Code, permission_denials got filled, but in another run where a deny rule blocked a read operation, the read was blocked and the model said so in the result text, while permission_denials was empty. So again, on this very harness and in this very version, there are two mechanisms — only one of them reports, so make sure you check the artefact as well.
What loads in a headless run isn't what you had
As I mentioned, headless runs happen in a different context than interactive ones. During an interactive run, it's shaped by the fact that a human is present:
They accept the trust dialog for the first time
Their personal settings are on the machine
The harness shows them the invalid settings file before starting
Claude Code's own help even says that the trust dialog is not shown in non-interactive mode, and that it silently ignores settings files with validation errors.
So during an unattended run on this harness, all I've seen was one line in stderr saying it has disregarded the project's allow entry because the workspace was never trusted. The run continued without it. Cursor, in contrast, would stop here.
In another run, I've seen a shell command being executed despite the fact that the project's allow entry for it had just been disregarded as untrusted. It must have been authorised based on the user-level configuration or the harness's pre-defined set of read-only commands — the run was being performed against my laptop rather than the pipeline.
That's the typical reason behind local-versus-runner differences. To address it, stop relying on automatic discovery — provide the context:
Claude Code offers
--bareand an explicit path for settings with--settingsCodex offers
--ignore-user-configand--ignore-rules
And make sure the context is the same on both sides.
There's a whole lesson on silent config failures back in the foundations chapter, and it's worse here as the information is being put in stderr — which nobody reads.
The prompt is the whole conversation
During an interactive run, if the user doesn't specify what they want to achieve precisely enough, the agent can always ask. If they guess wrong, the human will correct them next turn, and the thinness of the prompt will go unnoticed.
In a headless run, the agent can't ask the person, so their first guess becomes the entire run. So now the prompt must contain:
A definition of done that the agent can verify on their own — what's the required test command, what file needs to be created, and what's the expected output shape
An exclusion of the scope — otherwise, a loose brief will turn a one-file fix into a refactor during an unattended run
Instructions on what to do in case they get stuck — "stop and report" is a good outcome here
The prompting chapter covers the craft of it, and there's a headless-specific rule: one task per run. That's because with a three-point prompt you have three ways for the run to fail and just one exit code that will mean all of them.
Close stdin, bound the run
There are also two low-level things that are specific to unattended runs:
Some harnesses read stdin even if it's not a terminal — I've seen Codex's exec announcing it's reading additional input from stdin on every run, including one aimed at
/dev/null; the docs say they append piped content to the prompt as extra context, which is good when you pass in a diff, so make sure to redirect from/dev/nullif you don't want anything to get into the prompt by accidentThe run needs a limit as there's no human to get impatient and terminate it — some harnesses come with a print mode timeout or spend cap, some don't; I cover that in the cost chapter, in interactive runs it was the operator
In Claude Code
Headless mode (via -p) in Claude Code, short for --print, allows specifying --output-format with possible values of text (the default), json, stream-json; it works only when used with -p.
Eg: Running a single command with some prompt and the output in json:
claude -p "Summarise the changes on this branch" --output-format jsonThe most important part is the json wrapper, which contains following fields:
subtypeis_errorstop_reasonnum_turnssession_idtotal_cost_usd, split by modelpermission_denials— tools invocations, that were rejected due to lack of approval, each of them comes with its input
The most interesting of the above, is the last one. For instance, on 2.1.221 version running a not approved Write creates an entry in this array:
"permission_denials": [
{ "tool_name": "Write",
"tool_use_id": "toolu_01XYC5xQwshpyu3ZC3EGwvMX",
"tool_input": { "file_path": "/private/tmp/headless-check/cc-was-here.txt",
"content": "ok\n" } }
]The same run in terms of the return value, was:
subtype: successis_error: falseexit code: 0
So, to make it work properly it would be better for a CI gate to examine this field, rather than the process exit status, like in an example below:
claude -p "$PROMPT" --output-format json > run.json
test "$(jq '.permission_denials | length' run.json)" -eq 0 || exit 1Instead of that, we could define the set of tools in advance by using --allowedTools with an explicit list. For the most restrictive mode we can use --permission-mode dontAsk, which rejects everything not included in permissions.allow and the list of read-only commands (the docs call it useful for locked-down CI runs, and it's the closest thing to an honest "there's nobody here" mode).
For reproducibility we can use --bare flag, which doesn't pick up any context from the surrounding environment — skipping hooks, skills, plugins, MCP servers, automatic memory and CLAUDE.md. It also doesn't touch OAuth credentials and the keychain, so in order to use ANTHROPIC_API_KEY we need to set it in the environment. The context can be introduced on purpose with --settings, --mcp-config, --append-system-prompt and similar flags. This is a good option for scripting usage of Claude Code, it's also planned to become default for -p in the future.
If you plan to integrate it with some process supervisor, there are 2 more things worth knowing:
--printskips the workspace trust prompt, and if any settings file fails validation during headless run, it is just ignored without any error message (see--helpfor-pfor details)in case of
SIGTERMbeing send to Claude Code by a supervising process, the turn is cancelled, Bash command tree is terminated andSessionEndhooks are called; exit code is 143
Lastly, I've found that the permission_denials array contains info about only those approvals, that couldn't be obtained. For instance, for a file with a deny rule in place, if we try to read it, the result text says so plainly. But at the same time permission_denials is empty. So — as we can see — there are two ways that lead to the same result (or rather lack of it), and one of them does not leave a trace at all, so checking the output is always good.
In Codex CLI
It's worth noting that if you run codex in headless mode using subcommand codex exec it takes the value after prompt as an input. If you were to use -p flag with codex exec it sets --profile flag pointing to a config file (layered on top of user config) rather than printing the output.
Here's what a single run could look like, ignoring the git repo check:
codex exec --skip-git-repo-check "Summarise the changes on this branch" < /dev/nullAs mentioned, it's the headless mode, so the standard output is the answer and the standard error is used for the status messages (you can see it by redirecting stdout to a file, you will only get the answer):
$ codex exec "Hey" 2> codex-status.txt > codex-answer.txtIf you were to run codex in this way any time it is launched it outputs a config header on stderr so you can be sure what configuration it uses. It contains information like the following:
OpenAI Codex v0.146.0
--------
workdir: /private/tmp/headless-check
model: gpt-5.6-terra
provider: openai
approval: never
sandbox: read-only
...The default behaviour for codex exec is to run in a read-only sandbox, and the header is also where you see the approval policy that's actually in force — mine says "never". That means that there's no approver and there's nothing to be approved (in the read-only environment there are no files to change). To change this you need to run your code using flag --sandbox workspace-write. I would generally say that using danger-full-access is most reasonable when working in an already isolated environment like a docker image created for this purpose.
If you want to have structured output you can use --json flag which converts standard output into JSONL stream of events, there are thread.started, turn.started, turn.completed and turn.failed lifecycle events, and item.* events containing information about messages, commands and file changes.
There are also alternative ways if you're only interested in the final answer:
-o <path>/--output-last-message <path>— the last message is saved to the file--output-schema <file>— the last message adheres to a JSON Schema specified in the file
If you want to run codex without using any of your setup you can use following flags:
--ephemeral— no session files are being created on disk--ignore-user-config— user config from$CODEX_HOME/config.tomlis ignored--ignore-rules—.rulesfiles from both project and user directory are ignored
There are also 2 pitfalls to be aware of that might result in unexpected behaviour:
If you run codex outside of a git repository it exits before making a call to the model with an error message saying that current directory isn't trusted and that it's needed to use
--skip-git-repo-checkflag, exit code is 1The standard input is being consumed by codex so if you were to run it like this:
$ codex exec "Summarise the changes on this branch"You will see a notice on every run saying that it reads extra input. The input is being appended to the prompt as additional context so it's useful when you for example want to pipe diff to it to review it but if you don't want to do that make sure to redirect from /dev/null:
$ codex exec "Summarise the changes on this branch" < /dev/nullIn Cursor
The cursor agent is a command-line binary called cursor-agent. It has a non-interactive flag -p / --print, which its own help says it's built for to be used in scripts and non-interactive scenarios, with access to every feature of the tool including file writes and shell. Here's an example of a single command asking the agent to summarise changes of a branch and running it with trust granted and JSON output format:
cursor-agent -p --trust "Summarise the changes on this branch" --output-format jsonIt supports three output formats, set via the --output-format flag:
textjsonstream-json
you can set it to the latter and additionally pass --stream-partial-output to enable token-level deltas in the stream. If you were to use the tool with a host that doesn't support interactive authentication, you could pass --api-key or set the CURSOR_API_KEY environment variable.
Generally, the biggest source of confusion for new users is workspace trust — the agent doesn't work in non-trusted directories in print mode at all (rather than just being less capable), so it displays a message like this one:
⚠ Workspace Trust Required
Cursor Agent can execute code and access files in this directory.
Do you trust the contents of this directory?
/private/tmp/headless-check
To proceed, you can either:
• Run 'agent' interactively to decide
• Pass --trust, --yolo, or -f if you trust this directoryand then exits with an empty standard output and code 1. It's kinda annoying at first but actually makes sense — every CI run gets a fresh directory so it's not a single hurdle but rather a landscape; and --trust is the solution we have in mind, setting up workspace trust without asking is exactly what this flag is for. Be aware though that --yolo and -f do the same thing (in terms of letting you bypass the trust error) but they are permissions modes, so using them against a trust error you get a wall replaced with a highway.
The gotcha is that print mode really writes — I tested it in 2026.07.23-e383d2b with -p --trust and no --force, and for example made it create a file called cursor-was-here.txt containing the word ok, and it indeed created it on disk. So if you work with unattended agent in -p mode, assume it has write and shell capabilities from the very beginning, and if you want to have a real boundary, use a disposable clone, container or branch.
In Antigravity CLI
It's called agy, and the non-interactive version of it is agy -p (--print and --prompt are also valid aliases). The standard output stream is used to return an answer, while stderr is utilised for errors, displaying the run progress, and showing permission-related notices to ensure the returned answer remains pristine. For example:
answer=$(agy -p "Summarise the changes on this branch")The output format can be controlled using the --output-format option, which accepts these values:
text(default)json— emits a single envelope upon completion of the runstream-json— emits NDJSON events during the run
Here's an example of such an envelope:
{
"conversation_id": "055a398f-db14-4c5f-abbb-1bf03f8120a7",
"status": "SUCCESS",
"response": "A git rebase rewrites the commit history…\n",
"duration_seconds": 7.16,
"num_turns": 1,
"usage": {
"input_tokens": 10415,
"output_tokens": 657,
"thinking_tokens": 616,
"cache_read_tokens": 8113,
"total_tokens": 11072
}
}The important thing is the status field which you should base your conditions on; it can take the following values:
SUCCESSERRORCANCELEDINTERRUPTEDINVALIDWAITINGRUNNING
When you need to enforce a certain structure of the response, you can use --json-schema flag and either provide an inline schema or point to a JSON file (or even pass the name of a primitive type like string).
Permissions is another crucial aspect worth examining. Let's say there's a tool that requires a permission it can't get; if you run it, it'll be "soft-denied", meaning the run won't exit with a non-zero code but will proceed with an stderr notice indicating which tool and how to allow it. By default all file I/O operations within the project are allowed, but shell commands default to Ask (they're not allowed by default in headless mode). The allow-list is based on a configuration file called ~/.gemini/antigravity-cli/settings.json, which contains an array of permissions.allow entries, like this:
{
"permissions": {
"allow": [
"command(git)",
"command(npm run (build|lint|test))",
"write_file(src/)"
]
}
}This will allow using git, selected npm run scripts (build, lint and test), as well as writing files under a src directory.
The authentication is based on credentials that are cached once you've logged in at least once in a terminal. If you were to run the tool without a terminal (when it can't ask for the credentials), it'll throw an error instead of waiting indefinitely, which is the desired behaviour for overnight scheduled runs.
What's important to remember though, is that this soft-denial mechanism comes with a risk — the tool won't exit with a non-zero code but will display an stderr notice and return an answer on stdout. So make sure to check stderr, or at least to condition on status together with the produced artefact, not just on the exit code. A good illustration of how the tool works in this regard is that pointing --model at a nonexistent model results in it exiting with a non-zero code and setting the status to ERROR — it doesn't make any "smart" replacements.
The last thing worth keeping in mind is that print runs are limited by the --print-timeout flag which defaults to 5 minutes, so if you need more time to complete a substantial task, make sure to increase this value.
In Kimi Code CLI
The -p flag is short for --prompt and takes the prompt text as its value — so it's a valued flag, not a switch which needs to be placed before a raw string; it runs a single prompt and never starts the TUI. For example:
kimi -p "Summarise the changes on this branch" --output-format stream-jsonwhich will run a single prompt asking you for a branch change summary with the streaming JSON output mode enabled: By default, Kimi uses the text output format (the text value for the --output-format flag). You can set it to stream-json instead, which is the only parseable output format available (no single-object JSON mode, so stream-json is the only reasonable choice); in this mode, the output has a form of one JSON object per line. When the model calls a tool, an assistant message carrying tool_calls is emitted first, and the matching tool message follows it. The reasoning itself is not included in the JSONL file.
In the default mode (text), the output is a transcript rather than just a single answer; for example, a one-word prompt of mine came back as • PONG on standard output, with the thinking and a To resume this session: ... notice on standard error. It seems reasonable to me to direct the assistant's messages to the standard output and everything else to the standard error, but it'd be good to trim the leading bullet anyway in such scenario.
The -p flag also disables the confirmation procedure so you can't ever get asked for a permission when using it. In this mode the "ordinary" tool calls run under the auto permission policy; if you define some deny rules, those are still applied — they're the only thing left standing.
You can't run Kimi with both --prompt and --yolo, --auto, or --plan flags; Kimi won't accept such combinations and will exit with an error.
To deny certain tools, you need to set up a [[permission.rules]] array in the config file (config.toml); they are being evaluated one by one in order and the first match wins. The rule is either a tool name or a tool name with an argument pattern: For example, if you don't want to run a recursive shell deletion or read an env file, you can create two deny rules:
[[permission.rules]]
decision = "deny"
pattern = "Bash(rm -rf*)"
[[permission.rules]]
decision = "deny"
pattern = "Read(.env)"The thing is... The auto policy is the most permissive one, and on this harness it applies to the only tool that doesn't have a real OS-level sandbox as a backup — the blank cell in the sandbox row of the reference table. That means that the deny rules are all you have here, not a backup layer. Also, note that argument patterns can be defined only for built-in tools that have a concept of subject (so they can't be used with AgentSwarm, MCP tools, or your own custom tools) — if it comes to AgentSwarm, MCP tools and custom tools, you can only define rules based on tool names. Therefore, if you run Kimi with -p flag in an unattended manner, you should run it inside a container (or a virtual machine), which will replace the missing sandbox.
Rehearse it in the shape it'll run in
Before you schedule a headless run, make sure you do a dry run in its final shape: in a scratch directory, clone fresh, move your user config aside, close stdin, and redirect the output to a file. Then read the file instead of observing the terminal during the run.
There are four things to check, all of which will pass locally but fail on the runner:
If the harness really loaded your configuration
If it didn't deny anything
If the artefact is there (the file itself, not a summary saying that it should be there)
If the process exited for the reason you assumed
All of this once versus every night for a month.