Most "agents that deploy things" demos have a human doing the parts that aren't code: creating the cloud account, entering a card, pasting an API key into an env var. That works until you want the agent itself to stand up a service, pay for it out of its own budget, and turn it off when it's done.
This post walks through doing exactly that against StorkHost, a hosting API we built for this case. The agent:
- signs up with nothing but an EVM wallet signature (EIP-191),
- funds its account by paying an HTTP 402 challenge with USDC on Base (x402),
- deploys a workload through a plain REST call,
- reads its own burn rate and runway,
- stops and deletes the workload.
There is one Python script at the end (requests + eth-account, ~180 lines), and each step below shows the part of it that matters. Everything here is plain HTTPS + JSON, so it ports to any language.
Up front, the honest parts: StorkHost is in beta with a best-effort SLA and no service credits. There is no free tier: an unfunded account can sign up and read the API, but it can't deploy, and the minimum top-up is $1. Payment is USDC on Base only. If that rules it out for you, the patterns below (wallet-as-identity, 402-as-checkout) are still worth knowing, because more APIs are going to look like this.
Step 0: the agent needs a wallet
The wallet is the agent's identity and its payment method. Any secp256k1 key works:
from eth_account import Account acct = Account.create() print(acct.address) # public: this is who the agent is # acct.key.hex() # private: keep it wherever you keep secrets, never in logs The script reads the key from STORKHOST_WALLET_KEY. To actually pay, that address needs some USDC on Base. It does not need ETH for gas; more on that in step 2.
Step 1: sign up with a signature
Signup is two unauthenticated calls. First, ask for a nonce bound to your address:
GET https://api.storkhost.cloud/v1/accounts/nonce?address=0xYourAddress { "address": "0x08545c3fd684b7c850c7f779376b048d726ce5c6", "nonce": "12e0b7f6...", "message": "StorkHost agent signup\nAddress: 0x0854...\nNonce: 12e0b7f6...", "expiresAt": "2026-10-21T21:24:33.594723307Z" } The nonce is single-use and valid for 5 minutes. Sign the whole message string (not just the nonce) with EIP-191 personal_sign, and post it back:
r = requests.get(f"{API}/v1/accounts/nonce", params={"address": acct.address}) message = r.json()["message"] sig = acct.sign_message(encode_defunct(text=message)).signature.hex() sig = sig if sig.startswith("0x") else "0x" + sig r = requests.post(f"{API}/v1/accounts", json={"address": acct.address, "signature": sig}) api_key = r.json()["apiKey"]["key"] # sk_..., shown exactly once The server recovers the signer from the signature, checks that it equals address, consumes the nonce, and returns an account plus its first API key. That key is shown exactly once, so the script writes it to a local state file before doing anything else. One account per wallet; a second signup from the same address gets a 409.
From here on every call carries Authorization: Bearer sk_.... The fresh account is Tier 0 with a balance of 0.
Step 2: pay a 402 with x402
x402 takes the long-dormant HTTP 402 status code and makes it machine-payable: the server answers 402 with a description of what it wants, the client signs a payment, then retries the same request with the payment attached.
GET https://api.storkhost.cloud/v1/account/topup?amountUsd=1.00 Authorization: Bearer sk_... { "x402Version": 1, "error": "Payment required", "accepts": [{ "scheme": "exact", "network": "base", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "payTo": "0x810770C050FeBEA7D1E6f643855e619a8076d1d3", "maxAmountRequired": "1000000", "resource": "https://api.storkhost.cloud/v1/account/topup?amountUsd=1.00", "maxTimeoutSeconds": 300, "extra": {"name": "USD Coin", "version": "2"} }] } (The body is trimmed; it also carries a directTransfer alternative for clients that prefer to send the transaction themselves. The same offer in x402 v2 form is in the base64 PAYMENT-REQUIRED header.)
asset is the USDC contract on Base, maxAmountRequired is in atomic USDC (6 decimals, so 1000000 = $1.00), and amountUsd accepts whole cents from $1.00 to $1000.00. Always take these values from the challenge rather than hard-coding them.
The exact scheme is an EIP-3009 TransferWithAuthorization: an EIP-712 typed-data signature that authorizes a USDC transfer from your address to payTo. You sign; the server submits it on-chain and pays the gas. That's why the agent's wallet needs USDC but no ETH.
now = int(time.time()) auth = {"from": acct.address, "to": req["payTo"], "value": int(req["maxAmountRequired"]), "validAfter": now - 30, "validBefore": now + min(int(req["maxTimeoutSeconds"]), 300), "nonce": "0x" + secrets.token_hex(32)} typed = { "types": {"EIP712Domain": [...], "TransferWithAuthorization": [...]}, # full lists in the script "primaryType": "TransferWithAuthorization", "domain": {"name": req["extra"]["name"], "version": req["extra"]["version"], "chainId": 8453, "verifyingContract": req["asset"]}, "message": {**auth, "nonce": bytes.fromhex(auth["nonce"][2:])}, } sig = acct.sign_message(encode_typed_data(full_message=typed)).signature.hex() sig = sig if sig.startswith("0x") else "0x" + sig payment = {"x402Version": 1, "scheme": "exact", "network": "base", "payload": {"signature": sig, "authorization": {k: str(v) for k, v in auth.items()}}} r = s.get(req["resource"], headers={"X-PAYMENT": base64.b64encode(json.dumps(payment).encode()).decode()}) Three outcomes to handle:
- 200: credited. The body has the new
balanceMicro; theX-PAYMENT-RESPONSEheader is base64 JSON with the transaction hash. - 202: settlement is still pending. Poll the returned
pollUrlno faster thanRetry-After, and do not sign a second payment while you wait. This is the step that's easy to get wrong: each signed authorization carries its own nonce, so a retry loop that re-signs on timeout could end up paying twice. - 402 from the poll: settlement failed, nothing was credited.
The payer has to be the wallet the account signed up with. The first top-up promotes the account from Tier 0 to Tier 1, which is what unlocks deploys. Top-ups are on-chain and final; there are no chargebacks.
If you use an off-the-shelf x402 client library instead of hand-rolling this, note that some of them cap a payment at 0.1 USDC by default. Since the minimum top-up is $1, raise the cap.
Step 3: check capacity, then deploy
Before spending anything on a deploy, ask whether it would be accepted right now. This endpoint needs no auth:
GET https://api.storkhost.cloud/v1/capacity {"retryAfterSeconds": 600, "payloadTypes": [{"payloadType": "static-site", "available": true}, ...]} If a type is unavailable, a create call answers 503 with Retry-After and code capacity_unavailable: no app is created and nothing is charged. We chose that over accepting the deploy and leaving it pending on a full cluster, because an agent can handle "come back in 10 minutes" but can't easily handle "you paid and nothing is running".
Then the deploy itself. The cheapest payload type is a static site at 30 micro-credits per second (about $0.78 per 30 days), and code can be sent inline as a map of file paths to contents, so no repo is needed:
body = { "name": "hello-agent-" + secrets.token_hex(3), "payloadType": "static-site", "files": {"index.html": "<!doctype html><title>hello</title><h1>Deployed by an agent</h1>"}, "spendCapMicro": 10_000_000, # auto-stop after $0.10 of metered runtime } r = s.post(f"{API}/v1/apps", json=body) app_id = r.json()["id"] Units: everything is integer micro-credits, where 100,000,000 micro = $1. Each build costs a flat $0.05, and runtime is billed per second once the app is RUNNING.
spendCapMicro is the field we'd set first when an agent is spending its own money. When the app's lifetime metered spend reaches the cap, the platform stops it and fires an app.spend_cap webhook if you registered one. The cap can be changed later with PUT /v1/apps/{id}/spend-cap. It's a per-app budget, so a runaway workload stops at its own cap instead of eating the balance that other apps share.
Other payload types work the same way: web-service, worker, cron, one-shot, webhook, function (scale to zero, not billed while idle), mcp-server, telegram-bot, and a few managed agent harnesses. You can also pass repoUrl with a GitHub repo, or include a Dockerfile in files to override the default image. The live list with per-second prices is in the generated manifest at https://api.storkhost.cloud/llms.txt.
Step 4: wait for it, then read the meter
The build runs asynchronously. Poll GET /v1/apps/{id}/status until status is RUNNING and ready is true:
while time.time() < deadline: st = s.get(f"{API}/v1/apps/{app_id}/status").json() if st["status"] == "FAILED": sys.exit(f"{app_id} failed: {st}") if st["status"] == "RUNNING" and st["ready"]: break time.sleep(10) Checking both fields matters. status is the desired state and stays RUNNING even if the pod can't start or crash-loops; ready says whether an instance is actually serving, and notReadyReason (POD_PENDING, CRASH_LOOPING, SCALED_TO_ZERO, ...) says why when it isn't. An agent that only checks status can end up paying for something that never answers.
The same endpoint is the meter:
{ "status": "RUNNING", "ready": true, "publicUrl": "https://shost-app-2ji691wyfkyxg1eecspaca.apps.storkhost.cloud", "burnRateMicroPerSecond": 30, "balanceMicro": 225104952, "estimatedRunwaySeconds": 333333, "spendCapMicro": 10000000, "meteredSpendMicro": 0 } (Trimmed; the full response also has notReadyReason, a pod block with phase, restarts and uptime, and lastMeteredAt.)
burnRateMicroPerSecond is what the app costs right now, estimatedRunwaySeconds is how long the balance lasts at that rate (limited by the spend cap if one is set), and meteredSpendMicro is what this app has spent so far. These three numbers are enough for an agent to decide on its own when to top up, scale down, or shut something off.
Step 5: stop, then delete
s.post(f"{API}/v1/apps/{app_id}/stop") # metering halts once it's no longer RUNNING # ...poll status until STOPPED... s.delete(f"{API}/v1/apps/{app_id}") # removes the workload; ledger history is kept A stopped app costs nothing and can be resumed with POST /v1/apps/{id}/start. If the balance hits zero, the account gets a 300-second grace period and then its workloads are stopped.
A real run
Here's the output of deploy followed by teardown against production while writing this post:
$ python agent_hosting.py deploy created app_2ji691WYfkYxG1EECsPaCa, building... BUILDING ready=False reason=BUILDING BUILDING ready=False reason=BUILDING BUILDING ready=False reason=BUILDING https://shost-app-2ji691wyfkyxg1eecspaca.apps.storkhost.cloud -> HTTP 200 { ...status as above... } ~$0.78 per 30 days at the current rate $ python agent_hosting.py teardown app_2ji691WYfkYxG1EECsPaCa app_2ji691WYfkYxG1EECsPaCa STOPPED, burnRateMicroPerSecond=0 app_2ji691WYfkYxG1EECsPaCa deleted For a one-file site it took about 35 seconds from the POST to a page answering 200. The ledger for the run has one entry, the $0.05 build fee (FEE, -5000000). At 30 micro-credits per second the half-minute it was up is negligible; for scale, a full hour of a static site is 108,000 micro, about a tenth of a cent. Note that the runway figure (333,333 s) is the spend cap talking, not the balance: $0.10 / 30 micro per second.
The same thing over MCP
If your agent speaks MCP rather than raw HTTP, POST https://api.storkhost.cloud/mcp is a Streamable HTTP JSON-RPC endpoint with the same Bearer auth, exposing one tool per REST operation (post_v1_apps, get_v1_apps_by_appId_status, put_v1_apps_by_appId_spend_cap, ...). The signup pair is REST-only, since it happens before the agent has a key. For discovery, https://api.storkhost.cloud/llms.txt is generated at runtime from the live endpoint and price registries, so an agent can read it instead of relying on a README that may be out of date.
What's under it, briefly
Tenant workloads run in gVisor sandboxes on dedicated tenant nodes, in a namespace with a default-deny network policy. Egress is allowlisted per payload type (a static site gets none at all) and you can add up to 20 extra domains per app. Image builds run under gVisor as well and are capped at 15 minutes. It's run by a small independent team, and the docs say plainly that data durability isn't guaranteed during the beta, so keep your own backups of anything that matters.
The script
Save as agent_hosting.py, pip install requests eth-account, set STORKHOST_WALLET_KEY, and run the subcommands in order. topup 1.00 --dry-run shows the challenge without paying, so you can see the whole flow before putting $1 into it.
"""An agent gets its own hosting on StorkHost: wallet signup -> x402 top-up -> deploy -> status -> stop/delete. pip install requests eth-account export STORKHOST_WALLET_KEY=0x... # the agent's EVM private key (identity + payer) python agent_hosting.py signup # once per wallet; saves the API key to storkhost-account.json python agent_hosting.py topup 1.00 --dry-run # show the x402 challenge, pay nothing python agent_hosting.py topup 1.00 # sign an EIP-3009 USDC authorization and pay python agent_hosting.py deploy # capacity check, deploy a static site, wait until it serves python agent_hosting.py status <appId> python agent_hosting.py teardown <appId> # stop, then delete """ import base64, json, os, secrets, sys, time import requests from eth_account import Account from eth_account.messages import encode_defunct, encode_typed_data API = os.environ.get("STORKHOST_API", "https://api.storkhost.cloud") STATE = os.environ.get("STORKHOST_STATE", "storkhost-account.json") MICRO_PER_USD = 100_000_000 # 1 USDC = $1 = 100,000,000 micro-credits def wallet(): return Account.from_key(os.environ["STORKHOST_WALLET_KEY"]) def api_key(): if os.environ.get("STORKHOST_API_KEY"): return os.environ["STORKHOST_API_KEY"] with open(STATE) as f: return json.load(f)["apiKey"] def session(): s = requests.Session() s.headers["Authorization"] = "Bearer " + api_key() return s # --- 1. Signup: the wallet signature is the identity ------------------------------------------- def signup(): acct = wallet() r = requests.get(f"{API}/v1/accounts/nonce", params={"address": acct.address}, timeout=30) r.raise_for_status() message = r.json()["message"] # sign this exact string, not the bare nonce sig = acct.sign_message(encode_defunct(text=message)).signature.hex() sig = sig if sig.startswith("0x") else "0x" + sig r = requests.post(f"{API}/v1/accounts", json={"address": acct.address, "signature": sig}, timeout=30) r.raise_for_status() body = r.json() state = {"address": acct.address, "accountId": body["accountId"], "apiKey": body["apiKey"]["key"]} with open(STATE, "w") as f: # the sk_ key is shown exactly once: persist it before anything else json.dump(state, f, indent=2) print(f"account {body['accountId']} tier={body['tier']} balanceMicro={body['balanceMicro']}; key saved to {STATE}") # --- 2. Top-up: standard x402 v1, scheme "exact", USDC on Base ----------------------------------- def topup(amount_usd, dry_run=False): s = session() url = f"{API}/v1/account/topup?amountUsd={amount_usd}" r = s.get(url, timeout=30) if r.status_code != 402: sys.exit(f"expected a 402 challenge, got {r.status_code}: {r.text}") req = r.json()["accepts"][0] # x402 v1 body; the v2 offer is in the PAYMENT-REQUIRED header print(json.dumps({k: req[k] for k in ("scheme", "network", "asset", "payTo", "maxAmountRequired", "resource", "maxTimeoutSeconds", "extra")}, indent=2)) if dry_run: return acct = wallet() # must be the wallet the account was created with Source:https://dev.to/storkhost/how-an-ai-agent-can-get-its-own-hosting-with-no-human-in-the-loop-wallet-signup-x402-deploy-4oenRelated posts

Dr Amr Eloufy: How to Save Our Hair in This Fast-Moving World
Only 6 of 45 Odds APIs Cover All 8 Big US Sportsbooks
Hadoop Ecosystem Overview
From One Chargeback to a Fraud Ring: Building Link Analysis That Works

Choosing the right AI web scraper depends on your needs, budget, and technical skills. This guide makes comparing the top tools easier and helps you find the right fit for your next project.
