TL;DR

When a script — or an AI agent — needs to drive a program built for a human at a terminal, there are three common solutions: a direct HTTP call, the expect tool, and tmux. They are usually presented as independent alternatives to weigh case by case. They are not. They are three points on a single spectrum: how close the method sits to the pseudo-terminal.

HTTPS never touches a PTY. expect allocates one and throws it away. tmux allocates one and holds onto it. Each step toward the PTY buys a capability and costs reliability. The design rule that falls out of this is short: pick the method furthest from the PTY that still does the job.

The Problem

A pseudo-terminal — a PTY — is the kernel object that makes a program think it is talking to a human. When you run vim or top or a modern CLI like claude or codex, the program checks whether its input is a terminal, and if it is, it switches on the full interactive machinery: raw keystroke handling, cursor addressing, full-screen redraws, color.

That machinery is exactly what you do not want when a script is the caller. A script wants to send an instruction and read a result. It does not want to parse a full-screen redraw. But a program that has decided it is talking to a human will not give you the simple thing — it will only give you the terminal thing.

So the question “how do I automate this interactive tool” becomes the question “how do I deal with the PTY.” Three answers exist. Most discussions treat them as a menu — pick one. The menu framing is wrong: the three answers form a gradient, and the right question is not which to pick but where to stop.

The Spectrum

Arrange the three by how much they touch the PTY:

Layer A: HTTPS direct      ← never allocates a PTY

Layer B: expect            ← allocates a PTY, uses it once, discards it

Layer C: tmux              ← allocates a PTY and holds it for the session's life

Read top to bottom and each layer adds one unit of indirection. HTTPS is a request and a response, nothing else in the process tree. expect spawns one child, drives it through a PTY, and exits when the child exits. tmux runs a background daemon that owns the PTY and outlives any particular client attached to it.

Read bottom to top and each layer removes one failure surface. The daemon can leak. The spawned child can hang on a pipe. The HTTP call can do neither, because there is no daemon and no child — there is only a socket.

This is why the spectrum has a direction, and why the direction implies a rule. Capability increases as you move toward the PTY: only tmux can give you a session a human can attach to mid-run. Reliability increases as you move away from it: only HTTPS has no subprocess to interlock and no daemon to clean up. The two gradients point in opposite directions, so the decision is not “which tool is best” but “how far toward the PTY does this particular job force me to go.” Stop at the first layer that works.

The Three Layers

HTTPS directexpecttmux
What it isAn HTTP request to an API endpointA Tcl program that drives a PTY childA terminal multiplexer with a background daemon
Allocates a PTYNoYes, for one childYes, for the session’s lifetime
Spawns interactive PTY childNoYes, one childYes, plus a daemon parent
PersistenceStatelessChild dies, script endsSession survives client detach
A human can watch mid-runNoNo attach; logs or foreground onlyYes, by attaching
Pre-installed on macOScurl, yes/usr/bin/expect, yesNo
Main failure modeNetwork, auth, or server timeout; no PTY-specific interlockPattern wait timeout; fragile PTY stream parsing; child outlives expectDaemon leaks; pane buffer truncates

The table makes the trade visible. Moving down a row buys you something — expect can drive a program that has no API at all; tmux lets a person take over a stuck automation without killing it. But every row down also adds a thing that can go wrong. The pattern expect is waiting for can fail to arrive, leaving the script blocked on a timeout. The tmux daemon will sit in your process list until something kills it.

The decision rule is not “use the most capable tool.” It is the opposite. The most capable tool is the one closest to the PTY, and proximity to the PTY is precisely what costs you reliability.

Choosing

The first question splits the spectrum in half:

Does the target expose an HTTP API?
├── Yes → HTTPS direct. Stop here.
└── No  → it is a CLI or TUI; you need a PTY
    ├── One-shot automation, run and exit
    │   → expect
    └── Long-lived session, or a human may need to step in
        → tmux

“Does the target expose an HTTP API” is the real watershed. If the answer is yes, nothing below HTTPS is worth considering — you would be allocating a PTY to avoid a socket, which is backwards. If the answer is no, you have already been pushed into PTY territory, and the only remaining question is whether the job is a single run-and-exit or a session that has to persist.

Most automation is run-and-exit, which means most non-API automation lands on expect. tmux earns its extra weight only when the session has to outlive the script that started it, or when a human has to be able to attach and watch.

What the Spectrum Doesn’t Cover

The model is sharp inside its domain, and the domain is narrower than it looks. Three caveats are worth naming.

Non-PTY subprocess automation lives off the axis. MCP stdio servers — the kind whose debug logs the second war story below is reading — communicate over stdin and stdout pipes without ever allocating a PTY. They are subprocesses without terminal proximity, which is a fourth quadrant the spectrum does not name. So is anything that talks JSON-RPC over a Unix socket. The PTY axis captures one dimension of the problem; “child process or not” is a different dimension that crosses it.

HTTPS is a stand-in. When the post says HTTPS, it means any in-process socket transport: HTTPS, gRPC, WebSocket, plain TCP. They collapse onto Layer A because none of them allocates a PTY and none of them spawns the target as a child. The distinction between them is real but orthogonal to the PTY question.

screen is parallel to tmux, not above or below it. screen allocates and holds a PTY just like tmux does, with a slightly different daemon model. macOS ships screen instead of tmux, so on macOS the Layer C tool is screen unless you install tmux. Choosing between them is a separate decision, made after the spectrum has already told you Layer C is the right depth.

The spectrum answers one question — how far do you go toward the PTY when driving an interactive CLI — and it is sharp for that question. Pushed past it, every tool becomes the wrong tool, which is fine; the model is not trying to be the only model.

War Stories

Three cases from a single week of work, one per layer.

HTTPS: replacing codex exec with a direct call

The parallel-ai-agents plugin runs several AI reviewers in parallel and asks OpenAI’s Codex to be one of them. The original implementation shelled out to the Codex CLI: codex exec --full-auto, with the prompt on stdin and the review captured from stdout.

That subprocess could hang. The CLI communicates over stdin and stdout pipes, and under parallel invocation — several reviewers spawning at once — the pipes could interlock, leaving the parent waiting on output that never came until a ten-minute timeout fired.

The fix was to delete the subprocess. The plugin now ships bin/codex-call, a small wrapper that does what the Codex CLI does internally — an HTTPS POST to chatgpt.com/backend-api/codex/responses — and nothing else. It reads the same OAuth token the CLI stores in ~/.codex/auth.json, so there is no separate login. There is no Codex CLI child process, so there is no model output pipe to interlock. The --max-time flag is bounded by a URLSession timeout plus a small process-side guard, rather than left to the CLI’s own discretion.

The Codex CLI was never the thing doing the work. It was a wrapper around an HTTP call. Once that was visible, the subprocess was pure overhead — a PTY-adjacent mechanism standing in front of an API that was there all along.

expect: driving Claude Code to capture its own debug log

A wrapper script for a Telegram MCP server was emitting a JSON-RPC error envelope, and the question was whether Claude Code’s MCP transport actually surfaced that envelope or silently dropped it. Answering it meant starting a fresh Claude Code session, running /mcp, and reading what the transport logged.

Claude Code is a terminal UI. It has no HTTP API. It wants a PTY. So this is not an HTTPS job — it is at least one layer down.

The job was also a single run-and-exit: start a session, send two commands, capture a log, quit. That is the expect shape. The script spawned claude --debug-file /tmp/cc-mcp-debug.log --debug mcp, waited for initialization, sent /mcp, waited again, sent /exit, and let the session close. The debug file came out 1978 lines long, and the answer was on one of them: the transport had parsed the envelope and stored the full human-readable message. expect allocated a PTY, drove the session through it, and discarded it on exit. Nothing was left running.

tmux: the layer I reached for and did not need

The third story is the one that proves the rule, because it is the story of not using the heaviest tool.

When I first set out to drive that Claude Code session, my instinct was tmux. A detached tmux session is the obvious way to run something interactive under script control while keeping the option to attach and watch. I went to start one — and command -v tmux came back empty. macOS does not ship tmux; it ships the older screen.

I could have installed tmux. I did not, because the moment I asked what the job required, the answer was: nothing that tmux uniquely provides. The session did not need to persist past the script. No human needed to attach. It was run-and-exit. expect, one layer up the spectrum and already installed, covered the job.

The rule caught me by accident. I had reached for the tool closest to the PTY out of habit; an empty command -v forced the pause that turned habit into deliberation. The capability tmux adds — a persistent session a human can join — is real, and when a job needs it nothing else will do. This job did not need it. So the right tool was the lighter one, and the missing install was not an obstacle. It was the spectrum telling me I had reached for too much.

Coda

This post came out of a debugging session for a Telegram MCP server, where over the course of an afternoon I touched all three decisions — a direct HTTP wrapper to call one AI reviewer, an expect script to drive another AI agent, and a reach for tmux that I rejected in favor of expect. Three decisions in one afternoon felt unrelated until I noticed they were the same decision asked three times: how far toward the PTY does this job push me. The spectrum was the answer that made the three decisions one.