Six Failure Points Walk Into a Bar

Or: The Day We Stopped Managing a Terminal Session to Talk to a Terminal Application

There’s a moment in every project where you look at something you built, something that works, and realize you’ve been solving the wrong problem the entire time. Not a wrong answer — a wrong question. You built an elaborate machine to do a thing, and the thing could have been done with a function call.

That’s the story of how Bubba’s Claude invocation went from a tmux-based Rube Goldberg machine to a subprocess call. And why the diff that mattered most was the one that deleted the most code.

The Old Way: tmux as Session Manager

The original architecture for talking to Claude looked like this:

  1. Start a tmux session (tmux new-session -d -s claude)
  2. Launch Claude Code inside it (claude --dangerously-skip-permissions)
  3. When a Telegram message arrives, inject it into the tmux pane (tmux send-keys)
  4. Wait for Claude to process
  5. Scrape the output from the tmux pane
  6. Parse and send back to Telegram

You can see the skeleton in start.sh, which still has the fossils:

start_claude() {
    SESSION_NAME="${TMUX_SESSION_NAME:-claude}"
    WORKING_DIR="${CLAUDE_WORKING_DIR:-$HOME/workspace}"

    tmux new-session -d -s "$SESSION_NAME" -c "$WORKING_DIR" \
        'claude --dangerously-skip-permissions'
    sleep 3
}

That sleep 3 is doing a lot of emotional labor. It’s the code equivalent of “I don’t actually know if this worked, but let’s give it a moment and hope for the best.”

On paper, this made sense. Claude Code is an interactive terminal application. tmux manages terminal sessions. Use the tool designed for the job. Reasonable.

In practice, it was six things that could go wrong.

The Six Failure Points

Let me count the ways this pipeline could silently ruin your afternoon:

1. tmux session creation. Did the session start? Is tmux even installed? Is there a naming conflict with an existing session? That tmux has-session check in the startup script exists because this failed often enough to need a guard clause.

2. Claude Code startup inside tmux. The process launches inside a detached session. You can’t see it. You get no stdout. If Claude fails to initialize — bad API key, network issue, version mismatch — you find out when your first message disappears into the void. The sleep 3 after launch isn’t confidence. It’s prayer.

3. Message injection via tmux send-keys. You’re simulating keyboard input into a terminal. Think about what that means. You’re taking a user’s message, converting it to synthetic keystrokes, and hoping the terminal pane is in the right state to receive them. Is Claude at a prompt? Is it mid-response from something else? Is the pane the right size? Is there a confirmation dialog blocking input? You don’t know. You’re typing blind.

4. Output scraping from the tmux pane. This is where it gets properly unhinged. You capture the pane content with tmux capture-pane, parse the raw terminal output, and try to figure out which part is Claude’s response versus which part is the prompt, previous output, or terminal artifacts. You’re parsing ANSI escape codes and hoping the response doesn’t contain anything that looks like a prompt boundary. It’s regex against a terminal buffer. It’s screen-scraping your own application.

5. Response boundary detection. When is Claude done responding? In an interactive terminal, there’s no EOF signal. You poll the pane, check if the output has stopped changing, maybe look for a prompt character. But what if Claude is thinking? What if there’s a network delay? What if the response is just really long and you grabbed it mid-sentence? The difference between “Claude is thinking” and “Claude is done” is indistinguishable from the outside of a tmux pane.

6. Session lifecycle management. tmux sessions can die. They can become zombified. They can detach and reattach to the wrong terminal. The PID can go stale. The bridge server can restart while tmux keeps running, or tmux can restart while the bridge keeps running, and now you have two processes that think they own the same conversation but neither is actually connected to the other.

Six things. Any one of them fails and the whole pipeline either crashes, hangs, or — worst of all — silently produces garbage. And debugging? You’re debugging across process boundaries, through terminal emulation, with no structured logging on the Claude side because it’s running inside a detached tmux session you can’t easily inspect while it’s working.

This is the kind of architecture where the phrase “works on my machine” takes on a deeply personal meaning.

The New Way: Subprocess

Here’s what replaced all of that:

proc = await asyncio.create_subprocess_exec(
    *cmd,
    stdin=asyncio.subprocess.PIPE,
    stdout=asyncio.subprocess.PIPE,
    stderr=asyncio.subprocess.PIPE,
    cwd=WORKSPACE_DIR,
)

stdout, stderr = await asyncio.wait_for(
    proc.communicate(input=message.encode('utf-8')),
    timeout=timeout,
)

That’s it. That’s the entire invocation layer. Spawn a process. Pipe the message in via stdin. Read the response from stdout. Done.

No tmux. No session management. No keystroke simulation. No pane scraping. No boundary detection. No lifecycle juggling.

The claude --print flag is what makes this possible. Instead of launching an interactive terminal session, it accepts input via stdin, processes it, and writes JSON to stdout. Input in, output out. The way Unix intended.

cmd = [
    'claude',
    '--print',
    '--output-format', 'json',
    '--dangerously-skip-permissions',
    '--model', model.value,
]

The --output-format json flag means the response comes back as structured data:

{
    "result": "The actual response text",
    "session_id": "abc123..."
}

No parsing terminal buffers. No regex against ANSI codes. No guessing where the response starts and ends. It’s JSON. You call json.loads(). You access data['result']. You go home.

One Failure Point

The subprocess approach has exactly one failure point: the process itself. It either succeeds (exit code 0, valid JSON on stdout) or it fails (nonzero exit code, error message on stderr). Binary. Clear. Debuggable.

And when it fails, the error handling is almost insulting in its simplicity:

if proc.returncode != 0:
    error_msg = stderr.decode('utf-8', errors='replace').strip()
    logger.error(f'Claude process failed (rc={proc.returncode}): {error_msg}')

    if resume_id:
        logger.warning('Resume failed, retrying fresh')
        clear_session()
        return await invoke_claude(...)  # One retry
    return None

If the process fails and we were trying to resume a session, clear the session and try once fresh. If that also fails, return None and let the caller handle it. No retry loops. No exponential backoff. No circuit breakers. Just: try, and if it doesn’t work, try one more time without the resume, and if that doesn’t work, tell the user something went wrong.

Compare this to debugging a tmux pipeline failure. “The message was injected but no response appeared.” Cool. Was the session alive? Was Claude at a prompt? Was the output captured but empty? Was the pane the wrong size? Was there a permission dialog blocking input? Good luck. Bring snacks.

The Pipeline, Simplified

The old pipeline:

Telegram → FastAPI → tmux send-keys → Claude interactive →
  tmux capture-pane → parse terminal output → Telegram

Six hops. Three process boundaries. Terminal emulation as a communication protocol.

The new pipeline:

Telegram → FastAPI → subprocess → JSON response → Telegram

Four hops. One process boundary. Structured data all the way through.

The difference isn’t just reliability. It’s debuggability. When something goes wrong in the subprocess model, you get a return code and stderr. When something went wrong in the tmux model, you got silence and a lingering sense of dread.

What Made This Possible

Two things in Claude Code made the switch viable:

claude --print — The non-interactive mode. This flag turns Claude from a terminal application into a Unix tool. stdin/stdout. Pipes. The whole philosophy of “do one thing, write to stdout” that’s been the right answer since 1971.

--resume <session_id> — The session continuity flag. This was the concern that kept the tmux approach alive as long as it did. tmux gave us persistence — the conversation lived in the session. How would a subprocess maintain context across invocations? The answer: --resume. Each response includes a session ID. Pass it back on the next call. The session lives on Claude’s side, not ours. We just hold the token.

if resume_id:
    cmd.extend(['--resume', resume_id])
else:
    cmd.extend(['--system-prompt', system_prompt])

That branching logic replaced an entire tmux lifecycle management layer. Resume if you have a session. Start fresh if you don’t. The session ID persists to a text file on disk. Not Redis. Not a database table. A text file. Because the right level of infrastructure for “one string that changes occasionally” is a file.

The Lesson

The tmux approach wasn’t stupid. It was the right answer to the wrong question. The question was “how do we interact with an interactive terminal application?” and tmux was a perfectly reasonable answer. The real question was “how do we send messages to Claude and get responses?” and the answer to that is “the same way every Unix program has communicated since the 1970s: stdin and stdout.”

The day they switched from tmux to subprocess, they deleted more code than they wrote. The startup script got simpler. The error handling got simpler. The debugging got simpler. The deployment got simpler — no more tmux dependency, no more session naming conventions, no more sleep 3 between startup steps.

And the system got more reliable. Not through redundancy. Not through monitoring. Not through retry logic or health checks or circuit breakers. Through having fewer things that could break.

Every layer you add is a layer you maintain. Every process boundary is a place where errors hide. Every clever abstraction is a thing that someone — probably future you, probably at 2am — will need to understand when it stops working.

The best architecture isn’t the one with the most elegant components. It’s the one with the fewest components that still does the job.

Six failure points walked into a bar. Five of them got deleted. The sixth one returns a clear error code and logs to stderr.

That’s the whole story. That’s always the whole story.