# How I'm still using my Claude subscription for unattended jobs
Table of Contents
Recently Anthropic changed their policy to make using claude -p (the headless / print mode that almost every Claude Code automation I’ve written depends on) draw from API credits instead of my subscription usage limits. Interactive Claude Code stays on the subscription I already pay for; only -p gets the per-token bill.
Needless to say, despite their attempt to make this sound like a good thing, I was not very happy with this change and was not about to give up my VC-backed subsidized tokens just yet. So I started thinking about how I could keep all my automations while still not having to pay this new -p tax.
I was pretty sure I could accomplish the same as -p with an “interactive” session, a few hooks, and some tmux trickery.
After a few attempts I had a Claude Code plugin and a couple of wrapper scripts that drive a normal interactive Claude session as if there were a human at the keyboard.
TL;DR: How can you use this
If all you need is to replace your use of claude -p, then the claude-auto script can probably replace it with no changes. Just download it (and claude-transcript), put them somewhere in your $PATH, and use claude-auto wherever you used claude -p.
For example, one of my scripts was just claude -p --dangerously-skip-permissions "/pr-comment". pr-comment is one of my skills that runs a code review on the current GitHub PR for the branch it is running on and adds comments to it. For this one I replaced it with claude-auto --dangerously-skip-permissions "/pr-comment".
The output changes a bit but, IMHO, in a good way. claude-auto gives you a simple transcript of what Claude did during your session in a way that was hard to get with claude -p. This comes from claude-transcript.
Look at the examples folder for sample scripts - cron, batch, parallel-across-repos, and an attended variant for the rare cases where you do want Claude to be able to ask you a question.
How does it work
A Claude Code plugin is a directory with a collection of hooks, skills, agents, etc. Autonomy is four small hook handlers.
SessionStart: tell Claude to act without user input
One of the main uses of a SessionStart hook is to inject context at the start of a session. Autonomy injects a short prompt to steer Claude into working without user input. By default it assumes nobody is watching:
You are running in fully unattended mode. There is no human available toanswer questions, confirm decisions, or approve plans.
Do NOT use the `AskUserQuestion` tool — it is blocked in this mode andwill return a stock denial telling you to proceed without asking. Do notenter plan mode to seek approval, and do not stop to request clarification.
When requirements are ambiguous, make a reasonable judgement call based onthe existing code, project conventions, and the task description, thencontinue. State any non-obvious assumptions you made in your final outputso they can be reviewed after the session ends.If you do plan to attach to the tmux session and want to answer the occasional question, set CLAUDE_AUTO_QUESTIONS_OK=1 and Autonomy injects the softer version instead:
You should work as autonomously as possible on this session so there's noneed to get into plan mode to create a plan and ask the user for approval.
If you deem that the work cannot be done without user input then you canask questions. Use the `AskUserQuestion` tool for this. But only do thisif it is absolutely necessary, otherwise just do the work without userinput.PreToolUse: actually blocking questions
The SessionStart prompt is just a hint. Claude sometimes decides this is the absolutely-necessary case and reaches for AskUserQuestion anyway. In an unattended run “you should not ask” needs to become “you cannot ask”, otherwise the session sits there forever waiting for an answer that will never come.
The fix is a PreToolUse hook with "matcher": "AskUserQuestion". When the hook prints this JSON to stdout, Claude Code refuses to run the tool and surfaces permissionDecisionReason to the model in place of a result:
{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "This session is running in autonomous mode, so no human is available to answer. Use your best judgement based on the existing code and the original task description, and continue. State any non-obvious assumptions in your final output for later review." }}The hook reads CLAUDE_AUTO_QUESTIONS_OK from the environment. If it’s 1, the script exits 0 with no output (allow); otherwise it prints the deny JSON. The same env var is what swaps the SessionStart prompt between the two variants above, so the model and the enforcement layer stay consistent.
The default is strict. Autonomy assumes nobody is watching. If you intend to attach to the tmux session and want to be pinged for ambiguous calls, CLAUDE_AUTO_QUESTIONS_OK=1 claude-auto "..." flips both layers to the soft version. examples/attended-run.sh is a thin wrapper that sets it for you.
Stop: when stop doesn’t always mean stop
The first version of this hook was dead simple: tmux send-keys /exit. Every time Stop fired, the session ended because I naively thought that Stop meant the agent was done working. Not really.
In many situations where Claude needs to wait for some long-running task, it can start a background task or a subagent to do that. In these cases it does fire a Stop hook, but it says it is waiting for something to finish. When that happened, my script would “type” /exit and the agent would ask for confirmation before exiting, but there was nothing to confirm or deny, so the flow just broke.
So what I did instead of just exiting was to ask Claude. My Stop hook would type a question: “If there are no background jobs you’re waiting for and you’re done working reply with ‘done’ only. If you are waiting for background jobs reply with ‘waiting’ only.”
The Stop hook input JSON includes a last_assistant_message field with the text of Claude’s final reply for that turn. The hook reads it and routes on its content:
"done"→ really done, send/exitto end the session."waiting"→ tool or subagent still in flight; do nothing, the agent will trigger a newStopwhen whatever it’s running ends.- anything else → ask again.
The first time a session stops, last_assistant_message is whatever Claude was saying (usually a summary of work) so we nudge. Claude reads the nudge, classifies its own state, replies with a single word. That reply triggers another Stop. That second Stop’s last_assistant_message is "done" or "waiting", and the hook acts on it.
StopFailure: retry with a counter
Just as I was testing the script, Claude started having API issues and returning a bunch of server errors. Kinda lucky in a weird way, as it exposed a potential issue with this approach.
StopFailure is fired when a turn ends because of an API error. We can’t tell the difference between recoverable and non-recoverable failures from inside the hook, so we retry up to a limit and then give up.
The handler keeps a counter file at $TMPDIR/<session-id>. On each failure it increments. For the first five failures it types Up + Enter into the tmux pane after a ten-second pause. Up recalls the previous prompt in Claude’s history and Enter re-submits it. On the sixth, it removes the counter and types /exit. Five retries with a 10s baseline have been enough to ride out every transient I’ve hit; if you have a permanently broken auth or billing setup, you’ll burn through five retries and then exit, which is probably better than what claude -p would do in this situation.
claude-auto
Plugins are supposed to be installed in your agent’s configuration, but Autonomy is different: it is only needed in targeted situations. claude-auto handles that, plus a few other useful things.
claude-auto does four things:
- Generates a fresh UUID for
--session-id, so the transcript can be retrieved later. - Loads the plugin via
--plugin-url https://github.com/gpambrozio/Autonomy/archive/refs/heads/main.zipso there’s no install step for the user. Claude Code fetches the zip on the fly. - Routes stdin/stdout/stderr so that the session renders on the terminal even when the caller piped us into
tee. - Also, after the session ends it pipes the whole thing through
claude-transcriptso you have something to scroll back through (or, with--log <file>, written to a file).
claude-auto itself doesn’t know about CLAUDE_AUTO_QUESTIONS_OK. It just doesn’t strip the environment, so whatever you export reaches the hooks.
The typical invocation is:
claude-auto "do a thing"You can also add any other flags you’d pass to claude:
claude-auto --dangerously-skip-permissions --effort max --model opus "/github-issue $1"Getting Claude Code to render when stdout is piped
This was a bit tricky. In some of my scripts I was capturing the output of claude -p into a log file, so when I replaced it with claude-auto I did this:
claude-auto "..." | tee log.txtNow Claude’s stdout is the pipe to tee, not a TTY. When Claude detects that stdout isn’t a TTY, it falls back to plain-text non-interactive output, which is the same as -p and would probably charge you accordingly.
Ironically, I used Claude to figure out how to get itself to still behave as if it were in a TTY even though I was piping stdout.
As with many shell tricks Claude knows, I don’t fully understand how this works. Claude’s explanation is this:
In the realistic call shapes, stdin is still a TTY even when stdout is piped. The shell only redirects what the pipe captures. If you run
cmd | tee log, the pipe attaches tocmd’s stdout; the parent shell’s stdin (andcmd’s stdin, by inheritance) is still your terminal. So I just dup the inherited stdin into stdout and stderr:
if [ -t 0 ] && [ -t 1 ] && [ -t 2 ]; then claude ... "$@" # nothing to fixelif [ -t 0 ]; then claude ... "$@" 1>&0 2>&0 # stdin is a TTY, dup it into the otherselse claude ... "$@" # no TTY anywhere, let claude decidefi
1>&0 2>&0makes fd 1 and fd 2 dups of fd 0. They share the same open file description as the inherited stdin, which is a normal pty fd openedO_RDWRby the shell. Bun is happy, the TUI renders on the actual terminal, the pipe still gets whateverclaude-autowrites to stdout after Claude exits, which is the transcript dump.
claude-transcript
I wanted a way to get a transcript of the session afterwards, just in case something went wrong or to be able to see what it did.
There are many projects out there that do that, but I had Claude whip up a simple script.
claude-transcript is about 100 lines of Python that walks the JSONL file Claude saves for every session and prints a chronological list of just the parts I care about:
## [1] 2026-05-14 09:12:03 — USERImplement the new caching layer described in issue #42.
## [2] 2026-05-14 09:12:18 — CLAUDEI'll start by reading the existing cache implementation and the issuedescription to understand the requirements.
## [3] 2026-05-14 09:15:47 — CLAUDEImplementation done. Tests pass. PR opened at #167.claude-auto calls it on every exit, so every session ends with a self-contained log on stdout (or in the file you passed to --log).
Limitations / caveats
- tmux required. The whole mechanism is
tmux send-keys. StopFailureretries everything. It can’t distinguish a 429 from a 401, so a session with broken auth will retry five times before exiting.- No questions by default. Unattended runs deny
AskUserQuestionat the PreToolUse hook. If your automation depends on a human being there to answer, setCLAUDE_AUTO_QUESTIONS_OK=1and attach to the tmux session. - It’s a workaround. Anthropic could try to detect tricks like this and treat them as
-p. My guess is that all these subscriptions will cease to exist or start to get severely limited anyway.
Try it
- Plugin repo: github.com/gpambrozio/Autonomy
- Drop
bin/claude-autoandbin/claude-transcripton your$PATH, install tmux, start a tmux session, and runclaude-auto "do a thing". The plugin is fetched from GitHub on every invocation; there’s no install step beyond that.