BUILD WITH GAP / DOCUMENTATION

GAP Cloud — Instructions for Agents

GAP Cloud is an API-first backend for agent-built applications: projects, KV, objects, SQLite, functions, static sites, custom domains and realtime. Contract commerce, escrow, discovery and arbitration are archived and are not available on the Cloud server.

Start here

Check GET /v1/registration for this node's signup policy. On nodes with verification_required: true, use POST /v1/identity with your email:

curl -sX POST "$NODE/v1/identity" -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]"}'
# -> 202 {"verification_required":true,"challenge_id":"...","expires_in":600}

curl -sX POST "$NODE/v1/identity/verify" -H 'Content-Type: application/json' \
  -d '{"challenge_id":"...","code":"123456"}'
# -> 201 {"did":"did:gap:...","token":"gat_...","email_verified":true}

Use the actual code received by email. Codes expire after ten minutes, allow five attempts, and are consumed once. Requesting a new code invalidates the previous one. Allow 60 seconds between requests; the node limits each email to five requests/hour, each source IP to thirty/hour and all requests to 300/hour. A successful challenge request means the SMTP relay accepted the message, not that the recipient inbox has confirmed delivery. Delivery errors do not issue credentials. If identity persistence fails after code consumption, request a new code. Email ownership verification is not multifactor authentication.

Self-hosted nodes may leave verification disabled: POST /v1/identity then returns an identity directly. Existing identities, project ownership and tokens remain valid when verification is enabled; legacy identities are not retroactively marked email-verified. Human signup is available at /signup on enabled nodes. The email proof registry is local to the node. Where fleet access is enabled, explicitly connect a verified identity and its project to the operator account with POST /v1/fleet/connect using the local owner bearer and {"request_id":"unique-operation","project_id":"prj_..."}. The response includes credential.token, a short-lived control credential. This does not transfer old balances or change the project's billing mode.

Use the control credential on the operator gateway (Elestio: https://gap.geta.team) with GET /v1/fleet/account, /v1/fleet/projects, /v1/fleet/wallet and /v1/fleet/quotas. Projects are restricted to this agent's grants; follow next_cursor as ?after=.... POST /v1/fleet/project-token with {"project_id":"prj_...","ttl_seconds":120} returns a signed management bearer for that exact project and node. Send it to the node where the project resides; it cannot create other projects or administer the node. Owner/operator grants are required; viewer grants do not permit management. Node-local suspension and MicroVM approval checks still apply. Tokens expire after 30–300 seconds and must be renewed, including when used with a dashboard browser session. Logout through POST /v1/fleet/logout blocks new issuance; existing project tokens expire within five minutes. Operator accounts and credentials are independent across operators.

The account page also lists every authorized MicroVM across the configured nodes, with node/project filters and SSH/HTTPS details. Create and manage actions open that project's existing MicroVM dashboard on the selected node. New project placement remains an operator binding; choosing another existing project does not move its VMs. The dashboard keeps project bearers in encrypted HttpOnly server sessions, renewed while the account page is connected.

GET /v1/fleet/nodes returns trusted management path mappings. For an inventory client, request a project token with "read_only":true. The resulting project.vm.read capability permits only GET VM inventory, state, SSH metadata, ports, metrics, runtime, credit and ingress information. Viewer grants can obtain these read capabilities; they cannot obtain management tokens. A read token cannot open a terminal, change state, read project KV or provision a project.

Keep the returned API token server-side and use Authorization: Bearer <token>.

Humans can open /account on the operator gateway to view their common wallet, quotas and projects and manage agent memberships. It redirects to the isolated management origin. Human login uses a separate email challenge; operator-provided account credentials are also accepted without email. Account-wide credentials must not be given to agents. The isolated account page uses a Secure, HttpOnly, SameSite=Strict browser-session cookie, so reloading restores a valid session. Logout revokes the credential and clears the cookie. Server-side session expiry still applies; this is not a permanent login.

For an operator-bound project on another node, POST /v1/cloud/projects/{project}/provision with that project's signed management bearer. This idempotently creates the local project under its existing owner; it does not create an identity or move a workload. Operator binding, billing activation and MicroVM approval remain explicit prerequisites.

Existing identities can verify an email without replacing their DID, bearer, projects or balances. Use the existing local bearer on GET /v1/identity/email to check status. POST /v1/identity/email with {"email":"[email protected]"} sends a one-use code; POST /v1/identity/email/verify with challenge_id and code attaches the verified address. The code is bound to that existing DID and cannot be used for signup or another DID. A verified address cannot be replaced through this endpoint. Existing tokens remain valid during the transition.

Create a project with POST /v1/cloud/projects, then use its returned project_id in the examples below. Never expose the owner bearer in deployed browser code.

Runtime services — use GAP as your backend

If you need state or execution but do not want to operate infrastructure, create an owner-scoped cloud project with POST /v1/cloud/projects. The node provides:

All management routes require your normal agent bearer, and knowing a project identifier grants no access. Do not attempt ATTACH, PRAGMA, arbitrary network access or filesystem access: the runtime refuses them by design.

Functions have a 30-second execution timeout. The 30-second budget covers the entire invocation after admission, including worker replays and waiting for storage/HTTP capabilities; it does not reset after each call. The queue wait described below is separate. Each invocation may dispatch up to 128 capability calls total, including at most 32 HTTP calls. SQL, KV, objects and realtime-token issuance share the total budget; failed calls also count, while replaying an already completed call does not. Limit checks happen before dispatching the excess operation. Prefer SQL batches and retry at the application level only when safe: earlier successful writes are not rolled back if a later call hits a limit or times out.

The sandbox is allocated 4 CPUs, 8 GiB and 4096 PIDs, with at most 256 simultaneous invocations per project and node. A bounded queue holds 256 requests for at most 30 seconds. When it cannot accept an invocation, GAP returns HTTP 429 with {"error":{"code":"sandbox_busy","message":"sandbox is busy"}}; retry with exponential backoff and jitter rather than treating this as a malformed request.

Set these once for every example below:

export NODE=https://gap.geta.team
export TOKEN=gat_your_agent_bearer

Projects — create and list

# Create. Keep the returned project_id; it is used by every other route.
curl -sX POST "$NODE/v1/cloud/projects" \
  -H "Authorization: Bearer $TOKEN"
# -> {"project_id":"prj_...","owner_did":"did:gap:...","status":"active",...}

export PROJECT=prj_returned_above

# List only the projects owned by this bearer.
curl -s "$NODE/v1/cloud/projects" \
  -H "Authorization: Bearer $TOKEN"
# -> {"projects":[...]}

KV — put and get

Values use standard base64. expires_at is an optional Unix timestamp.

curl -sX PUT "$NODE/v1/cloud/projects/$PROJECT/kv/session-42" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value_base64":"eyJzdGF0dXMiOiJhY3RpdmUifQ==","expires_at":1893456000}'
# -> {"stored":true}

curl -s "$NODE/v1/cloud/projects/$PROJECT/kv/session-42" \
  -H "Authorization: Bearer $TOKEN"
# -> {"found":true,"value_base64":"eyJzdGF0dXMiOiJhY3RpdmUifQ=="}
# A missing or expired key returns {"found":false}.

Objects — put and get

curl -sX PUT "$NODE/v1/cloud/projects/$PROJECT/objects/report.json" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content_base64":"eyJvayI6dHJ1ZX0=","media_type":"application/json"}'
# -> {"stored":true,"digest":"sha256:..."}

curl -s "$NODE/v1/cloud/projects/$PROJECT/objects/report.json" \
  -H "Authorization: Bearer $TOKEN"
# -> {"found":true,"content_base64":"eyJvayI6dHJ1ZX0=",
#     "media_type":"application/json","digest":"sha256:..."}

Private static site — configure, deploy and activate

Static hosting is intentionally private-only. There is no public mode: every request below /sites/{project}/ requires the configured HTTP Basic credential. The owner bearer manages releases but is never used by visitors.

Configure the site first. Passwords must contain 12–128 bytes; GAP stores an Argon2id hash and never returns the password or hash. On later updates, omit password to keep the existing credential.

curl -sX PUT "$NODE/v1/cloud/projects/$PROJECT/site" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled":true,"entrypoint":"index.html","spa_fallback":true,
       "auth":{"mode":"basic","username":"visitor",
               "password":"replace-with-at-least-12-bytes"}}'

# Read configuration, retained versions, active version, URL and exact quotas.
curl -s "$NODE/v1/cloud/projects/$PROJECT/site" \
  -H "Authorization: Bearer $TOKEN"

Create a draft version, upload each file as standard base64, then activate the completed version. MIME types come from a server-side extension allowlist; an upload cannot choose its own Content-Type.

VERSION=$(curl -sX POST \
  "$NODE/v1/cloud/projects/$PROJECT/site/versions" \
  -H "Authorization: Bearer $TOKEN" | jq -r .version)

curl -sX PUT \
  "$NODE/v1/cloud/projects/$PROJECT/site/versions/$VERSION/files/index.html" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"content_base64":"PCFkb2N0eXBlIGh0bWw+PGh0bWw+PGJvZHk+PGgxPkhlbGxvPC9oMT48L2JvZHk+PC9odG1sPg=="}'

# Inspect the draft manifest or remove one draft file.
curl -s "$NODE/v1/cloud/projects/$PROJECT/site/versions/$VERSION/files" \
  -H "Authorization: Bearer $TOKEN"
curl -sX DELETE \
  "$NODE/v1/cloud/projects/$PROJECT/site/versions/$VERSION/files/obsolete.css" \
  -H "Authorization: Bearer $TOKEN"

curl -sX POST \
  "$NODE/v1/cloud/projects/$PROJECT/site/versions/$VERSION/activate" \
  -H "Authorization: Bearer $TOKEN"

# The browser receives 401 + WWW-Authenticate until credentials are supplied.
curl -u 'visitor:replace-with-at-least-12-bytes' \
  "$NODE/sites/$PROJECT/"

An activated version is immutable and activation fails unless its entrypoint exists. Create the next version for an update; activation switches every path atomically. Delete only inactive versions:

curl -sX DELETE \
  "$NODE/v1/cloud/projects/$PROJECT/site/versions/1" \
  -H "Authorization: Bearer $TOKEN"

Allowed assets are HTML, CSS, JavaScript modules, JSON, text, XML, SVG, common web images and web fonts. GAP rejects hidden/path-traversal names, executables, oversized files and excess project storage. Static uploads do not run AI judgement or content-pattern scans. Minified and encoded content is accepted; path, type, size and project quotas still apply. Acceptance is not a security certification: never ship real secrets in browser assets. Server functions likewise have no publication judge or content scan. Every HTML response on the GAP-owned /sites/{project}/ URL receives a non-removable "Hosted by GAP - private agent project" banner. Those responses use private, no-store, nosniff, noindex, nofollow, noarchive, no referrer, same-origin resource policy and a media-compatible CSP. Browser fetch/XHR connections may use any HTTPS origin, and WebSockets may use any WSS origin. Video/audio may use HTTPS or blob: URLs; embedded frames may use HTTPS, and workers may use same-origin or blob: URLs. This supports external players and HLS/MSE playback. Bundle player libraries (such as hls.js) as uploaded JavaScript files: external script CDNs, inline JavaScript and eval remain blocked. Plugins, embedding the GAP page inside another page, and cross-origin form submission also remain blocked. Images may use HTTPS, data: or blob: URLs; insecure HTTP resources remain blocked. Referrer-Policy: no-referrer prevents the private site URL and credentials from being sent as an image request referrer. Put configuration and application code in uploaded .js files rather than inline <script> elements.

These permissions apply to browsers only: function sandbox egress restrictions are unchanged. External servers must still allow CORS for fetch-based players; their framing policies and the browser's codecs/DRM also still apply. CSP does not guarantee playback or remove advertising inside external players. Sites under /sites/ share the gap.geta.team origin and browser storage; neither Basic Auth nor this CSP isolates localStorage between project paths. Never put owner bearers there. Use a dedicated custom origin per project for browser-storage isolation.

Free projects receive 5 MiB (5,242,880 bytes) per file, 100 MiB across retained versions, 5,000 files, 5 versions, 20 requests/second and 1 GiB per rolling 30-day period. Delete an inactive release to reclaim both its storage and version slot.

The upload API uses base64: a full-size file encodes to about 6.7 MiB, plus JSON overhead, within the default 10 MiB HTTP request limit. Object storage remains 1 MiB per object; function versions also allow 5 MiB.

Custom site domains

A free project may attach up to three domains. A verified custom domain can be public, while the GAP-owned /sites/{project}/ address always keeps Basic Auth. Use an ASCII hostname; encode internationalized names as Punycode.

# Register the hostname and choose public or basic access.
DOMAIN=$(curl -sX POST \
  "$NODE/v1/cloud/projects/$PROJECT/site/domains" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"hostname":"movies.example.com","access":"public"}')

echo "$DOMAIN" | jq .dns
# Add the returned TXT record verbatim. Then point the hostname at the
# returned target with A/AAAA, or with a CNAME when one is supplied. Cloudflare
# orange-cloud proxying is supported, including Flexible SSL; DNS-only gives
# Caddy end-to-end TLS directly, while Full (strict) is preferred when proxied.

curl -sX POST \
  "$NODE/v1/cloud/projects/$PROJECT/site/domains/movies.example.com/verify" \
  -H "Authorization: Bearer $TOKEN"

# List status, access mode and verification details.
curl -s "$NODE/v1/cloud/projects/$PROJECT/site/domains" \
  -H "Authorization: Bearer $TOKEN"

# Detach immediately. Caddy will refuse future certificate issuance and GAP
# stops routing the hostname even if an old certificate remains cached.
curl -sX DELETE \
  "$NODE/v1/cloud/projects/$PROJECT/site/domains/movies.example.com" \
  -H "Authorization: Bearer $TOKEN"

The TXT record is a project-specific ownership proof. DNS pointing alone is not enough: otherwise one agent could claim somebody else's hostname that was already aimed at GAP. Verification activates the exact hostname only; wildcard domains and IP literals are rejected. Caddy's internal ask endpoint also requires a shared secret and returns success only for an active mapping.

Cloudflare-proxied domains are accepted without an HTTPS redirect loop even in Flexible mode. GAP's Caddy edge honours Cloudflare's HTTPS CF-Visitor signal exclusively from Cloudflare's published IP ranges, so a direct caller cannot spoof that exception. Full (strict) is still recommended because it also encrypts the Cloudflare-to-origin connection.

Custom-domain pages are served from /, preserve SPA fallback, omit the GAP private-project banner, retain the same path/type/quota/rate/bandwidth controls, and use the same media-compatible CSP described above, including HTTPS/WSS connections to GAP or external services and HTTPS embedded players. Public domains may be indexed and cache for at most 60 seconds; basic domains keep noindex and private, no-store.

The verified hostname also exposes project-bound, same-origin aliases:

ANY https://movies.example.com/_gap/functions/{function}/{path...}
WS  wss://movies.example.com/_gap/realtime

The function alias is equivalent to https://gap.geta.team/functions/{project}/{function}/{path...}, including its public, scoped-token or owner authentication policy, method, query and body. The project id is taken exclusively from the verified hostname and must not be included in the alias URL. Management endpoints remain on gap.geta.team and still require the owner bearer.

const categories = await fetch('/_gap/functions/movix/categories').then(r => r.json());
const wsScheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${wsScheme}//${location.host}/_gap/realtime`);

The WebSocket wire protocol and token format are unchanged. During authentication GAP verifies that the token's signed project_id matches the project attached to the hostname. Removing the domain, disabling its site or suspending its project disables both aliases immediately. /_gap/ is reserved by the platform and cannot be shadowed by the site's SPA fallback.

SQLite — execute and query

Use execute for schema changes and mutations, query for rows. Always bind untrusted input through params; never concatenate it into SQL. A binary parameter is encoded as {"blob_base64":"..."}.

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/database/execute" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sql":"CREATE TABLE messages(id INTEGER PRIMARY KEY, body TEXT NOT NULL)","params":[]}'
# -> {"affected_rows":0,...}

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/database/execute" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sql":"INSERT INTO messages(body) VALUES (?)","params":["hello"]}'
# -> {"affected_rows":1,...}

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/database/query" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sql":"SELECT id, body FROM messages WHERE id > ? ORDER BY id","params":[0]}'
# -> {"columns":["id","body"],"rows":[[1,"hello"]],"truncated":false,...}

Only one statement is accepted per call. GAP refuses client-managed transactions, ATTACH, DETACH, PRAGMA, temporary schemas and virtual tables; retrying those statements will not make them valid.

Each SQL call accepts at most 1,000 bound parameters, through both the management API and function bindings gap.db.query / gap.db.execute. The combined parameter payload is capped at 4 MiB: UTF-8 bytes for text, decoded bytes for blobs, 8 bytes for numbers/booleans and 0 for null. The existing 1 MiB limit per text/blob parameter still applies. JSON/base64 transport overhead and sandbox message limits may impose a lower practical batch size. Oversized batches are rejected before SQL execution; errors report the received parameter count or cumulative byte size and the allowed maximum.

For bulk inserts, use bound placeholders and batches of at most Math.floor(1000 / parametersPerRow) rows (subtract any statement-level parameters first), also respecting the byte budget. For example, 10 values per row permit up to 100 rows per call. Do not interpolate values into SQL to evade the limit. SQL text remains limited to 32 KiB; query results to 100 rows, 50 columns and 4 MiB. The 250 ms SQL execution budget and 100 MiB project database quota are unchanged. A function still has a bounded capability-call budget, so do not split an import into arbitrarily many tiny calls in one invocation.

Functions — deploy, activate, invoke and delete

The deployed source is a JavaScript function expression. It receives the JSON request as its first argument and returns a JSON-serializable result.

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/functions/greet" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"runtime":"javascript","source":"async (request, gap) => ({ message: `Hello ${request.name}` })"}'
# -> {"name":"greet","version":1,"runtime":"javascript","digest":"sha256:...",
#     "ruling":"approved","security_review":{"judge":"none",
#     "static_findings":[],"reasons":[]},...}

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/functions/greet/activate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"version":1}'
# -> {"active":true,"version":1}

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/functions/greet/invoke" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"request":{"name":"Ada"}}'
# -> {"result":{"message":"Hello Ada"},"version":1,"digest":"sha256:..."}

# Deploying again creates version 2 but leaves version 1 active.
curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/functions/greet" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"runtime":"javascript","source":"async (request) => ({ message: `Hi ${request.name}` })"}'
# -> {"name":"greet","version":2,"active":false,...}

# Delete that inactive version. Deleting active version 1 would be refused.
curl -sX DELETE \
  "$NODE/v1/cloud/projects/$PROJECT/functions/greet/versions/2" \
  -H "Authorization: Bearer $TOKEN"
# -> {"deleted":true,"name":"greet","version":2}

# Delete the function and every version, including the active one.
curl -sX DELETE "$NODE/v1/cloud/projects/$PROJECT/functions/greet" \
  -H "Authorization: Bearer $TOKEN"
# -> {"deleted":true,"name":"greet"}
# Repeating the same DELETE is safe and returns {"deleted":false,...}.

Deploying creates a new immutable version; it does not switch production. Publication applies no AI judge or content-pattern scan. It still checks the owner, project state, JavaScript UTF-8, runtime, source size and storage quota. One publication runs at a time per node; concurrent attempts return HTTP 429 with error code publication_busy. Retry with backoff and jitter. Ownership, project status and storage quotas are rechecked before saving.

Activate the exact published version explicitly. The sandbox exposes no process environment, filesystem handle, database path, project bearer or arbitrary network access. It offers atob, btoa, TextEncoder, TextDecoder, URL parsing, URLSearchParams, crypto.randomUUID, crypto.getRandomValues, setTimeout, clearTimeout and queueMicrotask alongside standard JavaScript. Deleting source releases its function-storage quota immediately. Prefer the version endpoint for cleanup; use the function endpoint when the deployed name itself is no longer needed.

Function bindings, HTTP egress and browser routes

Functions may call project storage without receiving its owner token:

async (request, gap) => {
  await gap.kv.put("last-search", request.query);
  const cached = await gap.kv.get("last-search");
  await gap.db.execute("CREATE TABLE IF NOT EXISTS hits(q TEXT)");
  await gap.db.execute("INSERT INTO hits(q) VALUES(?)", [cached]);
  const rows = await gap.db.query("SELECT q FROM hits ORDER BY rowid DESC LIMIT 10");
  await gap.objects.put("hits.json", JSON.stringify(rows), "application/json");
  const object = await gap.objects.get("hits.json");
  return { rows, object };
}

Outbound HTTP is brokered by GAP, limited to HTTPS GET/POST, a 30-second timeout, 3 MiB responses and the headers Accept, Content-Type, Cookie and User-Agent. Configure exact hosts first; redirects, private/link-local addresses and unlisted hosts are refused:

There is no separate capability-grant endpoint: the project's /egress allowlist is the grant for gap.http. An approved_with_constraints release ruling means the function must remain inside these runtime constraints; it does not disable http.request.

curl -sX PUT "$NODE/v1/cloud/projects/$PROJECT/egress" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"hosts":["witozo.com"]}'
curl -s "$NODE/v1/cloud/projects/$PROJECT/egress" -H "Authorization: Bearer $TOKEN"
async (request, gap) => gap.http.get("https://witozo.com/films", {
  headers: { "User-Agent": "Mozilla/5.0", "Cookie": "g=true" }
})

Expose a function as a browser endpoint. public needs no credential; token accepts a scoped token valid for 60 minutes; private accepts only the owner bearer. Never embed the owner bearer in a site:

curl -sX PUT "$NODE/v1/cloud/projects/$PROJECT/functions/greet/http" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"auth":"token","cors_origins":["*"]}'

INVOKE_TOKEN=$(curl -sX POST \
  "$NODE/v1/cloud/projects/$PROJECT/functions/greet/tokens" \
  -H "Authorization: Bearer $TOKEN" | jq -r .token)

curl -s "$NODE/functions/$PROJECT/greet/categories?q=recent" \
  -H "Authorization: Bearer $INVOKE_TOKEN"

The handler receives {method,path,query,body}. GAP answers CORS preflights; the public route is /functions/{project}/{function}/{path...}.

Scheduled functions

The initial cron subset supports minute intervals */N * * * *, from 1 to 1440 minutes. Create/update by id, list, and delete:

curl -sX PUT "$NODE/v1/cloud/projects/$PROJECT/schedules/refresh-cache" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"function":"refresh","cron":"*/15 * * * *","request":{"source":"cron"}}'
curl -s "$NODE/v1/cloud/projects/$PROJECT/schedules" -H "Authorization: Bearer $TOKEN"
curl -sX DELETE "$NODE/v1/cloud/projects/$PROJECT/schedules/refresh-cache" \
  -H "Authorization: Bearer $TOKEN"

Realtime token — issue from a trusted backend

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/realtime/tokens" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"channels":["room:customer-42"],
       "permissions":["subscribe","publish"],
       "subject":"visitor:8f31"}'
# -> {"token":"base64url-claims.hmac-signature","expires_at":...}

The returned token lasts 60 minutes. permissions may contain subscribe, publish, or both. Omitting it grants both for backward compatibility. An empty channels array grants every channel in the project; do not issue that scope to public clients.

A GAP function is itself a trusted token backend without ever receiving the owner bearer or realtime signing secret. Prefer this native capability over storing a bearer in KV or attempting to pass Authorization through gap.http (that header remains forbidden):

async (request, gap) => {
  // Authenticate/authorize request.user in your application logic first.
  return await gap.realtime.issueToken({
    channels: [`room:${request.room}`],
    permissions: ["subscribe", "publish"],
    subject: `visitor:${request.user}`,
    expires_in: 600
  });
}

channels is mandatory and must contain 1–25 explicit scopes for tokens issued by functions. permissions defaults to both permissions, subject is optional, and expires_in must be between 60 and 3600 seconds. GAP injects the project scope, signs internally and audits the issuance.

WebSocket — every client action

Open wss://gap.geta.team/v1/realtime, or wss://your-domain/_gap/realtime on a verified custom domain. The wire protocol is JSON. Authenticate within five seconds, then subscribe before publishing to a channel.

{"action":"authenticate","token":"TOKEN_RETURNED_ABOVE"}
{"type":"authenticated","project_id":"prj_...","subject":"visitor:8f31",
 "permissions":["subscribe","publish"],"expires_at":1893456000}

Subscribe and optionally replay up to 100 persisted messages after a known sequence cursor:

{"action":"subscribe","channel":"room:customer-42","after":1042}
{"type":"subscribed","channel":"room:customer-42"}

Publish an ephemeral message, or set persist to retain it for at most 24 hours:

{"action":"publish","channel":"room:customer-42",
 "payload":{"kind":"status","value":"ready"},"persist":true}

Subscribers receive:

{"type":"message","channel":"room:customer-42","seq":1043,
 "payload":{"kind":"status","value":"ready"},"created_at":1893452400,
 "replay":false}

Replayed messages carry "replay":true. Ephemeral messages have "seq":null. Unsubscribe without closing the socket:

{"action":"unsubscribe","channel":"room:customer-42"}
{"type":"unsubscribed","channel":"room:customer-42"}

Protocol or quota failures arrive as {"type":"error","error":"..."}. A client must stop or back off on errors such as hard message rate exceeded, renew after token expired, and never reconnect in a tight loop.

Realtime credits — controlled overage

The free limits remain available with a zero balance. Beyond them, GAP debits:

One action that crosses several boundaries is charged atomically: it either receives all required credits or none. Credits never bypass the hard safety limits: 100 connections, 100 channels, 256 KiB per payload, 300 actions/minute per connection, 3,000/minute per project and 100 MiB persisted. Retention stays at 24 hours. Exhaustion returns a protocol error; a paid connection that cannot renew its hourly credit is closed with code 4402.

The owner can inspect its balance, aggregate spend by reason and the latest 100 top-ups:

curl -s "$NODE/v1/cloud/projects/$PROJECT/realtime/credits" \
  -H "Authorization: Bearer $TOKEN"

Only the GAP operator can top up. idempotency_key is mandatory, scoped to the project and safe to replay with exactly the same amount and note:

curl -sX POST "$NODE/v1/admin/cloud/projects/$PROJECT/realtime/credits" \
  -H "Authorization: Bearer $GAP_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount":10000,"idempotency_key":"manual-2026-001","note":"manual grant"}'

Top-ups are persisted in ClickHouse and written to the internal ordered event log. The realtime sidecar alone may debit the account through its internal authenticated route; project owners cannot forge or refund consumption.

Realtime for a static site

Your browser connects to wss://gap.geta.team/v1/realtime, but it must never receive the permanent project bearer. Put sdk/realtime-token-handler.js in a server-side or edge function; authenticate the visitor there and return only a 60-minute token with explicit channels, permissions and a subject:

{
  "channels": ["room:customer-42"],
  "permissions": ["subscribe", "publish"],
  "subject": "visitor:8f31"
}

Use subscribe alone for read-only visitors. Prefer a narrow channel per room, contract or tenant; an empty channel list means every channel in the project and is unsuitable for public clients. Browser integration is the dependency-free sdk/realtime.js, which renews through your token provider, reconnects and restores subscriptions.

MicroVMs — experimental

Opt-in on public or private nodes; operator approval required on gap.geta.team. A microVM is a full Linux machine with CPU, RAM, persistent disk, SSH and networking. Run compiled binaries, Python/Node.js programs, scripts or system services directly. Docker and Compose are optional. Neither publication, public ports nor the GAP_* environment requires a container or Compose release.

Each project can own multiple managed microVMs, within the owner's node quota. The same microVM access approval covers its lifecycle, SSH, networking and optional Compose operations. Public Cloud access alone does not grant microVM access; ask the operator to approve your exact agent DID. Private nodes additionally require general node approval. See operator setup.

Use /v1/cloud/projects/{project}/vm for the machine, /vm/ports, /vm/ssh and /vm/ingress for its network, and /vm/jobs/{job_id} to poll operations. /stack/releases, /stack/start, /stack/stop, /stack/status and /stack/logs operate only on the optional Docker Compose stack inside the VM. For native programs, manage processes and logs over SSH using Linux tools or a service supervisor. A running VM does not imply that a Compose stack exists.

The VM provides guest root, not host root. Guest Docker bind mounts, privileged containers and host networking refer to the guest only. No host control socket or owner bearer is injected. Agent CPU/RAM allocation quotas apply below; no GAP egress filtering is applied. Existing Cloud API quotas remain unchanged. Unrestricted guest networking can reach internal services, and workloads can affect host availability without resource safeguards.

Compose runs from /var/lib/gap-data/${GAP_PROJECT_ID} inside the selected VM. Use project-relative bind mounts such as ./mariadb:/var/lib/mysql and ./wordpress:/var/www/html for persistent data that is easy to inspect, back up and restore. The worker stores immutable release inputs separately. Do not bind persistent data below /var/lib/gap-compose/releases: those directories belong to the release journal. Stop or quiesce databases, or use their dump tools, before copying live data directories.

On the first release after a worker upgrade, GAP may atomically refresh its fixed guest helper before validating the bundle. The temporary update key is removed before application deployment begins; configured owner keys are restored unchanged.

Each approved agent has a cumulative allocation quota of 1 VM, 2 vCPUs and 4096 MiB RAM by default, shared across all its projects. Running, hibernated, stopped and partially created VMs count; destroying a VM releases its CPU/RAM allocation. An optional operator disk allocation quota may also apply. Allocate the minimum your workload needs (default VM: 1 vCPU, 1024 MiB RAM, 8 GiB disk), measure usage, then resize only when necessary. Do not reserve the full quota for every project.

GET /v1/cloud/projects/{project}/vm includes agent_quota.limits and agent_quota.allocated, even when that project has no VM. Creation, growth and start check the live quota; jobs report agent_quota_exceeded_vcpus or agent_quota_exceeded_memory_mib or agent_quota_exceeded_max_vms when blocked. Lowering a quota does not kill existing workloads; reductions, stop and destroy remain available. CPU/RAM quotas are allocations per agent, not a host-wide capacity reservation.

CPU/RAM resize and disk growth require stop → PATCH /vm → start. There is no hot resource resize. Public port mappings and SSH keys can change while running.

Humans can create a VM at /microvms after connecting their project with its owner token. The form defaults to 1 vCPU, 1024 MiB RAM, 8 GiB disk and started state, with an optional SSH public key. The machine selector switches between VMs in the same project. New microVM creates another machine; Delete requires its complete VM ID and offers retained storage (still billed) or permanent erasure. Running machines stop gracefully before deletion. Raising --max-vms allows additional VMs in the same project or across that agent's projects on this node.

GET /v1/cloud/projects/{project}/vms lists active allocations and the agent quota. POST /v1/cloud/projects/{project}/vms creates another VM with the same creation body and idempotent request_id as /vm. Select a VM for read operations with ?vm_id=vm_<32hex> on /vm, /vm/runtime, /vm/ingress, /vm/ports or /vm/ssh. Mutations continue to require vm_id in their JSON body. Legacy /vm and /stack without a selector target the default VM. For every Compose POST (releases, start, stop, status, logs), include vm_id beside request_id in the JSON body. A ?vm_id=... query selector is also accepted; conflicting body/query IDs are rejected. The submitted guest bundle still contains only request_id, compose_file and files: the worker consumes the VM selector and supplies the authenticated project identity before forwarding it. Deleting the default VM does not promote another VM. List /vms and select an existing VM explicitly; never recreate or delete a working VM just to obtain the default slot. Additional VMs have separate /apps/{vm_id}/ ingress paths, SSH identities, disks and five-port allocations. The project balance, spending budget and 72-hour storage retention deadline are shared across its VMs. Deleting one VM leaves the others running. This is not yet a fleet-wide customer quota.

Operator command (on the node host):

python3 scripts/microvm-access.py grant did:gap:<64-hex-agent-identity>
python3 scripts/microvm-access.py set-quota did:gap:<64-hex-agent-identity> --vcpus 2 --memory-mib 4096 --max-vms 1
python3 scripts/microvm-access.py revoke did:gap:<64-hex-agent-identity>
python3 scripts/microvm-access.py list

Agent approvals and quota updates take effect immediately without restarting the node or worker. The infrastructure is configured once; grant/revoke never changes environment variables. Approval covers the agent's projects and does not create or publish an application. Only the operator can run these host commands.

Serverless execution, credits and retention

Eligible email-verified customers receive a single $1 promotional Free Trial. The grant is also limited to one customer per public IPv4 address or IPv6 /64; another account from a network that already claimed it is created normally but starts without promotional credit. The control authority stores only a keyed digest of that network identity. The trial is a promotion, not an entitlement; funding the common wallet removes the promotional-credit dependency. Network usage is metered in both directions and consumes the same balance.

On workers with serverless enabled, a VM defaults to serverless with 15 minutes of inbound inactivity before disk hibernation. QEMU exits and releases its RAM; the disk snapshot preserves execution state. The next HTTP/API/WebSocket request, TCP/SSH connection or UDP datagram restores it. Concurrent requests share one restore. Applications can run native binaries; Docker is optional.

Only incoming application activity resets the timer. Outbound traffic, background jobs, internal health checks and management GET requests do not. An HTTP request in progress prevents idle hibernation. TCP/SSH/WebSocket connections with no incoming data do not: they can be disconnected and clients must reconnect. Protocol keepalives carrying incoming data count as activity. A publicly exposed port can be kept awake by visitors; use application authentication and a budget. A manually stopped VM requires explicit start. If an interrupted resume leaves the VM stopped without a manual-stop marker, the next routed request performs a controlled cold start instead of leaving the application permanently unavailable. The request that wakes a serverless VM remains pending while the restore completes and for up to 90 seconds while the application listener becomes ready. Concurrent first requests share that restore. Do not add client retries for the ordinary wake interval; retry only a returned failure. Unchanged SSH keys add no probe to the normal resume path; keys changed while hibernated are applied before traffic flows.

Configure always_on only for workloads that need uninterrupted background work. It requires an additional operator grant per agent, checked again at wake and periodically while running. CPU/RAM are charged continuously while ON. Losing this permission returns the VM to serverless mode. The idle timeout is configurable from 1 to 60 minutes; 15 is the default. Always-on does not bypass credit or host capacity checks. Hibernated VMs still count toward the agent allocation quota.

python3 scripts/microvm.py --project "$PROJECT" runtime
python3 scripts/microvm.py --project "$PROJECT" credits
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" set-runtime --mode serverless --idle-minutes 15
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" set-runtime --mode always_on
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" hibernate
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" resume
python3 scripts/microvm.py --project "$PROJECT" set-budget --microcredits 10000000
# Set/reset a budget from now, or remove the execution threshold:
python3 scripts/microvm.py --project "$PROJECT" set-budget --unlimited

API: GET /vm/runtime, PUT /vm/runtime with request_id, vm_id, mode and optional idle_timeout_seconds; POST /vm/hibernate and /vm/resume with request_id and vm_id. Writes above return jobs. GET /vm/credits and GET /vm/budget return the account. PUT /vm/budget accepts request_id and budget_microcredits (positive integer or null) and responds synchronously. Reuse request IDs after lost responses. Routes are relative to your project. The owner-only microVM console shows these controls and usage.

One credit is 1,000,000 microcredits. The microVM account belongs to the project and is separate from existing Realtime credits and legacy contract escrow. GAP hosted pricing uses 1 credit = USD 1 (1,000,000 microcredits): USD 0.010/vCPU-hour, USD 0.010/GiB RAM-hour, USD 0.10/GB disk-month, and USD 0.01/GB in each network direction. CPU/RAM bill only while ON; physical stored data includes hibernation snapshots and remains billable while OFF. A disk-month means 730 hours, prorated by elapsed time. GB means 1,000,000,000 bytes; GiB means 1,073,741,824 bytes. Conversions and fractional carry are exact. The operator sets immutable tariff versions. Host counters measure both directions, including guest control traffic; they are independent of guest-reported usage. CPU utilization percentage is not the price basis. CPU/RAM cost stops when QEMU exits; retained storage remains billable. Fractions carry between samples: there is no whole-minute rounding.

shadow records usage and estimates without debiting or blocking for zero credit; enforced debits prepaid credits. Check billing_mode and tariff in the account: a missing tariff means pricing is not configured, not that hosting is permanently free. Usage checkpoints normally run every 5 seconds and at lifecycle changes. The billing ledger accumulates these measurements into one row per VM state period, starting another row on a state, allocation, execution mode or tariff change. Credit checks keep their five-second cadence. Historical raw measurements are archived locally and grouped as historical compute ON/OFF because older records did not distinguish stopped from hibernated. Existing balances are preserved. Budget alerts appear at 80% and exhaustion. The budget is an execution stop threshold, not a hard final invoice cap: an in-flight metering interval can cross it and retained disk continues to consume prepaid credits after execution stops. A budget reset starts a new spending period; a top-up does not reset the budget.

At zero prepaid credit in enforced mode, execution is blocked and storage is retained for exactly 72 hours. delete_after gives the deadline. A recharge before deletion is claimed cancels that deadline. After 72 hours, the worker claims deletion and removes the VM disk, snapshots and attributable retained volumes; a late recharge cannot undo it. The next scheduler pass performs physical deletion; a worker outage delays execution, not the deadline. Retention is not a backup service. Export anything you need before expiry. Budget exhaustion alone with a positive balance does not start the 72-hour deletion timer.

Resume requires the same guest image and QEMU version used for the snapshot. Keep those assets pinned until snapshots are resumed or intentionally discarded. CPU/RAM/disk resize still requires resume if hibernated, then stop, resize, start. Use the minimum resources; a simultaneous wake may return capacity unavailable when the host reserve would be exhausted. Automatic restoration does not preserve external connections across inactivity; reconnect at the application layer.

Native application quickstart

No Docker build or Compose release is needed for this workflow. The CLI uses GAP_TOKEN for management; SSH uses your own Ed25519 or RSA public key.

export GAP_TOKEN="$TOKEN"
python3 scripts/microvm.py --project "$PROJECT" create --key ~/.ssh/id_ed25519.pub --guest-port 8000 --stopped
# Poll the returned job, then save result.vm.vm_id as VM.
python3 scripts/microvm.py --project "$PROJECT" job job_returned_above
export VM=vm_returned_by_the_job
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" set-ports --map 1:22:tcp
# Poll each mutation before submitting the next.
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" set-ingress --guest-port 8000
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" start
# Poll start, then probe readiness and poll its job. Native apps need guest_ready.
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" readiness
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" ssh
# Connect using the returned command and verify its host fingerprint.

Inside the guest, a simple native HTTP server can run immediately:

mkdir -p /root/www
cd /root/www
gap-env sh -c 'exec python3 -m http.server "$GAP_HTTP_PORT" --bind 0.0.0.0'

Before visiting the URL, configure visitor credentials using the HTTP access step below. Read /vm/ingress?vm_id=$VM for the exact URL; additional VMs use /apps/{vm_id}/. No container is required. This foreground example ends with the session; use OpenRC or another process supervisor for a persistent service. For your own Rust/Go binary, upload it with SCP/SFTP, mark it executable and run gap-env ./my-server. Configure the program to listen on GAP_HTTP_PORT and support GAP_BASE_PATH as appropriate. Stopping the VM stops all its processes; stopping an optional Compose stack leaves native processes running.

Managed app quickstart — optional Docker/Compose

Compose is for long-running Docker applications, including multi-service stacks and persistent volumes. Functions remain the lightweight, time-bounded JavaScript runtime. MicroVM access requires operator approval, even on a public node. On the public deployment, only explicitly approved agents can use it.

The complete flow is: create a project, create its microVM, deploy a Compose release, configure visitor Basic Auth, then enable its application route. No additional DNS record or TLS certificate is needed: visitors use /apps/{project_id}/ on the existing node.

# NODE, TOKEN and PROJECT come from the identity/project quickstart above.
# Save each request body and reuse its request_id when retrying that operation.
curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/vm" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"request_id":"11111111111111111111111111111111","vcpus":1,"memory_mib":1024,"disk_gib":8,"ports":[8000]}'
# -> 202 with job_id; poll /vm/jobs/{job_id} until it finishes.
# Save result.vm.vm_id as VM. QEMU running does not yet mean Docker is ready.
export VM=vm_returned_by_the_job

Wait for the explicit availability probe before deploying:

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/vm/readiness" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"request_id\":\"$(openssl rand -hex 16)\",\"vm_id\":\"$VM\"}"
# Poll /vm/jobs/{job_id}. result.ready must be true for Compose.
# If false, wait two seconds and submit a new probe with a fresh request ID.
# Bound this wait (for example 180 seconds); do not delete the VM on timeout.

running describes QEMU only. The readiness job returns guest_ready (the restricted SSH helper answered), docker_ready, ready and reason. A successful probe job can still have ready: false; inspect its result. The probe never starts/resumes a stopped/hibernated VM, installs anything or checks application health. Start or resume explicitly first. Native apps need guest readiness but do not require Docker. SSH authentication or host-key failures require operator diagnosis, not more RAM or repeated VM replacement.

Deploy your bundle with /stack/releases and vm_id in the JSON body as shown in the next section and poll that job. The app must listen on the guest port you intend to route, for example ports: ["8000:8000"] in Compose. Once it is running, publish its route:

# Set VISITOR_USER and a unique VISITOR_PASSWORD of 12-128 bytes first.
# Required for ALL shared GAP /apps URLs. Use distinct visitor credentials,
# never the owner bearer. Store the request securely if it contains a password.
umask 077
jq -n --arg vm "$VM" --arg user "$VISITOR_USER" --arg password "$VISITOR_PASSWORD" \
  '{vm_id:$vm,username:$user,password:$password}' | \
  curl -sX PUT "$NODE/v1/cloud/projects/$PROJECT/vm/http-access" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" --data-binary @-

curl -sX PUT "$NODE/v1/cloud/projects/$PROJECT/vm/ingress" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"request_id\":\"22222222222222222222222222222222\",\"vm_id\":\"$VM\",\"enabled\":true,\"guest_port\":8000}"
# Poll the returned job, then inspect the resulting URL:
curl -s "$NODE/v1/cloud/projects/$PROJECT/vm/ingress?vm_id=$VM" \
  -H "Authorization: Bearer $TOKEN"
# -> url: https://gap.geta.team/apps/prj_<project-id>/
#    base_path: /apps/prj_<project-id>/

/apps/{project_id}/api/items?x=1 reaches the guest as /api/items?x=1. HTTP methods, bodies, queries, streaming and WebSocket upgrades are forwarded. The gateway supplies X-Forwarded-Prefix; configure the app's public base path and cookie path, or use relative links. Root-relative assets and redirects are not automatically rewritten. Apps share the node's browser origin; keep owner bearers server-side. The URL is reachable from the Internet but requires GAP visitor Basic Auth, separately from any application login. Anonymous access requires a verified custom domain. routed describes worker routing, http_access.configured describes visitor credentials, and access_ready combines both. Missing steps are listed in blocking_reasons. application_health: "not_checked" means an HTTP check is still required. With no visitor credentials, a configured route can return https_route_unavailable; do not keep changing the application port.

For a complete, resumable example covering creation through administrator login, see WordPress on GAP.

Manage the microVM and publication

Every mutation below requires a fresh request_id; all except creation also require the exact vm_id. Mutations return jobs to poll with the same API as releases. A stale VM ID cannot mutate a replacement VM.

Method and project suffixAdditional body fieldsResult
GET /vmnoneInspect VM state and allocated resources
POST /vmoptional vcpus, memory_mib, disk_gib, ports, start, ssh_keysCreate; starts by default
POST /vm/stopoptional force: trueShut down VM; force explicitly quits it
PATCH /vmselected vcpus, memory_mib, disk_gib, portsReconfigure while stopped; disk growth only
POST /vm/startnoneRestart the same VM with its data
DELETE /vmoptional delete_data: true, confirm_data_loss: trueDestroy stopped VM; retain data by default
GET /vm/ingressnoneInspect publication and application URL
PUT /vm/ingressenabled: true, guest_portPublish any guest application port, creating its forward on demand
PUT /vm/ingressenabled: falseWithdraw publication; omit guest_port

POST /stack/stop stops application containers; POST /vm/stop stops the whole VM. VM stop/delete withdraws its route; restarting the same VM restores an enabled route. Named volumes survive stops, updates and VM resizing. Explicit data deletion is irreversible. Retained disks do not have an automated restore API. Approval revocation fences VM execution and forwarding through the worker policy watchdog.

Direct SSH and five public TCP/UDP ports

A managed microVM is a full Linux environment: Compose is optional. Agents can use root SSH, SFTP/SCP, install tools and run services directly. When public networking is configured, creation reserves five public port numbers per VM. Each slot supports TCP, UDP or both using the same number. No listener is enabled until you configure a mapping. HTTPS/API/WebSocket publication under /apps/{project_id}/ is separate and consumes no slots.

Use sites.gap.geta.team for direct SSH/TCP/UDP. gap.geta.team is behind Cloudflare and remains the HTTPS management/application origin. GAP chooses public ports; agents choose only a slot (1–5), guest port and protocol. Two slots cannot both forward UDP to the same guest port; use distinct guest ports so replies return through the correct public endpoint. A TCP mapping to guest port 22 consumes one slot and provides direct SSH with no bastion. Free Trial accounts can expose only this mapping, require a 5-60 minute expiry, and receive keys constrained with restrict,pty; application mappings, TCP forwarding, agent forwarding and X11 forwarding remain disabled. Approved accounts retain all five slots. Do not use example port numbers as allocations: read the API response.

Method and project suffixBody besides request_id and vm_idBehavior
GET /vm/portsnone; no body neededFive allocated numbers, mappings and routing state
PUT /vm/portsmappings: [{"slot":1,"guest_port":22,"protocol":"tcp"}], optional expires_in (300-3600 seconds)Replace all mappings at once, live; expiry is mandatory for Free Trial SSH
GET /vm/sshnone; no body neededManaged public keys, host key/fingerprint and SSH commands
PUT /vm/sshauthorized_keys: ["ssh-ed25519 AAAA..."]Replace managed owner SSH keys, live

Writes return asynchronous jobs, use the exact VM generation, and require the the microVM access approval. Retry a lost response with the same request ID and body; after a failed job inspect its result before using a new ID. A pending network state means application failed or was interrupted: inspect and resubmit the intended complete mappings. routed is configuration status, not a guest-service health check.

Use the agent CLI from the repository (Python standard library only):

export GAP_TOKEN="$TOKEN"
python3 scripts/microvm.py --project "$PROJECT" ports
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" set-ssh-keys --key ~/.ssh/id_ed25519.pub
# Poll each returned job before submitting another mutation.
python3 scripts/microvm.py --project "$PROJECT" job job_returned_above
python3 scripts/microvm.py --project "$PROJECT" --vm "$VM" set-ports --map 1:22:tcp --map 2:7000:both
python3 scripts/microvm.py --project "$PROJECT" ssh
# Run the returned ssh command; verify the returned host fingerprint.

The CLI prints the request ID before submitting; use --request-id to retry that exact operation. set-ports without --map disables all mappings. set-ssh-keys without --key removes all managed owner keys. Only unadorned Ed25519 and RSA public keys are accepted; never upload private keys. Optional ssh_keys: [...] on VM creation installs initial owner keys before first boot. SSH passwords are disabled. GAP's restricted internal control key remains separate and is never returned to the agent. Wait for SSH to boot before a live key update. The guest supports interactive SSH, SFTP/SCP and TCP tunnels.

Numbers and mappings survive VM stop/start; stop closes listeners and active VM connections, while start restores configured mappings. Destruction frees the numbers even when the disk is retained; a replacement VM receives a new host identity and does not inherit the old mappings or keys. Disabling a mapping or removing a key blocks new access but may leave established sessions alive. Guest root can independently modify sshd/keys; this API manages the supplied keys and is not a boundary against that VM's root. Revoking agent approval fences execution and forwarding through the policy watchdog.

The direct endpoints do not add TLS or visitor authentication to TCP/UDP services: configure those in your application. Guest ports from POST/PATCH /vm are internal host forwards used by HTTPS routing; public mappings are a separate list of five slots. Either can target any guest port without a reboot. Enabling HTTPS routing creates its internal forward automatically, so a port does not need to be declared first and never consumes a public slot.

Runtime environment inside the microVM

GAP injects non-secret networking metadata before SSH and Docker start. Root SSH sessions and login shells receive these variables automatically. Use gap-env COMMAND [ARGS...] to load the latest values for a service or command, or source /etc/gap/runtime.sh in its startup script. /etc/gap/runtime.json is the machine-readable snapshot; /etc/gap/runtime.env is a raw env file.

VariableMeaning
GAP_PROJECT_ID, GAP_VM_IDThis guest's project and VM generation
GAP_PUBLIC_HOSTDirect TCP/UDP hostname, e.g. sites.gap.geta.team
GAP_PUBLIC_PORTSComma-separated list of the five allocated public numbers
GAP_PORTS_JSONJSON array of slots with public_port, guest_port, protocol; unmapped targets are null
GAP_PORT_1_PUBLIC, GAP_PORT_1_GUEST, GAP_PORT_1_PROTOCOLPer-slot values, also available for slots 2–5; unmapped fields are empty
GAP_HTTP_PORTMain guest listening port routed for HTTPS/API/WS; empty when ingress is disabled or unconfigured
GAP_INGRESS_ENABLED1 when the HTTP route is configured, otherwise 0; not a health check
GAP_PUBLIC_URL, GAP_WS_URLFull public app URL and corresponding WS/WSS base URL; empty when disabled
GAP_BASE_PATHApp prefix such as /apps/prj_.../ on the shared origin
GAP_ENV_REVISIONDigest identifying the current metadata snapshot

Applications bind to the guest port, usually on 0.0.0.0, not to the allocated public port. No generic PORT variable is overwritten globally; map it explicitly for the service that needs it.

For correct first startup, configure /vm/ingress before deploying the application. You can create the VM with start: false, configure its HTTP route and public mappings while stopped, then start it. The environment is installed from the guest-only seed before Docker starts. Configuring a route does not imply that an application already listens there.

GAP's Compose helper automatically loads these variables for ${GAP_HTTP_PORT} and other Compose interpolation. To pass the values into a container:

services:
  app:
    image: your-app
    env_file:
      - path: /etc/gap/runtime.env
        format: raw
    environment:
      PORT: "${GAP_HTTP_PORT}"
    ports:
      - "${GAP_HTTP_PORT}:${GAP_HTTP_PORT}"

format: raw preserves the JSON values literally and is supported by the managed guest's Compose version. For Compose run manually over SSH, use gap-env docker compose up -d. For another process, use gap-env ./your-app.

Mapping and ingress changes refresh the files and future SSH sessions at runtime, without restarting the VM. Existing processes keep their old environment. An application can reread runtime.json; otherwise relaunch it with gap-env. Existing Compose containers must be recreated, not merely restarted, to receive new environment values (redeploy the bundle or use gap-env docker compose up -d --force-recreate). To watch updates from a container, mount /etc/gap read-only as a directory; individual file mounts can retain the old inode when GAP atomically replaces the file.

A failed guest update fails the management job and exposes environment_sync_pending: true in /vm; routing may already have changed. Inspect and retry the operation with a new request ID. The latest catalog metadata is regenerated on the next VM start and synchronized before managed Compose commands. The environment contains no owner bearer, controller credentials, host paths or worker-internal ports.

Compose — optional container deployment

The following operations apply only if you choose to run a Compose stack. They are not part of the native application workflow.

Submit a release or update

POST /v1/cloud/projects/{project}/stack/releases accepts a JSON bundle:

{
  "request_id": "0123456789abcdef0123456789abcdef",
  "vm_id": "vm_returned_by_creation",
  "compose_file": "compose.yaml",
  "files": {
    "compose.yaml": "<base64-file-content>",
    "Dockerfile": "<base64-file-content>",
    ".env": "<base64-file-content>"
  }
}

Only include files you need. Keys are relative file paths; no absolute paths, .., symlinks or archive extraction. Include referenced local build/config files in the bundle. Compose interprets the files only inside the guest. Example using an existing compose.yaml (requires jq and OpenSSL):

export REQUEST_ID=$(openssl rand -hex 16)
COMPOSE_BASE64=$(base64 < compose.yaml | tr -d '\n')

# Save this request before sending so a network retry uses identical input.
umask 077
jq -n --arg id "$REQUEST_ID" --arg vm "$VM" --arg source "$COMPOSE_BASE64" \
  '{request_id:$id,vm_id:$vm,compose_file:"compose.yaml",files:{"compose.yaml":$source}}' \
  > compose-request.json

curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/stack/releases" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  --data-binary @compose-request.json
# 202 -> {"job_id":"job_...","request_id":"...","status":"queued"}

For updates, submit a complete new bundle with a new request_id to the same endpoint. Files are stored as an immutable guest release. Compose validates it, then GAP promotes the managed files into /var/lib/gap-data/${GAP_PROJECT_ID}, validates from that stable directory and runs up --detach --build --remove-orphans --wait --wait-timeout 120 using a stable project name. Relative bind mounts keep the same guest paths across updates. Updates may cause downtime or partially change services; they are not atomic and do not automatically roll back database migrations.

Poll a job and inspect the latest operation

export JOB=job_returned_by_submission
curl -s "$NODE/v1/cloud/projects/$PROJECT/vm/jobs/$JOB" \
  -H "Authorization: Bearer $TOKEN"
# -> {job_id,request_id,action,status,created_at,result}

curl -s "$NODE/v1/cloud/projects/$PROJECT/stack" \
  -H "Authorization: Bearer $TOKEN"
# -> {project_id,latest_job,note}; this is NOT live application health.

Job states: queued, running, succeeded, failed, interrupted. The guest result includes ok, command output and exit/timeout information when available. succeeded for start/stop means the command completed, not continuous application health. Command output can contain application secrets; never publish it or put your owner bearer in browser code.

Start, stop, live status and recent logs

All four are asynchronous POST operations and return a job to poll:

# Start existing containers (not a redeploy).
curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/stack/start" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"request_id\":\"$(openssl rand -hex 16)\",\"vm_id\":\"$VM\"}"

# Stop app containers; preserve guest, containers and volumes.
curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/stack/stop" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"request_id\":\"$(openssl rand -hex 16)\",\"vm_id\":\"$VM\"}"

# Run Docker Compose ps --all --format json inside the guest.
curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/stack/status" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"request_id\":\"$(openssl rand -hex 16)\",\"vm_id\":\"$VM\"}"

# Fetch bounded recent output: Docker Compose logs --tail 200.
curl -sX POST "$NODE/v1/cloud/projects/$PROJECT/stack/logs" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"request_id\":\"$(openssl rand -hex 16)\",\"vm_id\":\"$VM\"}"

Use a fresh id for a new operation; save and reuse the original id/body when retrying that operation. The worker returns the same job for an identical request and 409 request_id_conflict if you change its input. Another operation while one is running returns 409 stack_operation_in_progress; wait and retry. An unconfigured public node returns 404 compose_disabled; missing/wrong agent credentials return 401, and worker approval failures return 403. A runner transport failure returns 502; retry with the same id rather than guessing whether the job was accepted.

After a worker restart, queued/running jobs become interrupted without blind replay. After SSH loss or timeout the remote state may be unknown; inspect status before submitting a new mutation. Previously attempted guest release ids are not automatically rerun. This is not exactly-once execution of arbitrary code.

The request transport is limited to 5 MiB including base64/JSON, and output is bounded. Docker commands have operational timeouts (540s per command, 600s SSH session); started applications are not given a 600-second lifetime. Fetch larger build contexts inside the guest. Named volumes persist between updates, but relative bind mounts point into the new release directory on each update.

Managed VM creation/start/stop, offline CPU/RAM/disk-growth updates and explicit deletion are available through /vm; see the VM API. Apps publish at https://gap.geta.team/apps/{project_id}/ through GET/PUT /vm/ingress, using the existing DNS and TLS certificate. See setup, API and base-path contract. Verified custom app domains are supported (see below). Automatic rollback, backup and fleet HA are not implemented. Public TCP/UDP forwarding is available through the five microVM port slots. Your ports: publishes on the guest, not automatically on the GAT host. Approval revocation blocks management/admission and fences running microVMs through the worker policy watchdog. Agent suspension additionally blocks the agent's other Cloud workloads; see the suspension policy below. Real KVM and API acceptance tests are provided; production activation requires operator configuration of the execution host.

Compatibility with earlier clients

Existing /stack/vm, /stack/vm/start, /stack/vm/stop, /stack/ports, /stack/ssh, /stack/ingress and /stack/jobs/{job_id} calls remain aliases of the corresponding /vm routes. They share authorization, VM identity, request deduplication and job history; no resources are duplicated. compose-access.py remains an alias for microvm-access.py. Existing approval files, historical GAP_COMPOSE_* infrastructure settings and machine error codes remain compatible. These names do not require using Docker.

Configure microVM sales prices from a node environment

Set GAP_PRICING_VERSION, GAP_PRICE_VCPU_HOUR_USD, GAP_PRICE_RAM_GIB_HOUR_USD, GAP_PRICE_DISK_GB_MONTH_USD, GAP_PRICE_NETWORK_IN_GB_USD and GAP_PRICE_NETWORK_OUT_GB_USD in that node's .env. Values use USD, at most six decimal places; RAM uses GiB, storage/network use decimal GB, and a storage month is 730 hours.

On the host, preview without credentials or network access, then apply:

python3 scripts/microvm-billing.py preview-pricing-env
python3 scripts/microvm-billing.py pricing
python3 scripts/microvm-billing.py set-pricing-env --expect-version usd-v1

Use --expect-version none only to initialize a fresh ledger. For updates, use the current live version as the expected version and a new version in .env whenever any amount changes. A stale expected version is rejected atomically. After a lost response, read live pricing before retrying.

Applying prices flushes current VM usage under the old tariff and requires no stack restart. Editing .env or restarting alone does not overwrite live operator settings. Missing, duplicate, malformed or entirely zero tariffs are rejected. Unrelated environment secrets are never printed or evaluated. This configures sales prices; provider costs and financial reports are separate.

Operator console and microVM access requests

The operator can enable an individual administrator console with GAP_CLOUD_ADMIN_ENABLED=1, a comma-separated GAP_ADMIN_EMAILS allowlist, and a dedicated HTTPS GAP_ADMIN_ORIGIN. That origin must not serve tenant applications or be the public client origin. Both the edge and node must receive the same origin. The console is at /admin; the first login enrolls a password (minimum 12 characters) only after verifying the code sent to the allowed email. Subsequent logins require both the password and a fresh email code. Sessions expire after 30 idle minutes or 8 hours total and can be ended with Sign out. The SMTP variables above also configure administrator email delivery.

Project owners can request microVM access or higher aggregate quotas without already having microVM approval. Use the request form on /microvms, or:

POST /v1/cloud/projects/{project_id}/access-requests
Authorization: Bearer <owner-token>
Content-Type: application/json

{"request_id":"0123456789abcdef0123456789abcdef","quota":{"vcpus":2,"memory_mib":4096,"max_vms":1},"always_on":false,"reason":"Run a small API"}

Reuse the request ID when retrying the same request. GET on the same endpoint lists the owner's requests. Administrators review requests under Approvals; accepting updates the live approval file without restarting the node or VMs. Quotas apply to the agent's combined VMs on this node, not to each project. Approval does not add prepaid credits. Always-on execution needs explicit approval and continues consuming compute credits while running.

Usage finance and provider costs

The operator console's Finance view reports complete UTC hours over the last 24 hours or 30 days. Historical billing periods are apportioned across hours and marked as estimates; aggregate debits and wallet balances remain unchanged. Usage charges may consume promotional credits and are not cash receipts. Unclassified credit additions remain separate from paid and promotional funding.

Provider costs are independent of sales tariffs. Configure GAP_COST_VERSION, GAP_COST_NODE_MONTH_USD, GAP_COST_EXTRA_DISK_MONTH_USD, GAP_COST_NETWORK_IN_GB_USD and GAP_COST_NETWORK_OUT_GB_USD in .env. Every key must be present. An empty amount means unknown; 0 explicitly means zero cost. Extra disk is the total monthly invoice for additional provisioned capacity (extra GB times the provider's per-GB quote), not customer storage usage. Costs become effective when applied; historical costs are not invented. Monthly infrastructure estimates use 730 hours, matching storage pricing.

python3 scripts/microvm-billing.py preview-costs-env
python3 scripts/microvm-billing.py set-costs-env --expect-version none

For later changes, supply the current cost version and a new immutable version. Missing provider costs make the full cost and usage margin unavailable. Known costs are shown as partial estimates. Project reports do not allocate shared node costs to individual customers.

An operator can annotate an existing credit addition without changing its amount or issuing more credit. For example, classify an operator grant as promotional:

python3 scripts/microvm-billing.py classify-funding --project prj_PROJECT_ID \
  --operation topup:EXISTING_REQUEST_ID --source promotional \
  --cash-microdollars 0 --note "Operator promotional allocation"

Annotations are immutable and idempotent. --source paid requires a positive cash amount and a supporting note; it records an operator assertion, not payment processor verification. Stripe receipts remain a later integration.

Agent suspension and reactivation

Administrators can suspend or reactivate an agent under Agents in /admin. Every decision requires a reason and stores the administrator, timestamp and monotonically increasing generation in durable history. Concurrent changes are rejected until the administrator refreshes. The decision survives node restarts and disabling the administrator UI.

Suspension denies the owner's management bearer, new function invocations, function capabilities, static pages and custom-domain routing. Realtime sessions are disconnected and active sandbox workers are terminated; queued invocations recheck policy before execution. Already completed writes are not rolled back. Content already downloaded or cached outside GAP cannot be recalled.

Workers refresh node-local authorization using five-second leases and deny work when authorization is unavailable. The microVM watchdog closes HTTP/WebSocket, TCP and UDP forwarding and pauses guest execution independently of long guest jobs. The lifecycle controller then writes a disk snapshot and releases memory; if snapshotting fails it stops the VM. A job holding the lifecycle lock can delay disk hibernation, but cannot keep guest CPUs executing. Reactivation permits normal serverless wake-up; a failed snapshot requires a cold start instead. Revoking microVM approval uses the same VM fencing without suspending the agent's other Cloud services.

Suspension itself does not debit the wallet, delete data or start the 72-hour zero-credit timer. Existing storage charges and credit-exhaustion retention rules still apply. Operator-wide and customer suspension can additionally preserve data for a configured abuse-review window; see Customer and fleet suspension.

Creation settings and remaining allocation

The /microvms creation form defaults to Start machine and Serverless. CPU and RAM inputs are limited to the agent's remaining allocation across all projects on this node. VM slots include stopped and hibernated machines. The optional disk quota includes provisioned capacity of retained volumes, not only currently running VMs. When no disk quota is configured, the UI states this explicitly. Disk capacity must also fit the base guest image.

Configure a disk quota live with:

python3 scripts/microvm-access.py set-quota did:gap:AGENT_ID --disk-gib 32

This optional quota preserves existing approvals: omitting disk_gib means no operator disk quota. It does not reserve physical host storage or change storage billing. Use the full desired quota when approving a replacement request; the administrator review displays whether a disk quota was requested.

POST /v1/cloud/projects/{project}/vms accepts execution_mode (serverless, the default, or always_on) along with existing creation fields. Always-on must be separately approved and is checked before any disk allocation. The choice is saved before first start, without a second configuration call. start: false keeps either mode stopped until an explicit start. Server-side allocation checks remain authoritative if another request consumes quota while the form is open.

Administrator resource navigation and project suspension

In /admin, open an agent to browse its paginated projects and decision history. Projects link back to their owner and show functions, site configuration/releases, database schema, project-filtered VM inventory and a shortcut to usage finance. The finance view identifies the selected project and can return to all projects. Resource metadata lists currently show at most 100 functions/schema entries. Agent, project and VM inventory pages support pagination.

A project can be suspended independently from its owner. Its management operations, public workloads and execution leases are denied while the owner's other projects remain accessible. Decisions use the same reason, administrator attribution, durable history and stale-generation rejection as agent suspension. Disabling or restarting the administrator console does not clear either decision. Reactivating an agent does not clear a separate project suspension, and reactivating a project does not override a suspended owner. This applies within the current node. Central customer and operator decisions are applied in addition to these local decisions when fleet policy is enabled.

Fractional CPU, RSA keys and browser VM sessions

MicroVM CPU allocations accept multiples of 0.25 vCPU. QEMU presents the rounded-up number of guest CPUs; Linux cgroup v2 limits the entire QEMU process to the purchased CPU time. Quotas and CPU billing use the fractional allocation. A configured host CPU quota broker is required for fractional allocations; an unavailable broker fails closed before guest execution. RAM and disk remain integer MiB and GiB. The web console offers memory from 256 MiB in 256 MiB steps, bounded by the remaining agent allocation. SSH public keys may be Ed25519 or RSA (2048–16384 bits), with an optional comment. Provide the complete public key, without truncation, private key material or authorized_keys options. RSA key format does not enable legacy SHA-1 signatures.

The public /microvms URL stays on GAP_PUBLIC_URL and embeds the console from the isolated GAP_ADMIN_ORIGIN management origin, where tenant applications are never served. It uses an opaque Secure, HttpOnly, SameSite=Strict session cookie scoped to VM management APIs. The bearer stays on the server. Refreshing the tab preserves the connection; Disconnect revokes the session. A non-secret project identifier and a CSRF value are held in tab session storage. Sessions expire after eight hours and survive node process restarts and deployments. The node stores encrypted session records in GAP_VM_SESSIONS_DB (default /data/cloud-vm-sessions.sqlite, on persistent storage). Keep GAP_MASTER_KEY stable: changing it invalidates existing sessions. Cookie IDs are stored only as hashes, and agent bearers are encrypted with a separate key derived from the master key. Disconnect durably revokes the session. API clients continue using their bearer tokens.

SSH connection details and browser terminal

The /microvms console displays the selected VM's public SSH command, port, ED25519 host fingerprint and authorized public key count. Add Ed25519 or RSA public keys from the console. Expose SSH explicitly on an unused TCP slot; Free Trial access closes automatically after one hour. Existing approved-tier port mappings are preserved. Use the matching private key locally, optionally with ssh -i /path/to/private_key; never upload a private key.

The web terminal uses a controller-owned, host-key-pinned SSH connection to the VM's internal SSH port. It needs no public port and opens /bin/sh inside the VM as root. A hibernated VM is resumed through the normal asynchronous resume job first; a stopped VM must be started first. Owner authorization, quota approval, workload suspension and credit checks apply. Browser sessions remain on the isolated management origin.

The dashboard embeds ttyd 1.7.7 over a WebSocket on that isolated origin. It supports resizing, clipboard operations and reconnecting. Reconnecting opens a new SSH shell; keep long-running tasks under a supervisor. The dashboard sends an authenticated keepalive every ten seconds; output and WebSocket pings alone cannot preserve authorization or keep the VM awake.

For this transport, add "transport":"ttyd" to terminal/open. Embed the returned relative url on the same management origin using the project browser session. The opaque URL alone grants no access: a valid HttpOnly project cookie is required. POST terminal/keepalive with vm_id and terminal_id at least every ten seconds, using the browser session CSRF header. Close uses the same terminal/close route. The older HTTP exchange transport below remains available for API clients.

Before opening a terminal, POST /v1/cloud/projects/{project}/vm/terminal/prepare with vm_id and request_id (32 lower-case hex characters) and await its job. This installs a separate forced-shell key, preserving the restricted deployment key and user public keys. Then, for an interactive terminal, POST /v1/cloud/projects/{project}/vm/terminal/open with vm_id, cols (20–300), rows (5–100). The response includes terminal_id. POST /vm/terminal/io with vm_id, terminal_id, dimensions, seq (starting at 1), cursor (starting at 0), and base64 input. Advance seq after each response, and use the returned byte cursor. A retry must use the same sequence and input. Decode base64 output; closed marks EOF and truncated indicates discarded old output. POST /vm/terminal/close with vm_id and terminal_id to close. All requests require project owner auth.

Limits: two live terminals per owner, 32 per node. The HTTP exchange transport has 16 KiB input per exchange, 64 KiB output per response and a 1 MiB output ring per terminal. Terminals expire after 30 seconds without browser exchanges, 15 minutes without input, or eight hours total. Polling/output alone does not reset VM inbound activity; opening a terminal and typing do. Stop/hibernate/suspension closes the terminal; background programs should use an appropriate supervisor inside the VM.

MicroVM network and live metrics

The /microvms console shows the public node hostname and its asynchronously resolved IP addresses, all five reserved TCP/UDP port slots, guest destinations, routing state, and the application's HTTPS/API/WebSocket URL. The IP belongs to the shared node; a microVM does not have a dedicated public IP. Unconfigured ports and disabled HTTP routing are explicitly marked. A published route does not certify application health. Use Refresh metrics & network or the 10-second automatic refresh; neither wakes a VM or resets inbound inactivity.

For HTTPS routing, enter the guest port your application listens on and enable routing from the network panel, or PUT /vm/ingress with request_id, vm_id, enabled: true and guest_port. Any guest port 1-65535 other than 22 is accepted: GAP creates the host-side forward itself, live, so routing needs neither downtime nor one of the five public slots, and a hibernated VM wakes on request. Declaring the port in ports at creation (for example ports: [8000]) or through PATCH /vm is still supported and is the right choice when the port is also published publicly. GET /vm/ingress?vm_id=... returns the exact URL; do not guess a project path when the VM has its own identity.

GAP application URLs require visitor Basic Auth, even when a public custom domain is attached. Configure access and domains from the network panel; see HTTP authentication and custom domains.

GET /v1/cloud/projects/{project}/vm/metrics?vm_id=... requires owner auth and microVM approval. It reads host counters only, without SSH, guest execution, QMP resume or an application health request. Metrics include:

Unavailable readings are null, not fabricated zeros. CPU and resident memory are zero when the VM process is absent; allocated resources and stored disk remain visible. sampled_at, cpu_window_seconds, errors and the DNS resolved_at timestamp describe freshness and limitations. Metrics are current samples, not a historical time-series database.

MicroVM HTTP authentication and custom domains

All shared GAP /apps/{project-or-vm}/ routes require Basic Auth, including HTTP APIs and WebSocket handshakes. Existing routes without visitor credentials are locked until their owner configures them. There is no anonymous-access switch on the GAP domain. SSH and the five raw TCP/UDP mappings are separate; applications exposed on those ports must enforce their own access policy.

In MicroVMs → Network & application access → GAP URL protection, choose a visitor username and a new password (12–128 bytes). These are separate from your owner bearer token. GAP stores an Argon2 hash and an encrypted recoverable copy, and removes visitor Authorization before forwarding to the guest. Changing credentials takes effect on new HTTP requests and WebSocket handshakes; an already open stream is not reauthenticated. A WebSocket client on the shared origin must support the Basic handshake; for public browser applications, use a verified custom domain.

Owner API (also accepts the console's authenticated browser session):

GET /v1/cloud/projects/{project}/vm/http-access?vm_id=vm_...
PUT /v1/cloud/projects/{project}/vm/http-access
    {"vm_id":"vm_...","username":"visitor","password":"your-unique-visitor-password"}

The GET response includes configured, username and password_recoverable, never the password or hash. The owner can explicitly POST /vm/http-access/reveal with vm_id to retrieve the visitor password. Browser sessions require their usual CSRF header. The console shows the login beside the base URL and provides Show/Hide and Copy buttons; revealed passwords are hidden after 30 seconds. Saving credentials requires GAP_MASTER_KEY; the recoverable copy is encrypted and bound to the VM, project and username. Existing hash-only passwords continue to authenticate but must be saved again to support reveal. No secret is stored in browser storage or embedded in application URLs. There is no credential default. Use your secret manager to deliver the chosen password to authorized visitors; never place it in a public URL.

To publish without GAP Basic Auth, use Custom domains in the same panel:

  1. Enable the VM's HTTPS ingress for the correct guest application port.
  2. Add a hostname such as app.example.com. GAP reserves it for that VM and shows a unique TXT challenge under _gap-verify.app.example.com.
  3. Create the TXT record exactly as shown. Point the hostname at the displayed node routing target using CNAME (hostname), or A/AAAA (IP). Use DNS-only while provisioning; the target must reach this node on 80/443. A CNAME to the node works because GAP now maps the verified hostname to this exact VM internally.
  4. Click Verify DNS after propagation. The status becomes active; the first HTTPS visit provisions a certificate through the node's TLS gateway.
  5. Configure your application's public origin, redirects, cookie paths and WebSocket URL for the domain. GAP does not rewrite application responses.

The custom hostname is public without GAP Basic Auth. Your app may still require its own login or API Authorization, which GAP preserves on that hostname. The shared /apps/ URL remains protected. Removing a domain disconnects its routing and certificate authorization for new connections. Hibernated VMs can wake on authorized shared-origin requests or verified-domain requests.

GET    /v1/cloud/projects/{project}/vm/domains?vm_id=vm_...
POST   /v1/cloud/projects/{project}/vm/domains
       {"vm_id":"vm_...","hostname":"app.example.com"}
POST   /v1/cloud/projects/{project}/vm/domains/app.example.com/verify
       {"vm_id":"vm_..."}
DELETE /v1/cloud/projects/{project}/vm/domains/app.example.com
       {"vm_id":"vm_..."}

The node's three-domain limit is shared across a project's static sites and microVMs. A hostname cannot be attached to two targets. Ownership, agent approval and project suspension are checked; stale mappings never route a replacement VM.

Operators: configure GAP_VM_EDGE_TOKEN with a random 32-byte hex value in the node .env. Put the same value in the worker's private /config/http-edge.token and set ingress.admission_token_file to that path in runner.json. Never expose this secret to tenants. Main edge and private Caddy both require this admission chain; without it VM routing fails closed. See the worker README for rollout.

MicroVM direct public ports use the operator-configured pool 24000-53999 by default (30,000 numbers, available in TCP and UDP), with five reserved numbers per VM. Host range DNAT avoids individual Docker publications; see runtime/compose/README.md for firewall setup and reconciliation. Per-agent VM and resource quotas still apply.

Active MicroVM pricing discovery

GET /v1/pricing returns this host's active billing registry (USD, one million microcredits per credit), mode, tariff, checked_at and available. Only an active enforced tariff is available for sale. An unavailable worker or shadow billing is not a zero price. /pricing displays these same values and estimates CPU/RAM charges; the MicroVM creation form uses the connected project's active registry. Operator provider costs remain private and are not sale prices.

Authenticated GET /v1/fleet/nodes includes each node's pricing, refreshed at most every 30 seconds. It uses configured node endpoints only, and returns available: false when a tariff cannot be obtained or validated. Current tariffs are labelled separately from historical usage and provider-cost versions in fleet finance. Prices can differ between nodes; compare before choosing a host.

MicroVM allocation limits: at most 4 vCPU, 8192 MiB RAM and 100 GiB disk per VM. CPU uses increments of 0.25, RAM increments of 256 MiB, disk increments of 1 GiB. Creation selectors also respect remaining owner quotas and the minimum disk size required by the guest image. API creation and resize enforce these limits; existing allocations are not automatically changed.

MicroVM storage encryption

New microVMs on the Elestio fleet encrypt their disks and saved hibernation memory at rest. This is built into the worker: applications do not need to manage disk passwords, and the host OS does not require a storage migration. Independent operators must explicitly enable encryption on their workers.

Check disk_encryption.enabled in the VM API response. The dashboard shows the AES-256 storage badge only when that VM reports encryption enabled. Do not infer coverage from a provider name or assume every independent node enables it.

This covers disk payloads and retained hibernation memory, not qcow2 metadata, guest seed/SSH files, host swap/logs, other GAP databases, or a compromised host with access to its keyring. Encryption does not replace backups. Keep an independent recovery copy of the keyring; losing a required key prevents recovery. Rotation of the active key version affects new VMs, not existing disk contents.

Operator configuration and recovery: MicroVM encryption.

Moving a MicroVM between hosts

In Account → Fleet MicroVMs, choose Move on a machine you may manage. Select the destination, review its active rates and accept downtime and endpoint changes. A listed destination still needs compatible images, sufficient resources and operator admission; selection does not reserve capacity. Automatic placement and automatic rebalancing are not enabled.

This is a cold move: GAP stops the source, transfers retained disks and SSH keys, then activates the destination. Running processes and memory are not transferred. A running VM restarts; stopped/hibernated machines do not promise a live-memory resume after migration. Keep applications under a service supervisor.

The shared HTTPS URL, verified custom domains and visitor credentials stay on the original HTTPS host, which proxies to the current host. The original host must remain reachable. This preserves URLs but is not high-availability routing. Public SSH and raw TCP/UDP endpoints change: use Account → Details after the move and update clients. Visitor settings remain on the original host; Account links to that dashboard where necessary. The destination's rates apply after handoff.

MicroVM moves shows progress. Retry a failed move using Retry; use Cancel move before commit to request cancellation. Cancellation is not an instant rollback and cannot undo a committed move. Follow the operation until it reaches a terminal state. Do not create a replacement VM to retry a transfer.

API clients use their fleet control credential at the operator gateway:

GET /v1/fleet/vm-placements
GET /v1/fleet/migrations
GET /v1/fleet/migrations?migration_id=move_...
POST /v1/fleet/migrations
  {"action":"start","request_id":"unique-operation","project_id":"prj_...",
   "vm_id":"vm_...","target_node":"node-02","target_tariff":{...},
   "confirm_downtime":true}
POST /v1/fleet/migrations
  {"action":"resume","migration_id":"move_..."}
POST /v1/fleet/migrations
  {"action":"cancel","migration_id":"move_..."}

Copy target_tariff exactly from the destination entry in GET /v1/fleet/nodes; it is a versioned object, not a numeric estimate. Reuse the same start request ID and body after a lost response. Use node_id when requesting a project token for the VM's current placement. Never send a local node bearer to an unrelated operator. Discovery alone grants neither placement permission nor access.

Customer and fleet suspension

An operator can suspend a customer across its nodes or suspend the whole operator fleet. Reasons, revisions and history are retained. Existing access tokens and new allocations are denied after policy propagation (normally within the node and workload leases, approximately ten seconds). Linked identities and projects are covered; a new DID using the same locally verified email cannot bypass the policy. An unrelated new email is not proof of the same person's identity.

Restoration is explicit and does not remove independent agent/project decisions. A suspension can hold centrally managed data for 72 hours by default, configurable up to 720 hours. Storage charges continue. Expiry of the hold does not itself cause deletion: normal credit-exhaustion retention applies. Suspension never immediately deletes disks. Previously revoked account sessions require login again.

When enabled, expired or unavailable central policy denies access. Control-plane high availability is separate work. Operator commands and activation settings: control reference.

Public infrastructure explorer

Open /explorer without signing in to compare operator-listed nodes. Public GET /v1/explorer aggregates GET /v1/public-node from explicitly configured HTTPS origins only. No caller URL, credentials, wallet, project identifier or customer inventory is forwarded or published. Browsing does not connect accounts or establish trust between operators.

The view reports operator-declared country/region, software version, services, measured hardware, active sale tariff and aggregate host headroom. Missing data is unavailable, never zero-priced capacity. Execution headroom counts only verified live VMs: stopped and hibernated VMs free CPU, RAM and VM slots, but their retained disks remain committed. The host policy allows up to eight simultaneously active VM slots and eight allocated vCPUs per logical CPU, counts all configured swap alongside RAM, and reserves 2048 MiB RAM and 5 GiB disk for overhead. RAM is also bounded by currently available host RAM plus free swap, with a 512 MiB physical availability floor; disk by free space. A wake can be rejected when another guest has taken the freed capacity. These are capacity observations, not reservations or promises of admission. Account quotas, compatible images, concurrent jobs and allocation checks remain authoritative.

Samples are cached for 30 seconds. Each card shows its sample time and the measuring node for HTTP latency; latency is not measured from the visitor and is not a historical availability SLA. Failed or stale remote samples are marked unavailable. No compliance badge is implied by a listing.

Operators configure GAP_PUBLIC_OPERATOR, GAP_PUBLIC_COUNTRY, GAP_PUBLIC_REGION and GAP_PUBLIC_EXPLORER_NODES (comma-separated HTTPS origins; at most eight). Leave metadata empty if it is unknown. These fields contain public information only. Each operator controls its own listing; no credential exchange or automatic federation follows from discovery.

Operator trust and evidence

Each explorer card has Trust & evidence. Operator metadata is declared; independent identity verification and reviewed technical controls are separate states. None is inferred from a name, hostname, software version or listing.

For origins explicitly listed by the directory operator in GAP_PUBLIC_ELESTIO_NODES (comma-separated HTTPS origins), the directory exposes Elestio's SOC 2 Type II and ISO 27001 statements and links to https://elest.io/security-and-compliance. The provider relationship is declared by the directory operator. Underlying audit reports have not been reviewed by GAP; applicability to a deployment requires reviewing their scope. These references do not certify GAP, the host provider, applications or independent operators, and do not establish encryption coverage.

The /v1/explorer response adds an assurance object to each directory entry. It is assigned by the directory, not copied from the remote node's metadata. Unknown origins have no provider evidence. An unavailable node may retain its listed provider reference; this does not establish present availability.