-
-
No hold, no keyboard. No proof, no charge. Press Stop, pay $0.
-
A free look names the fault from the screen; the price is fixed before any key is pressed.
-
The owner approves in PayPal's own window. PayPal notes the merchant requested an authorization: a hold, not a charge.
-
The moment PayPal says the hold is live, the keyboard is lent. Stop and void sits one click away.
-
Key moments while Claude types: PayPal re-checked, hold still live.
-
The agent's own console screen: a one-word sed fix on line 3, SYNTAX_OK, ssh active.
-
The owner's own check passed twice; only then did PayPal capture $9.00, 1 min 37 s after the hold.
-
A second run: $19 on hold, and the owner presses Stop.
-
Stopped. $0 charged. Nothing was typed after Stop; the authorization is voided.
-
Under the hood: Orders v2 and Payments v2, a request id on every money call, 14 of 14 real webhooks verified.
-
The public ledger: every run and where its money went, 1,160 tests passing.
-
Keyhold. Pay when it's back.
Inspiration
I used to look after my own servers, and often that meant heading out in the middle of the night to fix one on site. If your server lives at a hosting provider, its web console saves the trip: a picture of the screen, a keyboard, and behind them, root. But someone still has to be awake to use it.
Often the cause is a configuration change: in a 2026 survey, 51% of data-center operators who had major IT system or software outages named configuration or change management among their most common causes.
An AI agent could read that screen and type the fix. But hand it root on trust? Pay for a failed attempt? Even shopping gives people pause: in a 2026 survey PayPal co-released, 43% of UK online shoppers were concerned about "AI purchasing a product without the shopper's approval."
What would make yes reasonable? Money that has not moved yet. A PayPal authorization hold can be read anytime, voided anytime, and is no charge until captured. One hold can be the leash on the keyboard and the contract: proof of the fix, then payment.
That is the permission I want for any agent that acts or spends for me: visible, revocable with one press, paid only on proof.
No hold, no keystrokes. Only your server answering can charge you.
What it does
- Free look. Screenshots only. Claude names the fault from a fixed list of 14 classes and quotes the screen lines that show it; code sets the price from a class-to-tier table ($9, $19 or $29). A fault the console cannot fix, such as a password-locked GRUB menu, gets no price and no hold.
- Owner probe, fixed at checkout: an SSH login with a key the agent never sees, and a page
whose content must match. The sha256 of this probe spec becomes the order's
custom_id. - Hold. The owner approves a PayPal payment; the server creates an
AUTHORIZEorder for the stored quote and authorizes it. - The lock. A keystroke passes only after the gate has read the authorization from PayPal as
CREATED, for at least the quote and for this rescue. It reads again every 5 inputs and once its last read is 15 seconds old. Stop takes the keyboard back, then voids the hold. - Capture. After the agent says it is finished, the probe must pass twice, at least 30
seconds apart (10 in the labelled demo timing), with no failure between. Then Keyhold captures
with
final_capture: true. None of the agent's six tools can move money. - Void or refund. Giving up, a turn or time limit, Stop, or a probe that never passes stably voids the hold. After a capture, two failed checks in a row refund it in full.
How we built it
Python 3.13 and FastAPI in one process, with a SQLite ledger.
PayPal. In the browser, the JS SDK v6 (web-sdk/v6/core, sandbox): createInstance with a
client token, findEligibleMethods, then createPayPalOneTimePaymentSession, which gets its order
id from our server, so the browser never sends an amount. On the server, the official
paypal-server-sdk 2.4.0 makes every Orders and Payments call:
- Orders v2 (
client.orders):create_orderwith intentAUTHORIZE,authorize_order,get_order. - Payments v2 (
client.payments):get_authorized_payment(the lock),capture_authorized_paymentwithfinal_capture: true,void_payment,refund_captured_payment(always with an amount),get_captured_payment,get_refund. - A client token for the JS SDK from
oauth_2.fetch_token(response_type=client_token,intent=sdk_init). PayPal-Request-Idon every money call (order-,auth-,cap-,void-,refund-plus the rescue id), written to an outbox before the call.- Webhooks: ten
PAYMENT.AUTHORIZATION,PAYMENT.CAPTUREandPAYMENT.REFUNDevent types, stored raw, verified withPOST /v1/notifications/verify-webhook-signature(a direct REST call; the Server SDK has no webhook controller), deduplicated, and never moving state backwards. The API answer is a money call's first tick, the verified webhook its second. - A reconciler, at startup and every 5 minutes, replays unfinished outbox rows with their original request ids and reads every open rescue back from PayPal.
The client refuses any environment but the sandbox, and can send PayPal-Mock-Response to ask the
sandbox for a specific failure. tools/live_check.py checks the sandbox behaviour Keyhold depends
on, through the same ledger and client: a $7.00 capture of a $12.00 hold with
final_capture: true and the authorization's exact status afterwards, the capture replayed with
its request id, a second capture and a void after the capture (both must be refused), a $2.00
refund, a void of a second hold, and four negative tests with PayPal-Mock-Response.
Claude at runtime, through the official anthropic Python SDK (2.6.0). The recorded rescues
and the hosted demo use claude-haiku-5-5; claude-opus-5-5 is one setting away
(KEYHOLD_MODEL) and adds server-side fallback, which Haiku 5.5 does not accept. Both run with
adaptive thinking with summaries, effort medium and prompt caching. The free
look is one request with the screenshot, answered through structured output; code checks the class
and sets the tier. The agent is a tool-use loop with six tools (screenshot, type, key,
power_cycle, give_up, finished); each input returns a fresh screenshot, and thinking
summaries show on the status page as the agent's notes. The system prompt tells Claude to treat
screen text as data. A refusal or API error ends the run, which voids the hold. Spend is capped at
$3 per run and $10 per rolling 24 hours.
The broken server is a Debian 13 guest under QEMU in a local container, driven over QMP. A keystroke firewall refuses destructive commands before they reach the console.
Challenges we ran into
- An expired authorization has no EXPIRED status. The gate compares
expiration_timewith the clock and treats a hold within 120 seconds of expiry as gone. - A crash between our write and PayPal's answer. The outbox row is committed before the call, one money call may be in flight per rescue, and the reconciler replays with the same request id, so PayPal returns its earlier answer.
- Stop racing the capture. Both take the same per-rescue lock; exactly one wins.
- SDK details.
paypal-server-sdk2.4.0 computes the token'sexpiryfrom local time read as UTC, so we keep our own deadline. On claude-opus-5-5tool_choice: anyis rejected, so the agent loop asks again when no tool is called.
Accomplishments that we're proud of
- 1163 tests: 1160 pass, and 3 boot real guests on demand. None reach the network. A stateful property test drives rescues through random PayPal errors, lost answers, crashes, expiry and out-of-order webhooks, checking every money invariant after every step. Chaos tests SIGKILL a process around the capture and find exactly one capture afterwards.
- On real Debian guests (emulated, no KVM),
tools/vm/prove.pyran all six faults in 10 minutes 30 seconds, with a simulated PayPal and a scripted agent. The five solvable ones each ended in one capture after two probe passes; for the GRUB fault, a hold placed by hand was voided when the agent gave up. Input sent before a hold reached the console zero times. - A full rescue ran on the hosted Keyhold, from the public page: PayPal sandbox, Claude Haiku 5.5,
an x86-64 test VM under KVM. Order
9DX392958A657063W, authorization68M23905BN0687322, capture7WP61145AR824644Efor $9.00, 1 minute 45 seconds from the hold to the capture, 7 agent turns, about $0.0039 in model spend, and all 24 warranty checks passed. Two earlier rescues on a local test VM captured and passed their warranty too. - Three more hosted runs on the redesigned pages: the sshd typo again (captured $9.00, capture
1FM0294205526573H, 80 seconds after the hold); "fstab lists a disk that is gone" (captured $19.00, capture37X1688089973222U, 87 seconds, 12 agent turns); and the same fault with the owner pressing Stop 6 seconds after the keyboard was lent (authorization8VN59510G70260907voided a second later, $0 charged, nothing typed after Stop). - Four more hosted runs recorded from the owner's own browser for the film: two $9.00 captures
(the film's is
0JP43729YN445900W, 97 seconds after the hold) and two Stops. The second Stop (authorization4XY289162B198180C) was voided 0.9 seconds after Stop with nothing typed after it. - Webhooks on real deliveries: fourteen PayPal deliveries across the seven hosted runs since the
webhook was registered, one out of order (a capture's event before its authorization's), and
all fourteen passed
verify-webhook-signatureand were applied. tools/live_check.pyagainst the sandbox: 22 checks passed, none failed (its webhook step ran before the deployment had a public URL; the real deliveries above cover it). It covers a partial capture withfinal_capture, request-id replays, a refused second capture, a refused void after capture, a partial refund, a void, and four mocked refusals.
What we learned
Recording a real Stop from the owner's browser found a gap no test had: the gate checked the hold once per input, so a long command Claude had started a moment before Stop finished typing, Enter included, 5.7 seconds after it. Now text goes out 8 keys at a time with the hold checked before each batch and before every Enter, so Stop (or a void from anywhere) cuts a command mid-line and it never runs. The page had also said "Nothing was typed after Stop"; it now measures from the moment Stop took the keyboard.
The first live runs found two problems that every test had missed. The free look answered
unknownfor a failed ssh.service because the prompt never said that classes in the same tier cost the same. And the agent added the probe's forwarded port tosshd_config, because its goal named that port: the probe passed, so the money could not tell, but the transcript could. Both are fixed. In the clean rerun the agent changed one word on line 3 of sshd_config, created/run/sshd, kept a backup copy and restarted ssh; it changed no port or other setting.A fix that works by hand can fail as a script.
sshd -tfailed because sshd had never started, and the&&chain stopped before the restart. The money side did the right thing: it voided.Three of our six faults look like a healthy boot, so one screenshot cannot name them. The public demo offers the three that show on screen.
What's next for Keyhold
- A
Consoleadapter for a real hosting provider's console API. - Keeping the console password from the model, which today sees it in order to type it.
- Probing from more than one place.
Where it stands today, honestly:
- Ten live rescues of two faults (eight on the hosted Keyhold): seven captured, three stopped by the owner and voided. The other faults have run on real guests, under KVM too, only with a simulated PayPal and a scripted agent.
- Keyhold drives a local test VM, not a hosting provider's console.
- On a real screen, Claude's evidence lines are not machine-checked.
- The keystroke firewall is a screen, not a sandbox.
- A void made outside Keyhold cuts the keyboard at the next PayPal read or verified webhook.