Documentation
host0 documentation
Everything an agent (or a human) needs to publish an app to host0: get an API key with device login, upload files with the two-phase protocol, and control who can view the result. Base URL for every example: https://host0.ai. The machine-readable index lives at GET /api/v1.
Overview
What host0 is
host0 is a cloud for small software. You bring any coding agent — Claude, Cursor, Codex, and others — and host0 owns the moment after the code exists: a live URL, shareable like a doc.
The core primitive is an app: a named container for deployments. Publish a
directory of static files and the app goes live at https://{slug}.host0.app/.
Re-publishing creates a new deployment and flips it live atomically — the old
version serves until the new one is complete.
Every app is public by default: the app is unlisted and it's never indexed,
but anyone who has the URL can view it — make it private for anything
sensitive. A new app gets a random URL (https://k3x9q2m7ta.host0.app/); rename
it to something readable from the dashboard, the API, or the MCP
rename_app tool. Two opt-in alternatives:
- Link — same behavior as public, stated explicitly: anyone with the link, listed nowhere.
- Private — only you and people you invite by email; visitors sign in to view. Revoking an invite kills their access immediately.
host0 serves static sites today — HTML, CSS, JS, images, fonts, assets — plus shared data: an app can declare record collections and read and write them from the page, so several people can work in the same tickets, todos or sign-ups. Server code of your own is on the roadmap.
On the free plan, every HTML page an app serves gets a small Hosted on host0 badge fixed to its bottom-right corner (PDFs, images and other files are served untouched) — keep that corner clear of your own fixed buttons; paid plans will be able to remove it.
Quickstart
Deploy with the skill, manage with MCP
A. Deploy: the host0 skill
Deploying goes through the host0 skill — an instruction file plus one Bash
script (requires curl and jq) — in any agent that can run it: claude.ai,
Claude Code, Cursor, Codex, and others. The paths below are where Claude Code
keeps skills; other agents keep them elsewhere, so use whichever directory
yours reads:
mkdir -p ~/.claude/skills/host0/scripts
curl -sS https://host0.ai/skill.md -o ~/.claude/skills/host0/SKILL.md
curl -sS https://host0.ai/publish.sh -o ~/.claude/skills/host0/scripts/publish.sh
chmod +x ~/.claude/skills/host0/scripts/publish.shSign in with device login (see API keys) — or just publish: with
no key the script starts the sign-in itself. Then publish any directory with an
index.html at its root:
# first publish — creates the app
~/.claude/skills/host0/scripts/publish.sh ./my-site --name "my site"
# every publish after that — same folder, same app, unchanged files skipped
~/.claude/skills/host0/scripts/publish.sh ./my-site
# → https://k3x9q2m7ta.host0.app/B. Manage: the MCP server
Add host0 as a remote MCP server at https://host0.ai/mcp — it
works in claude.ai (Settings → Connectors), Claude Desktop, and Claude Code:
claude mcp add --transport http host0 https://host0.ai/mcpSign in when prompted. The MCP tools control apps you've already deployed —
list_apps, get_app, get_deploy_status, set_visibility, rename_app,
share, unshare, and delete_app (deletes an app completely, live or
not). They don't
deploy: that's the skill's job. Custom domains will be managed here later.
API keys
Device login: approve in your browser, key out
API keys come from a device login — the way gh or the AWS CLI signs in.
The agent sends nothing personal: it gets a short code and a link, the user
opens the link on host0 in their own browser, signs in, checks the code
matches and approves, and the agent picks up the key. It works from anywhere an
agent runs, including cloud sandboxes like claude.ai, because no email or
password ever passes through the agent.
With the skill it's two commands (a publish with no key starts the same
sign-in on its own). The first prints the link + code and exits 3 — "waiting
for the user":
# 1. start a sign-in — no email, nothing personal leaves this machine
# prints a link + code and exits 3: show both to the user
~/.claude/skills/host0/scripts/publish.sh --login
# 2. wait for the approval — saves the key to ~/.host0/credentials (chmod 600)
~/.claude/skills/host0/scripts/publish.sh --login-waitThe same flow over plain REST, for an agent without the script:
# 1. start — returns device_code, user_code and verification_uri_complete
curl -sS -X POST https://host0.ai/api/v1/auth/device
# 2. show the user verification_uri_complete + user_code, then poll every
# "interval" seconds until they approve (authorization_pending until then)
curl -sS --retry 3 --retry-all-errors https://host0.ai/api/v1/auth/device/token \
-H "content-type: application/json" \
-d '{"device_code": "…"}'
# 3. save the returned api_key yourself — don't make the human run this
mkdir -p ~/.host0 && printf '%s' "h0_your_key_here" > ~/.host0/credentials && chmod 600 ~/.host0/credentialsPOST /api/v1/auth/device answers {"device_code", "user_code": "WDJB-MJHT", "verification_uri", "verification_uri_complete", "expires_in": 600, "interval": 5}. Poll the token endpoint every interval seconds:
authorization_pending until the user approves, slow_down (with a new
interval) if you poll faster, access_denied if they deny, expired_token
after 10 minutes. Once approved it returns {"api_key": "h0_…", "key_prefix", "email"} — exactly once; only the key's hash is stored, which is why the
key goes straight into ~/.host0/credentials, the conventional location every
host0 client looks in.
Send the key on every authenticated request as Authorization: Bearer h0_….
The approval page is https://host0.ai/device — a user can also
type the code there by hand.
Email code (fallback)
For an agent running on the user's own machine, an emailed 6-digit code works too — never use it from a cloud sandbox, which shouldn't be sending the user's email address anywhere:
# fallback — only for an agent on your own machine, never from a cloud sandbox
# 1. request a 6-digit code — host0 emails it
curl -sS https://host0.ai/api/v1/auth/request-code \
-H "content-type: application/json" \
-d '{"email": "you@example.com"}'
# 2. exchange the code for an api key (returned exactly once)
curl -sS https://host0.ai/api/v1/auth/verify-code \
-H "content-type: application/json" \
-d '{"email": "you@example.com", "code": "123456"}'
# 3. save it yourself — don't make the human run this
mkdir -p ~/.host0 && printf '%s' "h0_your_key_here" > ~/.host0/credentials && chmod 600 ~/.host0/credentialsVerify answers {"api_key": "h0_…", "key_prefix": "h0_ab12cd34e", "message": "…"}.
Codes expire after an hour; an optional key_name in the verify body labels the
key.
Manage keys with GET /api/v1/keys (list — never shows the secret) and
DELETE /api/v1/keys/{key_id} (revoke — takes effect immediately). The
dashboard's API keys page (/dashboard/keys) lists your keys with a revoke button too. If a key
leaks, revoke it and sign in again for a new one.
Publishing
The two-phase protocol
Publishing is a manifest-first, two-phase flow: declare what you're uploading, upload only what the server doesn't already have, then finalize. Nothing is live until finalize succeeds — a half-finished upload never serves.
- 1Create an app (once) —
POST /api/v1/appswith{"name": "my app"}returns theapp_id, randomslug, and URL. - 2Send the manifest —
POST /api/v1/apps/{app}/deploymentswith one entry per file:path,size(bytes),sha256(lowercase hex of the file bytes), and optionalcontent_type. The response listsupload.targets(files to PUT) andupload.skipped— files whose hash matches the live deployment are hash-skipped, so re-publishing a large site with one edited file uploads one file. - 3Upload each target — PUT the raw bytes to each target's
urlwith itsheaders. Use the URL exactly as returned: in production, file targets are signed upload URLs on the hosting domain (/.h0/upload/…) that need no API key, whileh0.jsonuploads to the API (/api/v1/deployments/{id}/files/h0.json) with your key. Send the key only to the API origin. Either way the bytes are checked against the manifest's size and SHA-256. - 4Finalize —
POST /api/v1/deployments/{id}/finalizeflips the deployment live atomically. Until then the previous deployment (or nothing) serves. Check progress any time withGET /api/v1/deployments/{id}.
# 2. manifest → upload targets
RESP=$(curl -sS https://host0.ai/api/v1/apps/k3x9q2m7ta/deployments \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"files": [{"path": "index.html", "size": 1234, "content_type": "text/html; charset=utf-8", "sha256": "9f86d08…"}]}')
DEPLOYMENT_ID=$(echo "$RESP" | jq -r .deployment_id)
# 3. PUT the bytes to each target's url, with its headers
# (send your API key only when the url is on https://host0.ai)
UPLOAD_URL=$(echo "$RESP" | jq -r '.upload.targets[] | select(.path == "index.html") | .url')
curl -sS -X PUT "$UPLOAD_URL" \
-H "content-type: text/html; charset=utf-8" \
--data-binary @index.html
# 4. flip it live
curl -sS -X POST "https://host0.ai/api/v1/deployments/$DEPLOYMENT_ID/finalize" \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" -d '{}'
# → {"status": "live", "deployment_id": "…", "url": "https://k3x9q2m7ta.host0.app/"}What to publish
Publish a built static site with index.html at the root of the directory —
the directory's contents become the site root. A preflight check runs before
any bytes move and rejects the whole deploy (with every problem listed) when it
sees:
- No index.html at the root — a site needs an entry point. A deploy of
exactly one file (a lone PDF, image or page) is the exception: it serves at
/. - Server code —
.py .php .rb .go .rs .javafiles; host0 doesn't run server code yet. - Unbuilt source —
.ts .tsx .jsx .vue .sveltefiles; build first and publish the output directory (dist/,build/,out/). - Project source —
node_modules/, or manifests likepackage.json,requirements.txt,Gemfile,Cargo.toml,go.mod,Dockerfile,docker-compose*. - Secrets —
.env/.env.*,*.pem,*.key,id_rsa*; never deploy credentials.
Limits
| Path | Max files | Max per file | Max total |
|---|---|---|---|
| Two-phase upload (REST + skill) | 1000 | 25 MB (26214400) | 100 MB (104857600) |
Per-account caps also apply: 100 apps,
200 deploys per hour, and
500 MB (524288000) of stored bytes (pending + live
deployments). Exceeding one returns app_limit_reached, deploy_rate_limited,
or storage_quota_exceeded. API requests are throttled to
120 per minute per API key — over the cap you get
429 rate_limited with a retry-after header (seconds).
The live values (in bytes) are always in GET /api/v1 under limits.
Access control
Public, link, private
An app uses one visibility at a time:
- Public (the default) — anyone with the URL can view. The app is unlisted and never indexed, but the URL is not secret.
- Link — anyone with the link; listed nowhere. Behaves like public, stated explicitly.
- Private — only the owner and invited emails. Visitors sign in with the invited email address to view.
# make an app private
curl -sS -X PATCH https://host0.ai/api/v1/apps/k3x9q2m7ta \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"visibility": "private"}'
# invite viewers by email
curl -sS https://host0.ai/api/v1/apps/k3x9q2m7ta/shares \
-H "authorization: bearer $HOST0_API_KEY" \
-H "content-type: application/json" \
-d '{"emails": ["sasha@team.com", "mo@team.com"]}'
# remove one
curl -sS -X DELETE https://host0.ai/api/v1/apps/k3x9q2m7ta/shares/sasha@team.com \
-H "authorization: bearer $HOST0_API_KEY"Revocation is immediate: removing an invite (or making an app private) bumps the
app's grant_version, which invalidates every existing viewer session — no
waiting for cookies to expire.
API reference
REST API v1
Base URL https://host0.ai. Authenticated endpoints take
Authorization: Bearer h0_…. Request and response bodies are JSON (uploads PUT
raw bytes). Every error uses one envelope:
{"error": "<code>", "message": "<human-readable, actionable message>", ...details}message always says what went wrong and what to change — it's written for
agents. Some codes add detail fields, noted below.
Error codes
| Code | HTTP | Meaning |
|---|---|---|
| bad_request | 400 | Malformed body or invalid field (incl. manifest validation and invalid paths) |
| size_mismatch | 400 | Uploaded bytes don't match the manifest's declared size |
| hash_mismatch | 400 | Uploaded bytes don't match the manifest's SHA-256 |
| unauthorized | 401 | Missing or invalid API key |
| invalid_code | 401 | The emailed code didn't verify (codes expire after an hour) |
| authorization_pending | 400 | Device login: the user hasn't approved yet — keep polling every {interval} seconds |
| invalid_device_code | 400 | Device login: unknown or already-used device code — start a new login |
| access_denied | 403 | Device login: the user denied it — start a new one only if they ask |
| expired_token | 410 | Device login: the code expired (10 minutes) — start a new login |
| slow_down | 429 | Device login: polling too fast — wait the returned {interval} seconds between polls |
| sign_in_required | 401 | Records: the caller is anonymous and this action needs an identity; details: {signin_url} |
| forbidden | 403 | Records: signed in, but not in the audience this action allows |
| app_limit_reached | 403 | The account is at its max apps — deploy into an existing app, or delete one you no longer need |
| storage_quota_exceeded | 403 | The deploy would push the account past its stored-bytes quota — delete apps you no longer need, or deploy fewer/smaller files |
| record_limit_reached | 403 | The app is at its max records — delete records you no longer need |
| app_not_found | 404 | No app with that id or slug in your account |
| deployment_not_found | 404 | No deployment with that id in your account |
| file_not_in_manifest | 404 | PUT path wasn't declared in the deployment's manifest |
| key_not_found | 404 | No active API key with that id in your account |
| share_not_found | 404 | That email isn't on the app's share list |
| collection_not_found | 404 | Records: the live deploy declares no such collection |
| record_not_found | 404 | Records: no record with that id in the collection |
| slug_taken | 409 | Another app already uses that slug, or another account used it in the last 30 days — pick another (check with GET /api/v1/slugs/{slug}) |
| not_pending | 409 | The deployment already finalized or failed — start a new one |
| upload_incomplete | 409 | Finalize before every file uploaded; details: {missing: [paths]} |
| too_large | 413 | An upload to the API is bigger than the per-file byte cap. A manifest over any upload cap is refused earlier, at create-deployment, with bad_request |
| record_too_large | 413 | Records: one record exceeds the per-record byte cap |
| preflight_failed | 422 | The deploy breaks the platform contract; details: {problems: [{paths, message}]} |
| invalid_manifest | 422 | h0.json is malformed; details: {problems: [messages]} naming each line to fix |
| invalid_slug | 422 | The slug is malformed or reserved — 1–63 lowercase letters, digits or hyphens, no hyphen at the start or end |
| invalid_record | 422 | Records: the body doesn't match the declared fields; details: {problems: [messages]} |
| rate_limited | 429 | Too many requests — code requests/attempts, device-login starts from one network, or over the per-key API throttle; check the retry-after header |
| deploy_rate_limited | 429 | Too many deploys started in the last hour — wait, then deploy again |
| internal | 500 | Something broke on our side — retry |
Endpoints
The live index: endpoint list, byte/file caps, and the platform contract line. Agents read this before declaring something unsupported.
Response
{
"name": "host0",
"docs": "https://host0.ai/docs",
"endpoints": [{"method": "…", "path": "…", "auth": true, "description": "…"}],
"limits": {
"upload": {"max_files": 1000, "max_file_bytes": 26214400, "max_total_bytes": 104857600},
"account": {"max_apps": 100, "max_deploys_per_hour": 200, "storage_quota_bytes": 524288000},
"rate": {"requests_per_minute_per_key": 120}
},
"contract": "Supported today: static sites — …"
}Starts a device login — the default way to get an API key. No body, nothing
personal. Show the user verification_uri_complete and user_code; they
approve on host0 in their own browser.
Response
{"device_code": "…", "user_code": "WDJB-MJHT", "verification_uri": "https://host0.ai/device", "verification_uri_complete": "https://host0.ai/device?code=WDJB-MJHT", "expires_in": 600, "interval": 5, "message": "…"}Errors: rate_limited
Polls a device login every interval seconds. Returns the API key exactly once,
after the user approves.
Request body
{"device_code": "…"}Response
{"api_key": "h0_…", "key_prefix": "h0_ab12cd34e", "email": "you@example.com", "message": "Save this key — it won't be shown again."}Errors: bad_request, authorization_pending, slow_down, access_denied, expired_token, invalid_device_code
Fallback for an agent on the user's own machine: emails a 6-digit sign-in code.
Request body
{"email": "you@example.com"}Response
{"ok": true, "message": "Code sent — check your email (from host0) and paste the 6-digit code."}Errors: bad_request, rate_limited
Exchanges the emailed code for an API key. The key is returned exactly once; only its hash is stored.
Request body
{"email": "you@example.com", "code": "123456", "key_name": "laptop"} // key_name optionalResponse
{"api_key": "h0_…", "key_prefix": "h0_ab12cd34e", "message": "Save this key — it won't be shown again."}Errors: bad_request, invalid_code, rate_limited
Creates an app — public by default. Returns 201. The URL gets a random slug;
name is only the display name. Pass slug to choose the URL up front.
Request body
{"name": "my app", "slug": "my-app"} // slug optional — random when left outResponse
{"app_id": "<uuid>", "slug": "k3x9q2m7ta", "url": "https://k3x9q2m7ta.host0.app/", "visibility": "public"}Errors: bad_request, unauthorized, invalid_slug, slug_taken
Lists your apps.
Response
{"apps": [{"app_id": "…", "name": "…", "slug": "…", "url": "…", "visibility": "public",
"live_deployment_id": "<uuid or null>", "live_since": "<iso or null>", "created_at": "<iso>"}]}Errors: unauthorized
App detail, including the share list.
Response
{"app_id": "…", "name": "…", "slug": "…", "url": "…", "visibility": "private",
"grant_version": 3, "live_deployment_id": "<uuid or null>", "live_since": "<iso or null>",
"owner_email": "you@team.com", "shares": ["sasha@team.com"], "created_at": "<iso>"}Errors: unauthorized, app_not_found
Changes who can view the app and/or renames its URL. Send either field or both. A rename moves the live deployment, records and shares to the new URL; for 30 days the old URL redirects (301, path and query kept) to the new one and is reserved for you — nobody else can claim it, and you can take it back; then it's released. A deleted app's URL is reserved for its owner the same way (it just 404s).
Request body
{"visibility": "private", "slug": "team-todo"} // visibility: private | link | publicResponse
{"app_id": "…", "slug": "team-todo", "url": "…", "old_slug": "k3x9q2m7ta", "visibility": "private", "grant_version": 4}Errors: bad_request, unauthorized, app_not_found, invalid_slug, slug_taken
Is this slug free for an app URL? Advisory — the rename checks again.
code is one of invalid, reserved, taken, yours.
Response
{"slug": "team-todo", "available": false, "reason": "team-todo is already taken — try another.", "code": "taken"}Errors: unauthorized
Deletes an app completely, live or not: its URL stops working (404) and its deployments, shares and shared records are deleted with it; its slug stays reserved for you for 30 days. Cannot be undone — export records first if you need them; to just hide an app, make it private.
Response
{"deleted": "<uuid>", "slug": "k3x9q2m7ta", "was_live": true}Errors: unauthorized, app_not_found
Invites viewers by email (matters when the app is private). Duplicates are ignored; returns the full share list.
Request body
{"emails": ["sasha@team.com", "mo@team.com"]}Response
{"slug": "…", "shares": ["mo@team.com", "sasha@team.com"]}Errors: bad_request (details: {invalid: [emails]}), unauthorized, app_not_found
Removes an invited viewer and bumps grant_version so their existing sessions
die immediately.
Response
{"removed": "sasha@team.com", "grant_version": 5}Errors: unauthorized, app_not_found, share_not_found
Starts a two-phase deploy from a manifest. Returns 201 with upload targets;
files whose SHA-256 matches the live deployment come back in skipped and need
no upload. The app is not live until finalize.
Request body
{"files": [{"path": "index.html", "size": 1234, "sha256": "<64 lowercase hex chars>",
"content_type": "text/html; charset=utf-8"}]} // content_type optional (inferred from extension)Response
{"deployment_id": "<uuid>", "url": "https://k3x9q2m7ta.host0.app/",
"upload": {
"targets": [{"path": "index.html",
"url": "https://host0.app/.h0/upload/<sha256>?size=1234&exp=…&sig=…",
"headers": {"content-type": "text/html; charset=utf-8"}}],
"skipped": ["styles.css"],
"finalize_url": "https://host0.ai/api/v1/deployments/<id>/finalize"
}}Errors: bad_request, unauthorized, app_not_found, preflight_failed (details: {problems: [{paths, message}]})
Uploads one file's raw bytes (no JSON wrapper — use --data-binary). The body
must match the manifest's declared size and SHA-256. Don't build this URL
yourself — PUT to whatever upload.targets[].url the deployment response
returns. In production that's this endpoint for h0.json; other files get a signed
/.h0/upload/… URL on the hosting domain, which takes the same body and no
API key.
Response
{"ok": true, "path": "index.html", "size": 1234}Errors: bad_request, unauthorized, deployment_not_found, file_not_in_manifest, size_mismatch, hash_mismatch, not_pending, too_large
Flips the deployment live — atomically — once every manifest file is staged. Send an empty JSON object as the body.
Response
{"status": "live", "deployment_id": "<uuid>", "url": "https://k3x9q2m7ta.host0.app/"}Errors: unauthorized, deployment_not_found, not_pending, upload_incomplete (details: {missing: [paths]})
Deployment status: staged file count, missing paths, and whether this
deployment currently serves. status is one of pending, live, failed.
Response
{"deployment_id": "…", "app_id": "…", "slug": "…", "url": "…",
"status": "pending", "file_count": 4, "total_bytes": 20480,
"files_staged": 3, "files_missing": ["app.js"], "live": false, "created_at": "<iso>"}Errors: unauthorized, deployment_not_found
Lists your API keys — ID, display prefix, name, and timestamps. The secret is
never returned; only its hash is stored. revoked_at is null for active keys.
Response
{"keys": [{"key_id": "<uuid>", "name": "laptop", "key_prefix": "h0_ab12cd34e",
"created_at": "<iso>", "last_used_at": "<iso or null>", "revoked_at": "<iso or null>"}]}Errors: unauthorized
Revokes an API key. Takes effect immediately — the next request with the revoked key gets a 401. A key may revoke itself.
Response
{"revoked": "<uuid>", "key_prefix": "h0_ab12cd34e", "revoked_at": "<iso>"}Errors: unauthorized, key_not_found
Skill
The host0 skill
The skill is how every agent publishes to host0 (the MCP server manages apps
but doesn't deploy them): a Markdown instruction file (SKILL.md) plus one Bash helper (publish.sh) that wraps the
two-phase protocol — hashing, manifest, uploads, finalize, and state tracking.
Requirements: curl, jq, and shasum/sha256sum.
# install (or update) the skill
mkdir -p ~/.claude/skills/host0/scripts
curl -sS https://host0.ai/skill.md -o ~/.claude/skills/host0/SKILL.md
curl -sS https://host0.ai/publish.sh -o ~/.claude/skills/host0/scripts/publish.sh
chmod +x ~/.claude/skills/host0/scripts/publish.sh
# first publish — creates the app
~/.claude/skills/host0/scripts/publish.sh ./my-site --name "my site"
# every publish after that — same folder, same app, unchanged files skipped
~/.claude/skills/host0/scripts/publish.sh ./my-siteThe script finds the API key in --api-key, then $HOST0_API_KEY, then
~/.host0/credentials. Other flags: --slug / --app-id (update a specific
app), --visibility private|link|public, --client <agent-name> for
attribution.
Output contract
Stdout is the live URL and nothing else. Stderr ends with machine-readable
publish_result.* lines an agent should read to report the outcome:
publish_result.url=https://k3x9q2m7ta.host0.app/
publish_result.app_id=<uuid>
publish_result.slug=k3x9q2m7ta
publish_result.action=create # or update
publish_result.deployment_id=<uuid>
publish_result.visibility=public # public | link | private
publish_result.api_key_source=credentials # flag | env | credentials
publish_result.files_total=4
publish_result.files_uploaded=4
publish_result.files_skipped=0After every publish the script writes .host0/state.json into the published
directory so repeat publishes update the same app — internal cache only, never
a source of truth.