# CapVM Operated by Makersclub Molina (Switzerland). On-demand ARM and L4 GPU machines for AI agents. Prepaid credits, per-key spend caps. Hosted in Zurich. Beta. Base URL: https://api.capvm.com/v1 Money is integer micro-USD. 1 USD = 1000000 micro-USD. Never send floats. price_for_seconds = ceil(price_per_hour_micro * seconds / 3600). Billed per second with a 60-second minimum, capped at ttl_seconds. A machine hold cannot make the balance negative. A refund or a dispute can. Minimum top-up $10. Credits never expire and are non-refundable during the beta. ## Auth Authorization: Bearer ak_test_... or ak_live_... The secret is shown once when a human issues the key from the dashboard. Only its SHA-256 hash is stored. Machine list, exec, and usage are scoped to the calling key. Balance is the organization available balance. Humans sign in at /signin with a single-use link or an 8-character code. The session cookie is __Host-capvm_session. That cookie is never accepted on /mcp or on ak_ routes. A frozen organization can still read GET /v1/balance and GET /v1/usage, and can still stop a machine with DELETE /v1/machines/{id}. Other calls with an ak_ key return 403 org_frozen. Creating a key, raising a spend cap, renaming a key, creating a machine, and topping up return 403 org_frozen. Lowering a spend cap still works. ## SKUs Prices in USD, excluding VAT where applicable. - arm-small: AWS c8g.large in eu-central-2, $0.20/h (200000 micro-USD/h). AWS cost 99780 micro-USD/h, markup 2.0. - gpu-l4: AWS g6.xlarge in eu-central-2, $2.00/h (2000000 micro-USD/h). AWS cost 1085000 micro-USD/h, markup 1.8433. ## Create a machine POST /v1/machines Header Idempotency-Key: required. Same key and same body returns the same machine. Same key and a different sku or ttl_seconds returns 409 idempotency_conflict. A missing or empty key is 400 idempotency_key_required. REST says "Idempotency-Key header is required." MCP says "idempotency_key is required." Leading and trailing whitespace is trimmed, and the stored key is that trimmed value. Length is the trimmed value's UTF-16 code unit count, the same count as String.Length, not Unicode scalar values and not UTF-8 bytes, and must be 1 to 255. A whitespace-only key, a trimmed length outside 1 to 255, or a control character (Char.IsControl) is 400 idempotency_key_invalid. REST says "Idempotency-Key must be 1 to 255 characters after trimming, with no control characters." MCP says "idempotency_key must be 1 to 255 characters after trimming, with no control characters." Over REST, a whitespace-only Idempotency-Key header counts as empty and is idempotency_key_required. An MCP argument is not stripped, so a whitespace-only idempotency_key stays idempotency_key_invalid. Two Idempotency-Key header values are idempotency_key_invalid. An absent header, or a header with no values, is idempotency_key_required. Body: {"sku":"arm-small","ttl_seconds":900} ttl_seconds is required, from 60 to 7200 in beta. On success the full TTL is held from the organization balance and checked against the key spend cap, in one transaction. Nothing launches on 402. ## Other agent routes GET /v1/machines GET /v1/machines/{id} DELETE /v1/machines/{id} stops the machine, charges billed seconds, releases the rest of the hold POST /v1/machines/{id}/exec body {"command":"...","timeout_seconds":30,"working_directory":"/home/ec2-user"} -> {"stdout","stderr","exit_code","truncated"} timeout_seconds is optional (default 30, maximum 300). working_directory is an optional absolute path. Only the key that created the machine can exec, and only while the lease is ready and unexpired. A stopped or expired machine returns machine_not_running. A lease that is not ready yet returns machine_not_ready with "Machine is not ready yet." and writes no command_audits row, because no AWS call was made. An agent whose PingStatus is not Online returns machine_not_ready. At 30 seconds, if the invocation has not started, the API calls CancelCommand and reads GetCommandInvocation again. Pending or Cancelling keeps polling. When the status is Cancelled, an empty ExecutionStartDateTime returns machine_not_ready and a set ExecutionStartDateTime returns command_timeout. A command that provably never started does not return command_timeout. Both agent machine_not_ready results write command_audits with outcome not_ready (command_sha256, command_length, has_working_directory, duration_ms). stdout and stderr are each capped at 262144 bytes. truncated is true when either stream was cut. The machine writes stdout and stderr to S3 under exec/. The API reads them and deletes them right after. If a read or delete fails, or the command times out, a 1-day lifecycle rule removes them. Nothing is kept in Postgres. The command text is not stored. The command runs on the leased machine. The local fake provider returns simulated stdout and does not start a shell. GET /v1/usage this key's spend, outstanding holds, cap, and recent events GET /v1/balance available_micro and held_micro for the organization ## States requested -> provisioning -> ready -> stopping -> terminated A reaper stops leases at ttl_seconds, at the 5 GB egress cap, and settles billing. New accounts are limited to 1 concurrent GPU and 3 concurrent machines until the organization is trusted. ## Errors {"error":{"code":"...","message":"..."}} 402 insufficient_credits | spend_cap_exceeded 400 sku_required | unknown_sku | ttl_seconds_required | ttl_out_of_range | idempotency_key_required | idempotency_key_invalid | command_required | command_too_long | timeout_out_of_range | working_directory_invalid | invalid_json | validation_error | cap_out_of_range | min_topup_not_met | max_topup_exceeded | invalid_webhook_signature | terms_acceptance_required | link_used 401 unauthorized | invalid_api_key | key_revoked 403 sku_not_allowed | account_limit_reached | terms_not_accepted | account_suspended | org_frozen | reauth_required 404 not_found 408 command_timeout 409 idempotency_conflict | concurrency_limit_exceeded | machine_not_ready | machine_not_running | acceptance_current | details_changed 410 link_superseded | link_expired 429 rate_limited Retry-After is the seconds left in the window 500 internal_error on exec: The command finished but the audit record could not be stored. 502 provider_error | stripe_error 502 provider_error on exec: The compute provider failed to run the command. A failed DescribeInstanceInformation call is this error. A terminal Success or Failed invocation with no ResponseCode is this error with "The machine did not report an exit code." The exec/ output objects are still deleted. 503 capacity_unavailable | launches_disabled | email_send_failed | stripe_not_configured ## Waitlist POST /v1/waitlist {"email":"you@company.com","source":"hero"|"footer"} -> 201. Public. Deduped by email. ## MCP Streamable HTTP: https://api.capvm.com/mcp Same host as this API, not under /v1. Local default: http://localhost:8080/mcp Authorization: Bearer ak_test_... or ak_live_... Requests without that key are rejected. Tools call the same billing service as the routes above and are scoped to the calling key. create_machine {"sku":"arm-small"|"gpu-l4","ttl_seconds":60..7200,"idempotency_key":"..."} A missing or empty key is 400 idempotency_key_required. REST says "Idempotency-Key header is required." MCP says "idempotency_key is required." Leading and trailing whitespace is trimmed, and the stored key is that trimmed value. Length is the trimmed value's UTF-16 code unit count, the same count as String.Length, not Unicode scalar values and not UTF-8 bytes, and must be 1 to 255. A whitespace-only key, a trimmed length outside 1 to 255, or a control character (Char.IsControl) is 400 idempotency_key_invalid. REST says "Idempotency-Key must be 1 to 255 characters after trimming, with no control characters." MCP says "idempotency_key must be 1 to 255 characters after trimming, with no control characters." Over REST, a whitespace-only Idempotency-Key header counts as empty and is idempotency_key_required. An MCP argument is not stripped, so a whitespace-only idempotency_key stays idempotency_key_invalid. Two Idempotency-Key header values are idempotency_key_invalid. An absent header, or a header with no values, is idempotency_key_required. The same key and body returns the same machine and does not take a second hold. get_machine {"machine_id":"m_..."} list_machines stop_machine {"machine_id":"m_..."} charges billed seconds (minimum 60) and releases the rest of the hold exec {"machine_id":"m_...","command":"...","timeout_seconds"?:30,"working_directory"?:"/home/ec2-user"} -> {"stdout","stderr","exit_code","truncated"} timeout_seconds defaults to 30 and must be from 1 to 300. Outside that range the error is timeout_out_of_range. working_directory is an optional absolute path. get_balance -> {"available_micro","held_micro","currency":"USD","unit":"micro_usd"} get_usage -> {"spend_micro","held_micro","spend_cap_micro","events":[{"id","machine_id","sku","seconds","amount_micro","created_at"}]} get_usage has no parameters. It is this key only, the same shape as GET /v1/usage. create_checkout_session {"amount_usd":"10.00"|10,"idempotency_key":"...","success_url"?:"...","cancel_url"?:"..."} create_checkout_session calls the same checkout service as POST /v1/checkout/sessions. amount_usd is a JSON number or a string. Exec goes through BillingService.ExecAsync and then the compute provider. The fake provider simulates stdout. The AWS provider runs the command with Systems Manager. Tool errors are ": " using the same codes as the HTTP API, plus {"error":{"code":"...","message":"..."}} with any extra fields inside error. A frozen organization's key is not rejected before the MCP method runs. initialize, ping, and tools/list pass. Protocol 2026-07-28 does not include initialize or ping; those methods still pass on the handshake revisions through 2025-11-25. tools/call for get_balance, get_usage, and stop_machine still runs. Any other tools/call returns a tool result with isError true and code org_frozen. The HTTP status is 200. Protocol: ModelContextProtocol.AspNetCore 2.3.0. A current client negotiates 2026-07-28. Older initialize clients are still accepted. MCP Apps extension io.modelcontextprotocol/ui, spec 2026-01-26. list_machines and get_machine render ui://capvm/machines.html (status, SKU, TTL remaining, estimated cost). The charged amount is final and comes from the server. get_balance and get_usage render ui://capvm/balance.html. A top-up uses the host ui/open-link request. Stripe is not embedded. Those resources are text/html;profile=mcp-app. CSP connectDomains and resourceDomains are empty. The HTML has no external origins and no API keys. exec, create_machine, stop_machine, and create_checkout_session have no UI. Clients that do not negotiate the extension get the same tool results. ## Terms POST /v1/signup starts an account. Body: {"company_name":"Acme GmbH","country":"CH","vat_id":"CHE-123.456.789","terms_acceptance":{"tos_version":"tos-0.2","aup_version":"aup-0.2","accepted_by_email":"person@acme.example"}} Missing terms_acceptance, false, or versions that are not the current material Terms and AUP return 400 terms_acceptance_required. Success creates the organization in pending_acceptance and emails that person one link. The link expires after 72 hours. No API key is created. An agent cannot accept. Repeating POST /v1/signup for that same email while the organization is still pending_acceptance sends a fresh link, marks the previous unused link superseded, and counts toward the same rate limits. The newest company_name, country, and vat_id replace the pending organization's details. Opening a superseded link returns 410 link_superseded. A link that was accepted returns 400 link_used. A link past its expiry returns 410 link_expired. If the email cannot be sent, the new link is void with reason send_failed and the response is 503 email_send_failed with Retry-After, and the request can be retried. The human opens the link. The page shows the account email, the organization's current name, country, and VAT ID, the Terms, AUP, DPA, billing, and privacy versions, and the notice version stored on the link. Those details are read when the page loads. The checkbox is not pre-ticked. Each page load issues a short-lived form token bound to a hash of that snapshot. Acceptance is the form POST with that token and the ticked checkbox. If the current details no longer match the snapshot, the POST returns 409 details_changed, shows the page again, and writes no acceptance row. A matching POST stores the snapshot on the acceptance row. An agent cannot accept. GET and the form POST render 410 link_superseded, 400 link_used, 410 link_expired, form-token errors, 409 details_changed, and an unknown confirmation link as an HTML page that includes the status code and message. Those responses are JSON only when the request Accept header prefers application/json over text/html. Accept: application/json, */* prefers JSON. Posting the form with the checkbox unticked returns 400 and shows the confirmation form again, with "Please tick the box to accept." and a fresh form token. That response is 400 terms_acceptance_required JSON only when Accept prefers application/json. POST /v1/admin/orgs/{id}/acceptance-links emails one link for a first acceptance or a re-acceptance. A new operator link supersedes older unused links for that organization, the same as a new signup link. Opening a superseded link returns 410 link_superseded. A link that was accepted returns 400 link_used. A link past its expiry returns 410 link_expired. A first acceptance always uses signup-checkbox-0.2. reaccept-checkbox-0.2 is only for an organization that already accepted an earlier version. If acceptance is already current, the route returns 409 acceptance_current and sends nothing. If the email cannot be sent, the new link is void and the response is 503 email_send_failed with Retry-After. POST /v1/machines, POST /v1/admin/orgs/{id}/keys, POST /v1/checkout/sessions, and adding credits return 403 terms_not_accepted until that acceptance is current. The error includes required_versions and accept_url. POST /v1/checkout/sessions checks that gate before Stripe is called. checkout_notice_shown is written only after the Checkout Session exists, once for that session. A refusal or a Stripe error writes no row. A retry of the same idempotency key does not write another row. In production those actions are also refused before any Terms version is in effect. A new material version blocks those actions again. Running machines are not stopped. A Checkout Session that is already paid is still credited. An open dispute returns 403 account_suspended for a new machine. While a material change is published but not yet effective, responses include X-CapVM-Terms-Update. Documents: /terms /aup /dpa /billing /privacy ## Top up POST /v1/checkout/sessions Header Idempotency-Key. The same API key and the same value return the same Checkout Session. Another API key with that value gets its own session. A missing or empty key is 400 idempotency_key_required. REST says "Idempotency-Key header is required." MCP says "idempotency_key is required." Leading and trailing whitespace is trimmed, and the stored key is that trimmed value. Length is the trimmed value's UTF-16 code unit count, the same count as String.Length, not Unicode scalar values and not UTF-8 bytes, and must be 1 to 255. A whitespace-only key, a trimmed length outside 1 to 255, or a control character (Char.IsControl) is 400 idempotency_key_invalid. REST says "Idempotency-Key must be 1 to 255 characters after trimming, with no control characters." MCP says "idempotency_key must be 1 to 255 characters after trimming, with no control characters." Over REST, a whitespace-only Idempotency-Key header counts as empty and is idempotency_key_required. An MCP argument is not stripped, so a whitespace-only idempotency_key stays idempotency_key_invalid. Two Idempotency-Key header values are idempotency_key_invalid. An absent header, or a header with no values, is idempotency_key_required. Body: {"amount_usd":"10.00","success_url":"https://example.com/ok","cancel_url":"https://example.com/cancel"} amount_usd is USD. A JSON number must be a whole number of dollars. A string may include cents, with at most two decimal places. Never send a float. Minimum $10. Maximum $1000 unless Stripe:MaximumAmountCents says otherwise. The amount must be an integer number of cents. success_url and cancel_url are optional absolute http(s) URLs. Omitted URLs use Stripe:SuccessUrl and Stripe:CancelUrl. Those default to https://capvm.com/checkout/success and https://capvm.com/checkout/cancel. Returns 201 {"id":"cs_...","url":"https://checkout.stripe.com/..."}. Redirect a human to url. The session is payment mode, currency USD. Prices exclude VAT. Stripe Tax adds VAT. A company name is required. A VAT ID is optional. invoice_creation is enabled. Credits land when Stripe reports the session paid. The credited amount is amount_subtotal, the price excluding tax. Tax is never credited. Replays of the same session id credit once. There is no refunds endpoint. Credits are non-refundable during the beta. Stripe can still refund or dispute a charge. A succeeded refund removes the pre-tax share once per refund id and writes refund_debited. A refund that later fails or is canceled credits that debit back once. Dispute credits are removed when the dispute opens and restored if we win. Inquiries are logged only. An open dispute suspends new machines until it closes. The balance may go negative. Replays do not apply twice. Machines stopped for a dispute or a negative balance are not restarted after a won dispute. POST /v1/stripe/webhook is Stripe's endpoint, not an agent route. It requires Stripe-Signature. It handles checkout.session.completed, checkout.session.async_payment_succeeded, charge.refunded, charge.refund.updated, refund.updated, charge.dispute.created, charge.dispute.updated, charge.dispute.closed, and charge.dispute.funds_reinstated. Dispute credits are removed when the dispute opens and restored if we win. Inquiries are logged only. The webhook marks the organization and does not stop machines inline. Other events return 200 and change nothing. ## Not for agents POST /v1/admin/... is a separate admin token, not an ak_ key. Agents cannot add credits or set a balance. POST /v1/admin/orgs/{id}/trusted {"trusted":true} lifts the new-account caps. Agents cannot call it. OpenAPI: /openapi.json (the REST API only; MCP is https://api.capvm.com/mcp)