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
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": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}],
"TransferWithAuthorization": [{"name": "from", "type": "address"}, {"name": "to", "type": "address"},
{"name": "value", "type": "uint256"}, {"name": "validAfter", "type": "uint256"},
{"name": "validBefore", "type": "uint256"}, {"name": "nonce", "type": "bytes32"}]},
"primaryType": "TransferWithAuthorization",
"domain": {"name": req["extra"]["name"], "version": req["extra"]["version"],
"chainId": 8453, "verifyingContract": req["asset"]}, # Base mainnet USDC
"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()}}}
header = base64.b64encode(json.dumps(payment).encode()).decode()
r = s.get(req["resource"], headers={"X-PAYMENT": header}, timeout=90)
while r.status_code == 202: # settlement pending: poll, never sign a second payment meanwhile
time.sleep(int(r.headers.get("Retry-After", "15")))
r = s.get(API + r.json()["pollUrl"], timeout=30)
if r.status_code != 200:
sys.exit(f"top-up failed: {r.status_code} {r.text}")
receipt = json.loads(base64.b64decode(r.headers["X-PAYMENT-RESPONSE"]))
print(f"credited; balanceMicro={r.json()['balanceMicro']} tx={receipt['transaction']}")
# --- 3. Deploy: check capacity first, then create with a spend cap --------------------------------
def deploy():
cap = requests.get(f"{API}/v1/capacity", timeout=30).json()
entry = next(p for p in cap["payloadTypes"] if p["payloadType"] == "static-site")
if not entry["available"]:
sys.exit(f"no room for static-site right now; retry in {cap['retryAfterSeconds']}s")
s = session()
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, # stop automatically after $0.10 of metered runtime
}
r = s.post(f"{API}/v1/apps", json=body, timeout=60)
if r.status_code == 503: # capacity_unavailable: nothing created, nothing charged
sys.exit(f"capacity unavailable, retry after {r.headers.get('Retry-After')}s")
if r.status_code == 402: # unfunded account
sys.exit(f"top up first: {r.text}")
r.raise_for_status()
app_id = r.json()["id"]
print(f"created {app_id}, building...")
deadline = time.time() + 15 * 60 # a build may run for at most 15 minutes
while time.time() < deadline:
st = s.get(f"{API}/v1/apps/{app_id}/status", timeout=30).json()
if st["status"] == "FAILED":
sys.exit(f"{app_id} failed: {st}")
if st["status"] == "RUNNING" and st["ready"]:
break
print(f" {st['status']} ready={st['ready']} reason={st.get('notReadyReason')}")
time.sleep(10)
else:
sys.exit(f"{app_id} not ready after 15 minutes")
page = requests.get(st["publicUrl"], timeout=30)
print(f"{st['publicUrl']} -> HTTP {page.status_code}")
status(app_id)
return app_id
# --- 4. Observe: burn rate and runway --------------------------------------------------------------
def status(app_id):
st = session().get(f"{API}/v1/apps/{app_id}/status", timeout=30).json()
burn = st["burnRateMicroPerSecond"]
print(json.dumps({k: st.get(k) for k in ("status", "ready", "publicUrl", "burnRateMicroPerSecond",
"balanceMicro", "estimatedRunwaySeconds", "spendCapMicro",
"meteredSpendMicro")}, indent=2))
if burn:
print(f"~${burn * 86400 * 30 / MICRO_PER_USD:.2f} per 30 days at the current rate")
# --- 5. Stop billing, then delete -----------------------------------------------------------------
def teardown(app_id):
s = session()
s.post(f"{API}/v1/apps/{app_id}/stop", timeout=30).raise_for_status()
for _ in range(60):
st = s.get(f"{API}/v1/apps/{app_id}/status", timeout=30).json()
if st["status"] in ("STOPPED", "FAILED"):
break
time.sleep(5)
print(f"{app_id} {st['status']}, burnRateMicroPerSecond={st['burnRateMicroPerSecond']}")
s.delete(f"{API}/v1/apps/{app_id}", timeout=30).raise_for_status()
print(f"{app_id} deleted")
if __name__ == "__main__":
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == "signup":
signup()
elif cmd == "topup":
topup(args[0] if args and not args[0].startswith("--") else "1.00", dry_run="--dry-run" in args)
elif cmd == "deploy":
deploy()
elif cmd == "status":
status(args[0])
elif cmd == "teardown":
teardown(args[0])
else:
sys.exit(__doc__)
If you build something on it, or hit a wall, the API has a POST /v1/feedback endpoint that agents can call themselves, and humans can reach us at support@storkhost.cloud.
Top comments (0)