GitHub - quietforgelabs/AgentPTY

9 min read Original article ↗

If a CLI is interactive, it is scriptable.

AgentPTY keeps Claude Code's interactive terminal UI running inside a persistent pseudo-terminal, uses Claude Code hooks as turn-completion signals, and exposes the session through a localhost FastAPI API.

It is also a small question about local tooling:

When a CLI has both interactive and headless modes, how meaningful is that distinction if the interactive mode still reads from a terminal and writes to a terminal?

AgentPTY drives the same claude process you would run manually. It does not implement a model API, patch Claude Code, or change Claude Code's account, usage, or permission model. The point is to explore how programmable an "interactive-only" local CLI really is.

Why

Claude Code already has several ways to be automated:

  • claude -p for non-interactive prompts
  • the Claude Agent SDK for headless agent workflows
  • interactive Claude Code in a terminal or IDE

Those modes do not have the same product semantics. Anthropic says that starting June 15, 2026, Claude Agent SDK and claude -p usage move to a separate Agent SDK monthly credit, while interactive Claude Code continues to use the normal Claude subscription allocation.

Related Anthropic docs:

That makes the boundary interesting. A terminal app can be "interactive" from the product's point of view and still be automatable from the operating system's point of view.

AgentPTY is a concrete probe of that boundary.

What It Does

External app
  -> AgentPTY FastAPI server
  -> persistent PTY session
  -> Claude Code interactive CLI
  -> Claude Code UserPromptSubmit hook
  -> Claude Code Stop or StopFailure hook
  -> cleaned assistant text returned over HTTP

AgentPTY:

  • starts claude inside a pseudo-terminal
  • keeps the process alive across requests
  • writes prompts into the terminal
  • waits for Claude Code hooks to mark the end of a turn
  • routes hook events back to the right AgentPTY session
  • returns cleaned assistant text as JSON
  • supports multiple named sessions
  • lets each session override the command, model, or Claude Code flags

Non-Goals

AgentPTY is terminal automation around a local Claude Code process.

It does not:

  • log in for you
  • implement its own Claude API
  • change Claude Code account limits or policy
  • change Claude Code permissions
  • use the Claude Agent SDK
  • use claude -p

Use it only with accounts, workspaces, and workflows you are allowed to automate.

Status

This is an experimental local tool. It depends on terminal behavior and Claude Code hook semantics.

This repository is meant to demonstrate the concept, not prescribe a framework or final architecture. Treat the code as a starting point: simplify it, reorganize it, add stricter parsing, or replace pieces to fit your own application.

Expect breakage if Claude Code changes its TUI, hook payloads, startup prompts, or permission model.

Install

Requirements:

  • Python 3
  • Claude Code installed and authenticated with claude

Install Python dependencies:

python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt

Configure Claude Code Hooks

AgentPTY needs Claude Code hooks so it can tell when a prompt was submitted and when a response is complete.

Copy the example project settings:

mkdir -p .claude
cp .claude/settings.example.json .claude/settings.local.json

.claude/settings.local.json is intentionally ignored by git.

The example config wires three Claude Code hooks to the local AgentPTY server:

  • UserPromptSubmit -> /hooks/claude-prompt-submit
  • Stop -> /hooks/claude-stop
  • StopFailure -> /hooks/claude-stop-failure

It also includes a conservative permissions.deny list for environment files, secrets, and high-risk shell commands. Keep that list tight, especially if you run Claude Code in a more permissive mode.

Inside Claude Code, you can verify hook configuration with:

Run The Server

Start AgentPTY on localhost:

.venv/bin/uvicorn agentpty.api:app --host 127.0.0.1 --port 8765

The default command for new sessions is:

Override it with AGENTPTY_COMMAND:

AGENTPTY_COMMAND="claude --model sonnet" \
  .venv/bin/uvicorn agentpty.api:app --host 127.0.0.1 --port 8765

Quick API Demo

Ask a question without creating a session first:

curl -s -X POST http://127.0.0.1:8765/ask \
  -H "Content-Type: application/json" \
  -d '{"message":"Say hello in one sentence.","timeout":120}'

AgentPTY creates a session automatically and returns the session_id:

{
  "session_id": "c6e5...",
  "event": "stop",
  "text": "Hello from Claude.",
  "prompt_submitted": true,
  "duration_seconds": 3,
  "output_tokens": 12,
  "returncode": null
}

Continue an existing session:

curl -s -X POST http://127.0.0.1:8765/ask \
  -H "Content-Type: application/json" \
  -d '{"session_id":"c6e5...","message":"Now make it shorter.","timeout":120}'

Create a session and ask with a custom command in one request:

curl -s -X POST http://127.0.0.1:8765/ask \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "sonnet-demo",
    "message": "Say hello in one sentence.",
    "timeout": 120,
    "command": ["claude", "--model", "sonnet"]
  }'

Create a named session explicitly:

curl -s -X POST http://127.0.0.1:8765/sessions \
  -H "Content-Type: application/json" \
  -d '{"session_id":"demo"}'

Ask that named session:

curl -s -X POST http://127.0.0.1:8765/sessions/demo/ask \
  -H "Content-Type: application/json" \
  -d '{"message":"What directory are you in?","timeout":120}'

Parsing Responses

AgentPTY does not require any particular assistant output format. It sends your prompt to the interactive Claude Code process and returns the cleaned assistant text.

For applications, it is often easier to ask Claude to return JSON and then parse text on the caller side:

{
  "message": "Return only valid JSON with keys: summary and next_action. No markdown.",
  "timeout": 120
}

AgentPTY intentionally does not enforce this. It is an example bridge for driving an interactive CLI; you can layer whatever prompting, parsing, retries, or validation your application needs on top.

Reliability Notes

Each AgentPTY session accepts one in-flight prompt at a time. If another request targets the same session while a turn is running, the API returns 409 Conflict instead of queueing or interleaving input in the PTY.

If a submitted turn times out before a Stop or StopFailure hook arrives, AgentPTY marks that session as needing recovery. Later asks for that session return 409 Conflict; delete and recreate the session before using it again. This avoids sending a fresh prompt into a terminal that may still be generating output, waiting on permissions, or sitting in a partial state.

Use GET /sessions/{session_id}/debug to inspect:

  • raw and cleaned PTY output tails
  • whether the session is busy
  • whether the session needs recovery
  • the last turn status and error
  • recent hook events for that session

Per-Session Commands

/sessions accepts command as a JSON list. This lets a session choose its own Claude Code flags without changing the server default:

curl -s -X POST http://127.0.0.1:8765/sessions \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "sonnet-demo",
    "command": ["claude", "--model", "sonnet"]
  }'

If you already use Claude Code's --dangerously-skip-permissions flag in a trusted local workspace, you can include it too:

curl -s -X POST http://127.0.0.1:8765/sessions \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "local-automation",
    "command": ["claude", "--model", "sonnet", "--dangerously-skip-permissions"]
  }'

Use whatever model names and flags your installed Claude Code CLI supports.

--dangerously-skip-permissions is dangerous outside a sandboxed, trusted workspace. The example deny list is a guardrail, not a complete security boundary.

If command is sent for a session that already exists, AgentPTY returns 409 Conflict instead of silently reusing the existing process with different flags than the request implies.

Sessions

List sessions:

curl -s http://127.0.0.1:8765/sessions

Check a session:

curl -s http://127.0.0.1:8765/sessions/demo

Inspect the PTY tail after a timeout or bad submission:

curl -s http://127.0.0.1:8765/sessions/demo/debug

Close a session:

curl -s -X DELETE http://127.0.0.1:8765/sessions/demo

API

POST   /ask
POST   /sessions
GET    /sessions
GET    /sessions/{session_id}
GET    /sessions/{session_id}/debug
POST   /sessions/{session_id}/ask
DELETE /sessions/{session_id}
POST   /hooks/claude-stop
POST   /hooks/claude-stop-failure
POST   /hooks/claude-prompt-submit

POST /ask

Request:

{
  "session_id": "optional-session-id",
  "message": "prompt text",
  "timeout": 120,
  "command": ["claude", "--model", "sonnet"]
}

If session_id is omitted, AgentPTY creates a new session. If it is provided and exists, AgentPTY reuses it. If it is provided and does not exist, AgentPTY creates that session id.

command is optional and only applies when AgentPTY creates a session for the request. Sending command for an existing session returns 409 Conflict.

Response:

{
  "session_id": "resolved-session-id",
  "event": "stop",
  "text": "clean assistant answer",
  "prompt_submitted": true,
  "duration_seconds": 3,
  "output_tokens": 12,
  "returncode": null
}

POST /sessions

Request:

{
  "session_id": "optional-session-id",
  "command": ["claude", "--model", "sonnet"]
}

Both fields are optional.

Environment Variables

AGENTPTY_COMMAND                  Command used for new sessions. Default: claude
AGENTPTY_ASK_TIMEOUT              Default ask timeout in seconds. Default: 120
AGENTPTY_STARTUP_OUTPUT_TIMEOUT   How long first ask waits for initial output. Default: 2.0
AGENTPTY_PRE_SEND_DRAIN_TIMEOUT   Max wait for quiet output before sending. Default: 0.75
AGENTPTY_SUBMIT_DELAY             Delay between prompt text and Enter. Default: 0.05
AGENTPTY_POST_HOOK_DRAIN_TIMEOUT  Max PTY drain wait after completion hook. Default: 0.5
AGENTPTY_AUTO_TRUST_WORKSPACE     Auto-confirm Claude Code trust prompt. Default: false
AGENTPTY_HOOK_URL                 Hook script HTTP target
AGENTPTY_HOOK_EVENT               Hook event name override. Default: stop
AGENTPTY_SESSION_ID               Internal routing id injected into child processes

Implementation Notes

AgentPTY uses a pseudo-terminal instead of pipes because terminal apps often behave differently when they detect a real TTY. It writes the prompt text first, waits briefly, then sends carriage return as a separate submit key.

Claude Code hooks are used as the turn boundary. Terminal output is noisy, animated, and not a reliable completion signal by itself.

AgentPTY injects AGENTPTY_SESSION_ID into each child process. The hook script copies that id into hook payloads so multiple Claude Code sessions can run at the same time without relying on Claude's internal session ids.

The returned text is cleaned from PTY output. This is best-effort and intentionally narrow: it strips terminal control sequences and extracts Claude answer blocks from the current turn.

Limitations

  • One request at a time per session.
  • Localhost-only by default.
  • TUI parsing is brittle.
  • Hook delivery is required for reliable completion.
  • Claude Code startup prompts can block automation.
  • Permission prompts can block automation unless you configure Claude Code appropriately.
  • This has not been hardened as a multi-user service.

Safety

Keep AgentPTY bound to 127.0.0.1 unless you have explicitly designed a safer deployment model.

Treat a writable Claude Code session as a powerful local automation surface. It can read files, edit files, and run commands according to your Claude Code permissions. Start with a narrow workspace, keep deny rules for secrets and destructive commands, and avoid exposing this server to a network.

Development

Run tests:

.venv/bin/python -m unittest discover -s tests

Compile-check Python files:

PYTHONPYCACHEPREFIX=/private/tmp/agentpty-pycache \
  .venv/bin/python -m compileall agentpty scripts tests