Writing
What stops Claude Code on its first launch in a container
An agent that starts, paints a banner and then waits is producing bytes. Bytes look like success in a log. On a fresh container Claude Code stops three times before it does any work, and each stop is a question asked of a terminal with nobody in front of it.
The three stops, in the order an agent meets them
| stop | what it asks |
|---|---|
| the first-launch assistant | Choose the text style that looks best |
| the workspace trust check | Is this a project you created or one you trust? |
| the provider key check | Do you want to use this API key? |
The first and the third are global to the machine. The second is recorded per directory. Closing one of them moves the agent forward exactly one stop, which is why anything short of all three reads as progress and delivers nothing.
What was measured
Every row below is claude 2.1.220 driven through a real pty, one fresh HOME per row. None of it is inferred from the name of a field.
| seeded | what the agent did |
|---|---|
| nothing | blocked on the theme picker |
settings.json theme alone | still blocked on the theme picker |
.claude.json hasCompletedOnboarding | reached the trust dialog |
plus projects[resolved].hasTrustDialogAccepted | reached the prompt, “Not logged in” |
plus ANTHROPIC_API_KEY in the environment | blocked: “Do you want to use this API key?” |
plus customApiKeyResponses.approved | reached the prompt, “API Usage Billing” |
Three of those rows contradict what looks obvious.
themeon its own does not silence the theme picker. Nobody needs to try that again.hasCompletedOnboardingon its own does silence it, on this version.- A key listed in
customApiKeyResponses.rejecteddoes not prompt at all. It silently drops the key, and the status line reads “Not logged in”. So a stale rejection has to be cleared for the key being approved, not merely appended past.
The trap in the trust key
Trust lives in ~/.claude.json under projects[path], and the path is the resolved one. Both places Murmell creates a directory use mkdtempSync, which on macOS hands back /var/folders/… while the resolved path is /private/var/folders/…. Seeding the unresolved key looks completely correct. It writes a plausible entry, the file now says the project is trusted, and the dialog still appears. That one cost a day.
export function trustKeyFor(root: string): string {
try {
return realpathSync(root);
} catch {
return root;
}
}realpathSync throws for a path that does not exist, which is a real case: a workspace can be torn down before this runs. Falling back to the path as given writes a useless but harmless entry, and the agent asks the question exactly as it does today.
The stop nobody had found
The provider key check is the expensive one, because its default answer is 2. No (recommended). A person connects a provider in the interface, the key travels correctly all the way into the environment of the pty, and the agent offers to throw it away with throwing it away pre-selected.
What the CLI records is not the key. It is the last twenty characters of one. That was established by driving a pty through the prompt, answering yes, and reading back what the CLI wrote: for sk-ant-api03-PROBE-TAIL-abcdef1234567890 it stored exactly AIL-abcdef1234567890.
export function approvalTokenFor(apiKey: string): string {
return apiKey.slice(-20);
}That it is a suffix is also what makes writing it acceptable at all. A file that recorded whole keys would be one more place a key lives, and the rest of this codebase goes to real trouble to keep a key out of the logs and off the wire.
Two answers that look right and are not
--permission-mode bypassPermissions does not open the trust gate. It governs tool permissions, which is a different question in the same shape. Pre-seeding the unresolved path does not work either, for the reason above. Both were tried against a real pty before this was written, and both are worth stating so nobody spends the afternoon again.
A theme is not a decision. Deleting a file is.
This removes questions of configuration. It never removes questions of judgement. Whether a directory is yours is a fact the server already knows on the user’s behalf, and asking an empty terminal does not make it safer, it only makes the product not run. Whether to delete a file or force a push is a different kind of question, and an agent must still ask it.
So there is deliberately no --dangerously-skip-permissions anywhere in this path, and the tool allowance stays empty.
const trust: ProjectTrust = {
hasTrustDialogAccepted: true,
hasCompletedProjectOnboarding: true,
projectOnboardingSeenCount: 0,
allowedTools: [],
};Empty on purpose, and it has to stay empty. This grants trust in the directory. It is not permission to act inside it.
Why the version is read from the binary
The onboarding record carries the version of the CLI it was written for, and that value is compared against the installed one. A literal in the source is correct until the image updates its CLI, and from then on it pins an onboarding state that no longer matches the binary: a regression that arrives on somebody else’s release schedule, in production, with nothing in any test suite to notice. So it is read, and when the read fails nothing is written. Not knowing is a first-class answer.
Reading it costs a process. claude --version was measured at 40ms warm and 430ms on a virgin HOME, and it creates no files, so the probe does not itself trigger any of the onboarding this exists to pre-empt. Its stdin is closed rather than inherited, because a CLI that decides to ask something must not be able to hang the spawn.
Preparing must never cost the session
The caller is the path that spawns an agent, immediately before the pty. Failing to prepare costs a keypress. Throwing costs the session. Every error here is swallowed and the worst case is the behaviour we already had.
There is a second reason for restraint. ~/.claude.json is shared: agents write to it constantly, and in production they run as the same user with the same HOME, so a read then write can lose somebody else’s concurrent update. Nothing is written when the file already says what it should. Every write lands through a temp file and a rename in the same directory, so a reader sees the old file or the new one and never a truncated one. The mode is 0o600, because that file also holds credentials.
If you are spawning agents yourself
Assert on the prompt, not on the output. Our own honest production measurement was that agents started, painted a banner and stopped, and the arriving bytes made it read as a success for longer than it should have.
Drive a real pty and read back what the CLI wrote. Every useful thing on this page came from doing that, and the two guesses that looked most reasonable were both wrong.
Before you silence a prompt, say which of the two kinds it is. Anyone widening a file like this one should be able to answer that in a sentence, and if they cannot, the prompt stays.