The short version: llm 0.32 (August 4, 2026) added llm.PauseChain, an exception a tool raises to stop an agent's tool loop before it does something you can't undo, hand control back to your code, and resume later without repeating the calls that already ran. It's the terminal-native version of "are you sure?" — except it doesn't block your shell, doesn't burn a model call, and works even if the human approves an hour later from Slack.
If you only remember one thing: gate the dangerous tool, not the whole agent. Let read-only tools run unattended; raise PauseChain in the one tool that deletes, deploys, or spends money.
The problem PauseChain solves#
The obvious way to add human approval to a terminal agent is to call input() inside the risky tool. It works on your laptop and nowhere else. The approver is usually not sitting at that terminal; the agent might be running in cron or a container with no TTY; and a blocked process is a process that can't do anything else while it waits.
llm.PauseChain is the clean alternative. When a tool raises it, llm does three specific things:
- It stops the chain instead of converting the exception into an
Error: ...tool result the way it would for any other exception. - It makes no model call for the paused turn — no placeholder result is sent, so you spend zero tokens waiting.
- It propagates the exception to your code with two useful attributes attached:
pause.tool_call(the call that paused, including.nameand.tool_call_id) andpause.tool_results(any sibling calls from the same turn that already succeeded).
That last point matters: if the model asked for three tools at once and only one is dangerous, the two safe ones still run, and their results are preserved for when you resume.
1. Install and pick a model#
uv tool install llm # or: pipx install llm / brew install llm
llm keys set openai # paste a key; any provider works
model.chain() runs an automatic tool loop — it calls the model, runs whatever tools the model asks for, feeds the results back, and repeats until the model stops requesting tools. Our job is to make one of those tools pause.
2. Define a gated tool#
A tool is just a Python function with a docstring. The gated one checks whether approval has already been recorded; if not, it records the request and raises PauseChain instead of acting:
import llm
def list_files(path: str) -> str:
"""List files under a path (safe, runs unattended)."""
import os
return "\n".join(os.listdir(path))
def delete_path(path: str) -> str:
"""Delete a file or directory. Requires human approval."""
if not approval_recorded(path):
record_request(path) # write to disk / Slack / a queue
raise llm.PauseChain(f"approval needed to delete {path}")
do_delete(path) # only runs once approved
return f"deleted {path}"
Note what the tool does not do: it doesn't block, and it doesn't call input(). It records the request somewhere durable and raises. Everything after the raise only executes on the resumed run.
3. Run the chain and catch the pause#
model = llm.get_model("gpt-5.6-luna")
chain = model.chain(
"Clean up the temp files under ./build",
tools=[list_files, delete_path],
)
try:
print(chain.text())
except llm.PauseChain as pause:
# Persist everything needed to resume later.
save_state({
"messages": chain.messages(), # the chain's message history, ending in the unresolved call
"paused_tool": pause.tool_call.name,
"paused_id": pause.tool_call.tool_call_id,
"argument": pause.tool_call.arguments,
})
print(f"⏸ paused on {pause.tool_call.name} — awaiting approval")
The list_files call, if the model made one, already ran and its result is safe in the history. Only delete_path is left unresolved. Your process is now free to exit entirely.
Serialize the chain's message history — a JSON file, a row in SQLite, a Redis key.llmpersists the same structured messages to its own log store, so if you'd rather not hand-roll this, read the history back fromllm logs. (Accessor names move between releases; confirm the exact call —chain.messages()here — against the Python API docs for your version.) The whole point is that the approver doesn't have to be here — the pause is a serialized fact, not a live thread.
4. Approve out of band, then resume#
Approval can now happen anywhere: a Slack button, a tiny web form, a second terminal running approve.py ./build/tmp. Whatever channel you use, its only job is to make approval_recorded(path) return True. Then you resume by re-running the chain from the saved history:
state = load_state()
record_approval(state["argument"]) # flip the gate to "approved"
chain = model.chain(
messages=state["messages"], # ends in the unresolved tool call
tools=[list_files, delete_path],
)
print(chain.text())
Here's the part that makes this safe and cheap: when the trailing assistant message contains a tool call with no matching result, llm executes that call first — through the normal before_call/after_call path — before the next model turn. Calls that already have results are skipped, matched by tool_call_id. So list_files does not run again; only the now-approved delete_path executes. No duplicate side effects, no wasted tokens re-deriving state the model already had.
The one caveat that actually matters#
A PauseChain gate is a control primitive, not a security boundary. The gate lives inside a tool the model chose to call, and the model's choice is driven by text it's reading — text an attacker may partly control. It's superb for catching honest mistakes and giving a human a veto over the agent's plan. It is not a substitute for authorization.
Put the authoritative check where the damage happens: the function or API that actually deletes the row should verify the caller's permission itself, independent of what the agent decided. We laid out the full argument in why an agent's approval prompt is not a security boundary — the short version is that a gate the model enforces can be talked out of; a gate the server enforces cannot.
Where this fits#
For a solo founder shipping a scriptable terminal agent, PauseChain plus a JSON file on disk is your human-in-the-loop system — roughly 40 lines, no framework, no runtime to operate. When you outgrow it — multiple services, multiple approvers, an audit trail with SLAs — the same concepts port to a framework's durable interrupts; see our cross-framework human-in-the-loop approval gate. And if you're new to driving llm as an agent from the shell in the first place, start with the tool highlight on llm 0.32, which covers install, logging, and the wider feature set this pattern sits inside.



