Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when running multi-turn Claude Code CLI sessions programmatically via 'claude -p', integrating Claude into server-side processes, handling stream-json output, managing session continuity across turns, or building systems that spawn claude subprocesses. ALWAYS use when: spawning claude -p subprocesses, handling 'Session ID already in use' errors, working with --session-id vs --resume flags, parsing stream-json events, or implementing Claude Direct-style session management. Covers subprocess l
.claude/skills/valtterimelkko-claude-p/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 17% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 14% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 68% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 61% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 61% | 0% |
A reference for using claude -p (non-interactive print mode) to run Claude Code sessions programmatically — particularly for multi-turn conversations in server-side code.
This is the direct CLI approach, not the Claude Agent SDK. The key binary is claude, authenticated via claude auth login (subscription auth).
bashclaude -p "your prompt" \ --output-format stream-json \ --verbose \ --permission-mode acceptEdits \ --model sonnet \ --session-id <uuid> # first turn only — see below
--output-format stream-json --verbose is required together: stream-json alone is rejected in -p mode without --verbose.
typescriptconst env = { ...process.env }; delete env.ANTHROPIC_API_KEY; // forces subscription auth delete env.ANTHROPIC_AUTH_TOKEN; // forces subscription auth // Claude reads <CLAUDE_HOME>/ credentials automatically
Check auth status:
bashclaude auth status --json # correct flag # NOT: --output-format json # wrong, will error
Claude Code uses a per-session-id file lock. The lock persists for several seconds (sometimes indefinitely) after the subprocess exits. This is the single most common source of failures.
| Turn | Flag | Why | |------|------|-----| | First | --session-id <uuid> | Creates the session file | | Follow-up | --resume <uuid> | Continues without triggering the lock |
--session-id on a follow-up always fails with "Session ID already in use" — even seconds after the first process exited.
typescriptconst sessionFlag = isFirstTurn ? ['--session-id', claudeSessionId] : ['--resume', claudeSessionId]; spawn('claude', ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'acceptEdits', '--model', model, ...sessionFlag], { ... });
Track "has had a first turn" per session:
typescriptprivate sessionsWithHistory = new Set<string>(); // After successful first turn: sessionsWithHistory.add(sessionId); // On subsequent turns: isFirstTurn = !sessionsWithHistory.has(sessionId)
Also check entry.messageCount > 0 from persistent storage as a fallback for server restarts.
Claude may assign a slightly different session_id than the one you passed. Always capture it from the system event output:
typescript// In the stream-json system event: // {"type":"system","session_id":"<actual-uuid>",...} // Use THIS id for --resume, not necessarily what you passed to --session-id
Always use stdio: ['ignore', 'pipe', 'pipe']. If stdin stays open, Claude waits up to 3 seconds for piped input and warns: > "no stdin data received in 3s, proceeding without it"
This also means the process lingers and the session lock is held longer.
typescriptconst proc = spawn('claude', [ '-p', prompt, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'acceptEdits', '--model', model, // 'sonnet' | 'opus' | 'haiku' ...sessionFlag, ], { cwd: workingDirectory, env: claudeEnv, // with API keys stripped stdio: ['ignore', 'pipe', 'pipe'], });
Stream stdout line-by-line with readline:
typescriptconst rl = createInterface({ input: proc.stdout, crlfDelay: Infinity }); rl.on('line', (line) => { const events = normalizer.normalize(line, sessionId); events.forEach(onEvent); });
Emit agent_end on process exit, not on receiving the result event:
typescriptproc.on('exit', (code, signal) => { rl.close(); activeProcesses.delete(sessionId); if (code !== 0 && signal !== 'SIGTERM') { onComplete(new Error(`claude exited ${code}`)); } else { onEvent({ type: 'agent_end', ... }); onComplete(); } });
If you emit agent_end when the result event arrives (before process exit), the UI becomes interactive again while the session lock is still held — the next prompt will fail.
{"type":"system","subtype":"init","session_id":"<uuid>","tools":[...],"model":"claude-sonnet-4-6",...}
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_...","name":"Read","input":{...}}],...},...}
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","rateLimitType":"five_hour","isUsingOverage":false,...},...}
{"type":"user","message":{"content":[{"type":"tool_result","content":"...","tool_use_id":"toolu_...","is_error":false}]},...}
{"type":"assistant","message":{"content":[{"type":"text","text":"The file contains..."}],...},...}
{"type":"result","subtype":"success","result":"...","session_id":"<uuid>","usage":{...},"total_cost_usd":0.05,...}The result event exposes full token data — don't return zeros:
typescript// result event data: { usage: { input_tokens: 3, cache_creation_input_tokens: 12413, cache_read_input_tokens: 11171, output_tokens: 54 }, total_cost_usd: 0.05 }
| stream-json type | Normalized type | Notes | |---|---|---| | system (init) | session_init | Capture session_id here | | assistant (tool_use content) | tool_execution_start | One per tool block | | user (tool_result content) | tool_execution_end | Matches by tool_use_id | | assistant (text content) | message_start + message_update + message_end | Emit all three | | rate_limit_event | rate_limit | Forward quota info to UI | | result | claude_result | Store usage; emit agent_end on subprocess exit instead | | (subprocess exit) | agent_end | Emit here, not from result |
Each session UUID is independent — multiple sessions can run simultaneously without interfering. The lock is per-UUID.
Within a single session, only one turn can run at a time. Queue follow-up prompts server-side:
typescriptif (isRunning(sessionId)) { // Wait for current turn to finish before spawning next await waitUntilIdle(sessionId, 30_000); }
If a second claude -p starts for a session that hasn't fully released its lock, retry with backoff (the lock typically releases within a few seconds of process exit when using --resume):
typescriptproc.on('exit', (code) => { if (code !== 0 && stderr.includes('is already in use') && retries < 5) { const delay = 1500 + retries * 1000; setTimeout(() => spawn(options, onEvent, onComplete, retries + 1), delay); return; } // ... normal completion });
Claude Code subscription accepts short aliases — use these, not provider-qualified IDs:
| Alias | Maps to | |---|---| | sonnet | Latest Sonnet | | opus | Latest Opus | | haiku | Latest Haiku |
Don't pass anthropic/sonnet or claude-sonnet-4-6 — those work in the API but may fail or behave unexpectedly via the CLI subscription path.
Normalize any incoming model string:
typescriptfunction normalizeAlias(model: string): 'opus' | 'sonnet' | 'haiku' { const lower = model.toLowerCase(); if (lower.includes('opus')) return 'opus'; if (lower.includes('haiku')) return 'haiku'; return 'sonnet'; }
Claude stores session history in:
<CLAUDE_HOME>/projects/<cwd-encoded>/<session-uuid>.jsonlThis is Claude Code's internal format — don't write to it. Maintain your own session log (a separate JSONL) if you need to replay history to reconnecting clients.
| Mistake | Symptom | Fix | |---|---|---| | --session-id on follow-up turns | "Session ID already in use", silent failure | Use --resume for turns 2+ | | Emitting agent_end from result event | UI accepts input while session is locked | Emit agent_end on subprocess exit only | | stdin: 'pipe' (open stdin) | 3s delay + lingering lock | Use stdio: ['ignore', 'pipe', 'pipe'] | | ANTHROPIC_API_KEY in subprocess env | Uses API billing instead of subscription | Strip API key from subprocess env | | claude auth status --output-format json | Command errors | Use --json flag | | Returning zeros for token/cost | Misleads users; data is available | Read from result.usage and result.total_cost_usd | | Adding session stats that show "N/A" for Claude Direct | Confusing UI | Actually parse result event — token data is there | | Treating session info endpoint as unsupported for Claude Direct | Missing feature | It's implementable; the result event has full usage data |
The last two rows reflect a specific anti-pattern: an implementation was added that returned zero tokens/cost for Claude sessions under the assumption the data wasn't available. It was reverted because the data IS in the result event — always parse it rather than returning placeholder values.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-14 | pass→pass | 15,800 | 9,131 | -42% | 1 | 1 | 0% | 2,555 | 4,310 | +69% | 0 | 0 | — |
case-01 | fail→pass | 27,579 | 17,527 | -36% | 1 | 1 | 0% | 5,579 | 6,534 | +17% | 0 | 0 | — |
case-02 | fail→pass | 24,243 | 16,280 | -33% | 1 | 1 | 0% | 5,489 | 6,265 | +14% | 0 | 0 | — |
case-03 | fail→pass | 16,332 | 14,886 | -9% | 1 | 1 | 0% | 3,498 | 5,879 | +68% | 0 | 0 | — |
case-04 | fail→pass | 11,859 | 5,314 | -55% | 1 | 1 | 0% | 2,238 | 3,598 | +61% | 0 | 0 | — |
case-09 | pass→pass | 6,303 | 1,578 | -75% | 1 | 1 | 0% | 1,194 | 2,730 | +129% | 0 | 0 | — |
case-05 | fail→pass | 11,748 | 7,905 | -33% | 1 | 1 | 0% | 2,121 | 3,424 | +61% | 0 | 0 | — |
case-06 | pass→pass | 6,119 | 2,801 | -54% | 1 | 1 | 0% | 986 | 2,888 | +193% | 0 | 0 | — |
case-07 | pass→pass | 9,879 | 3,051 | -69% | 1 | 1 | 0% | 1,734 | 2,980 | +72% | 0 | 0 | — |
case-08 | pass→pass | 7,556 | 2,838 | -62% | 1 | 1 | 0% | 1,298 | 3,037 | +134% | 0 | 0 | — |
case-10 | pass→pass | 13,549 | 5,376 | -60% | 1 | 1 | 0% | 2,248 | 3,450 | +53% | 0 | 0 | — |
case-11 | fail→pass | 15,985 | 6,882 | -57% | 1 | 1 | 0% | 3,726 | 3,855 | +3% | 0 | 0 | — |
case-12 | pass→pass | 4,125 | 1,771 | -57% | 1 | 1 | 0% | 848 | 2,798 | +230% | 0 | 0 | — |
case-13 | pass→pass | 12,791 | 3,479 | -73% | 1 | 1 | 0% | 2,426 | 3,176 | +31% | 0 | 0 | — |
case-15 | pass→pass | 17,106 | 20,486 | +20% | 1 | 1 | 0% | 3,157 | 6,734 | +113% | 0 | 0 | — |
case-16 | pass→pass | 6,814 | 7,598 | +12% | 1 | 1 | 0% | 1,330 | 4,231 | +218% | 0 | 0 | — |
case-17 | fail→pass | 17,671 | 15,774 | -11% | 1 | 1 | 0% | 2,929 | 5,866 | +100% | 0 | 0 | — |
case-18 | fail→pass | 5,269 | 1,662 | -68% | 1 | 1 | 0% | 827 | 2,767 | +235% | 0 | 0 | — |
case-19 | pass→pass | 8,528 | 3,591 | -58% | 1 | 1 | 0% | 1,460 | 3,002 | +106% | 0 | 0 | — |
case-20 | pass→fail | 16,472 | 16,292 | -1% | 1 | 1 | 0% | 2,478 | 3,925 | +58% | 0 | 0 | — |
case-21 | pass→pass | 7,933 | 7,813 | -2% | 1 | 1 | 0% | 1,654 | 3,592 | +117% | 0 | 0 | — |
case-22 | pass→pass | 12,025 | 7,900 | -34% | 1 | 1 | 0% | 2,371 | 3,888 | +64% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +32 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.