Untether claude-stream-json
install
source · Clone the upstream repo
git clone https://github.com/littlebearapps/untether
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/littlebearapps/untether "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/claude-stream-json" ~/.claude/skills/littlebearapps-untether-claude-stream-json && rm -rf "$T"
manifest:
.claude/skills/claude-stream-json/SKILL.mdsource content
Claude Code stream-json Protocol (Consumer)
Untether spawns Claude Code CLI as a subprocess and consumes its JSONL output. This skill covers the protocol from Untether's perspective.
Key files
| File | Purpose |
|---|---|
| — subprocess management, PTY, control channel, event translation |
| msgspec structs for Claude JSONL events |
| — tool name to ActionKind mapping |
| Full runner specification |
| JSONL event shapes with examples |
| Claude JSONL to Untether event mapping |
CLI invocation
Non-interactive mode (-p
)
-pclaude -p --output-format stream-json --input-format stream-json --verbose -- <prompt>
/-p
: non-interactive, prompt as positional arg after--print--
: required for full stream-json output--verbose
: enables JSON input on stdin--input-format stream-json- Prompt passed after
to protect prompts starting with---
Interactive permission mode
claude --output-format stream-json --input-format stream-json --verbose \ --permission-mode plan --permission-prompt-tool stdio
- No
flag — prompt sent via stdin as JSON user message-p
: enables bidirectional control channel--permission-prompt-tool stdio
: determines what needs approval--permission-mode plan|tool
Common flags
: resume a previous session--resume <session_id>
: model override (sonnet, opus, haiku)--model <name>
: auto-approve specific tools--allowedTools "<rules>"
JSONL event types
One JSON object per line on stdout. Required field:
type.
system
(init)
system{"type":"system","subtype":"init","session_id":"...","cwd":"/repo","model":"sonnet", "permissionMode":"auto","tools":["Bash","Read","Write"],"mcp_servers":[...]}
- Emitted once at stream start
: opaque string (do NOT assume UUID format)session_id- Untether emits
hereStartedEvent
assistant
/ user
messages
assistantuser{"type":"assistant","session_id":"...","message":{"id":"msg_1","role":"assistant", "content":[...],"usage":{...}}}
Content blocks in
message.content[]:
| Block type | Fields | Untether mapping |
|---|---|---|
| | Stored as fallback answer; no action emitted |
| , , | |
| , , | |
| | Optional note action or ignored |
result
result{"type":"result","subtype":"success","session_id":"...","is_error":false, "result":"Done.","total_cost_usd":0.01,"usage":{...}, "duration_ms":12345,"duration_api_ms":12000,"num_turns":2}
: authoritative error indicatoris_error
: final answer stringresult- Untether emits exactly one
hereCompletedEvent - Lines after
are droppedresult
Fields NOT in Untether's
StreamResultMessage schema (silently ignored by msgspec):
,error
,permission_denialsmodelUsage
Tool name to ActionKind mapping
| Tool name | ActionKind | Title source |
|---|---|---|
| | |
, , , | | or |
| | |
, | | pattern from input |
| | |
| | URL from input |
, | | "update todos" |
| | "ask user" |
, | | tool name |
| | tool name |
| (other) | | tool name |
Mapping implemented in
src/untether/runners/tool_actions.py.
Control channel protocol
When using
--permission-prompt-tool stdio, Claude Code sends control requests as JSONL on stdout and expects responses on stdin.
Control request (stdout)
{"type":"assistant","session_id":"...","message":{"content":[ {"type":"tool_use","id":"toolu_ctrl_1","name":"PermissionPromptTool", "input":{"type":"control_request","request_id":"req_1", "tool_name":"Bash","tool_input":{"command":"rm -rf /"}}} ]}}
Control response (stdin)
{"type":"control_response","request_id":"req_1","approved":true}
Or with denial:
{"type":"control_response","request_id":"req_1","approved":false, "denial_message":"Not allowed — explain your plan first."}
ControlInitializeRequest
Sent at session start; auto-approved immediately (no user prompt):
{"type":"control_response","request_id":"req_init","approved":true}
PTY for stdin
ClaudeRunner uses
pty.openpty() instead of subprocess.PIPE for stdin:
- Prevents deadlock when keeping stdin open for control responses
- Master FD held by the runner; slave FD passed to subprocess
for raw byte passthroughtty.setraw(master_fd)- Stdin refs captured locally at spawn time (not on
)self
Session registries (concurrent sessions)
_SESSION_STDIN: dict[str, anyio.abc.ByteSendStream] # session_id -> stdin pipe _REQUEST_TO_SESSION: dict[str, str] # request_id -> session_id
- Registered in
when session_id is first seen_iter_jsonl_events - Control responses routed via
lookup_REQUEST_TO_SESSION - Cleaned up when run completes
Auto-approve logic
Non-interactive tools are auto-approved without user prompt:
AUTO_APPROVE_TOOLS = {"Grep", "Glob", "Read", "LS", "Bash", "BashOutput", "TodoWrite", "TodoRead", "WebSearch", "WebFetch", ...}
: always auto-approvedControlInitializeRequest- Tool requests where
: auto-approved silentlytool_name in AUTO_APPROVE_TOOLS
: always shown to user as inline buttonsExitPlanMode
ExitPlanMode handling
When Claude requests
ExitPlanMode:
- Inline keyboard shown: Approve / Deny / Pause & Outline Plan
- "Pause & Outline Plan" sends a deny with a detailed message asking Claude to write a step-by-step plan
- After outline is written, post-outline buttons appear: Approve Plan / Deny / Let's discuss
- "Let's discuss" sends a deny asking Claude to discuss the plan (action:
)chat - Progressive cooldown on rapid retries: 30s, 60s, 90s, 120s (capped)
Progressive cooldown
# In ClaudeRunner _discuss_deny_count: int = 0 # escalates per click _discuss_last_at: float = 0.0 # timestamp of last discuss/auto-deny _DISCUSS_BASE_COOLDOWN_S = 30 # base cooldown _DISCUSS_MAX_COOLDOWN_S = 120 # cap
- After "Pause & Outline Plan", auto-deny rapid ExitPlanMode retries within cooldown window
- Cooldown:
secondsmin(base * count, max) - Deny count preserved across expiry (keeps escalating)
- Resets on explicit Approve or Deny
Early callback answering
Telegram buttons show a spinner until
answerCallbackQuery. The Claude control callback handler sets answer_early = True to clear the spinner immediately with a toast ("Approved", "Denied", "Outlining plan...").
write_control_response
helper
write_control_responseasync def write_control_response( session_id: str, request_id: str, approved: bool, deny_message: str | None = None, ) -> None:
Looks up stdin in
_SESSION_STDIN[session_id], writes JSON response, handles cleanup.
Config keys ([claude]
section in untether.toml)
[claude][claude] model = "sonnet" allowed_tools = ["Bash", "Read", "Edit", "Write"] dangerously_skip_permissions = false use_api_billing = false permission_mode = "plan" # set via /planmode command or ChatPrefsStore
(default): stripsuse_api_billing = false
from subprocess envANTHROPIC_API_KEY
: overridable per-chat viapermission_mode
command/planmode