Guide: Designing AI-Native CLI Commands That Read from stdin
The principle: Every stdin-reading path must be explicit, declared in the schema, and fail immediately with a structured error when invoked without data in a non-TTY context.
stdin seems simple. It is not. When a human runs my-tool import, they type data and press Ctrl-D. When an AI agent runs the same command, there is no human, no TTY, and often no data flowing yet. The result: the tool hangs silently, burns the agent's timeout budget, and produces no error. The agent retries. It hangs again. The task fails.
This guide covers the three failure modes that break stdin-reading commands under agent orchestration, the spec-grounded solutions for each, and the safe invocation patterns every agent builder should use today.
The Three Failure Modes
§50 — Stdin Consumption Deadlock
A command silently falls back to stdin when an argument is missing. In a TTY, this looks like a helpful prompt. In a non-TTY (agent) context, it blocks indefinitely — no output, no error, no timeout until the process is killed externally.
# Agent omits --input-file
bean commodity import
# ← blocks forever waiting for stdin EOF
# Password defaults to stdin when flag omitted
my-tool --user admin
Password:
# ← blocking read in non-TTY mode: hangs until timeout
The tell: the process is running, consuming no CPU, producing no output — indistinguishable from slow initialization until the full timeout fires.
§61 — Bidirectional Pipe Payload Deadlock
UNIX pipes have a 64 KB kernel buffer. If a tool reads a large payload from stdin and writes a large response to stdout, both sides fill their buffers and wait for the other to drain. Neither does. Silent deadlock.
# Agent sends 200 KB CSV and expects a 200 KB response
echo "$large_csv" | bean commodity import --output json > result.json
# Timeline:
# 1. Agent writes 200 KB to tool's stdin
# 2. Tool reads stdin, starts writing response to stdout
# 3. Tool's stdout fills the 64 KB pipe buffer — tool blocks
# 4. Agent is still writing stdin, hasn't started reading stdout yet
# DEADLOCK. Both processes hang forever.
§10 — Interactive Prompt in Non-TTY Context
A confirmation prompt (Proceed? [y/N]) that works fine interactively hangs an agent that has no way to answer. Even tools that detect non-TTY and suppress confirmation prompts sometimes leave stdin-reading codepaths that predate the non-TTY requirement.
Solutions for CLI Authors
Never silently fall back to stdin
Require an explicit flag. There must be no hidden stdin reads. Every stdin-reading path must be declared in the command's --schema output. - as the flag value is the explicit opt-in; omitting the flag entirely in non-TTY context must fail immediately.
# Wrong — auto-reads stdin when no args given → hangs in non-TTY
bean commodity import
# Correct — named file (no stdin, safe for any payload size)
bean commodity import --input-file commodities.csv
# Correct — explicit stdin opt-in via `-`
cat commodities.csv | bean commodity import --input-file -
When --input-file is omitted in a non-TTY context, the command must fail immediately with a structured error:
{
"ok": false,
"error": {
"code": "STDIN_REQUIRED",
"message": "Argument '--input-file' is required when stdin is not a TTY",
"hint": "Pass --input-file <path> or pipe: cat file.csv | bean commodity import --input-file -",
"retryable": false,
"phase": "validation"
}
}
Exit code 4. Immediately. Under one second.
Detect non-TTY at startup, not at read time (REQ-F-009)
The framework checks isatty(stdin) once at startup and globally disables interactive prompts. Any command that would block waiting for stdin in non-TTY mode must fail at validation time — before any side effects run. This is a Framework-Automatic behavior; command authors should not have to think about it.
import sys
def bootstrap():
if not sys.stdin.isatty():
os.environ["NON_INTERACTIVE"] = "1"
_install_stdin_guard() # raises STDIN_REQUIRED before any blocking read
Cap stdin at 64 KB, auto-register --input-file (REQ-F-054, REQ-O-039)
For any command that declares stdin_input: true, the framework automatically registers --input-file <path>. When the payload would exceed 64 KB, reject at stdin read time and direct the caller to the flag. --input-file <path> (non-stdin) has no size limit — it reads in streaming chunks.
MAX_STDIN_BYTES = 65_536 # configurable via TOOL_MAX_STDIN_BYTES
def read_stdin() -> str:
data = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1)
if len(data) > MAX_STDIN_BYTES:
fail_structured(
code="STDIN_TOO_LARGE",
message=f"Stdin payload exceeds {MAX_STDIN_BYTES}-byte limit",
hint="Use --input-file <path> instead",
context={"limit_bytes": MAX_STDIN_BYTES},
exit_code=2,
)
return data.decode()
Declare stdin paths in --schema output
Agents discover capabilities from machine-readable schema. Every stdin-reading argument must document stdin_fallback, stdin_format, and non_tty_behavior so agents can find the correct invocation pattern without trial-and-error.
{
"command": "bean commodity import",
"flags": [
{
"name": "--input-file",
"type": "string",
"required": true,
"stdin_fallback": true,
"stdin_format": "CSV with headers: symbol,name,precision,currency",
"non_tty_behavior": "fail_with_exit_4",
"overflow_flag": "--input-file",
"overflow_hint": "For payloads >64 KB use --input-file <path> instead of stdin"
}
]
}
Safe Invocation Pattern for Agent Builders
Use this pattern for any command that accepts stdin input. The key: 32 KB is the conservative threshold — half the 64 KB pipe buffer, leaving headroom for output-side expansion.
import subprocess, json, tempfile, os
PIPE_SAFE_BYTES = 32 * 1024 # conservative: 32 KB, half the pipe buffer
def invoke_stdin_command(cmd: list[str], payload: str) -> dict:
payload_bytes = payload.encode()
if len(payload_bytes) > PIPE_SAFE_BYTES:
# Large payload → temp file to avoid §61 pipe deadlock
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
f.write(payload)
tmp = f.name
try:
result = subprocess.run(
[*cmd, "--input-file", tmp],
capture_output=True, text=True,
stdin=subprocess.DEVNULL, # never inherit agent stdin (§50)
timeout=30,
)
finally:
os.unlink(tmp)
else:
# Small payload → safe to pipe via explicit -
result = subprocess.run(
[*cmd, "--input-file", "-"],
input=payload,
capture_output=True, text=True,
timeout=30,
)
if result.returncode != 0:
try:
err = json.loads(result.stdout)
raise RuntimeError(f"[{err['error']['code']}] {err['error']['message']}")
except (json.JSONDecodeError, KeyError):
raise RuntimeError(f"exit {result.returncode}: {result.stderr[:200]}")
return json.loads(result.stdout)
stdin=DEVNULL is not optional. Without it, the subprocess inherits the agent's own stdin. If the agent is running non-interactively, that stdin is a pipe or /dev/null — any tool that silently falls back to stdin will consume it, corrupting the agent's own input stream.
Checklist for AI Agents
Before calling any command that might read stdin:
- Run
<tool> <command> --schema --output json— checkflags[*].stdin_fallback - If
stdin_fallback: trueis present, the flag accepts-for stdin input - If
non_tty_behavioris absent or not"fail_with_exit_4", use a temp file instead of piping - Always set
stdin=DEVNULLunless explicitly sending data viainput= - A process with zero CPU and zero output after 1 second is almost certainly blocked on an undeclared stdin read — kill it, switch to
--input-file
If the schema has no stdin declaration at all, assume the command may silently fall back to stdin. Use a temp file for any payload and set a short timeout (5–10 seconds) as a circuit breaker.
Implementation Checklist for CLI Authors
| Rule | Requirement | What to implement |
|---|---|---|
| No silent stdin fallback | REQ-F-009 | Require explicit --input-file or -; no hidden stdin reads |
| Non-TTY fail-fast | REQ-F-009 · §50 | On isatty() == false with no --input-file: exit 4, STDIN_REQUIRED, under 1 s |
| 64 KB stdin cap | REQ-F-054 · §61 | Read at most 64 KB; reject with STDIN_TOO_LARGE + hint to --input-file |
--input-file auto-registration |
REQ-O-039 | Any command with stdin_input: true gets the flag; --input-file - == stdin |
| Schema declaration | §50 · §61 | stdin_fallback, stdin_format, non_tty_behavior present on every stdin arg |
| Structured errors | REQ-F-004 | All rejections use the response envelope; code, message, hint always present |
Related
| Document | Relationship |
|---|---|
| §50 Stdin Consumption Deadlock | Provides: the blocking failure mode this guide prevents |
| §61 Bidirectional Pipe Payload Deadlock | Provides: the pipe buffer failure mode this guide prevents |
| §10 Interactivity & TTY Requirements | Provides: the interactive prompt failure mode referenced in this guide |
| REQ-F-009 | Enforces: non-TTY detection and fail-fast at validation time |
| REQ-F-054 | Enforces: 64 KB stdin cap and STDIN_TOO_LARGE error |
| REQ-O-039 | Provides: --input-file auto-registration for stdin commands |
| REQ-O-006 | Provides: - convention for reading a single ID value from stdin |
| schemas/response-envelope.md | Provides: canonical envelope schema used in all structured errors |