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.sh

Sign 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/mcp

Sign 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-wait

The 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/credentials

POST /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/credentials

Verify 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.

  1. 1Create an app (once) — POST /api/v1/apps with {"name": "my app"} returns the app_id, random slug, and URL.
  2. 2Send the manifest — POST /api/v1/apps/{app}/deployments with one entry per file: path, size (bytes), sha256 (lowercase hex of the file bytes), and optional content_type. The response lists upload.targets (files to PUT) and upload.skipped — files whose hash matches the live deployment are hash-skipped, so re-publishing a large site with one edited file uploads one file.
  3. 3Upload each target — PUT the raw bytes to each target's url with its headers. 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, while h0.json uploads 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.
  4. 4Finalize — POST /api/v1/deployments/{id}/finalize flips the deployment live atomically. Until then the previous deployment (or nothing) serves. Check progress any time with GET /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 .java files; host0 doesn't run server code yet.
  • Unbuilt source — .ts .tsx .jsx .vue .svelte files; build first and publish the output directory (dist/, build/, out/).
  • Project source — node_modules/, or manifests like package.json, requirements.txt, Gemfile, Cargo.toml, go.mod, Dockerfile, docker-compose*.
  • Secrets — .env / .env.*, *.pem, *.key, id_rsa*; never deploy credentials.

Limits

PathMax filesMax per fileMax total
Two-phase upload (REST + skill)100025 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.

Shared data

A database, without a backend

A static page covers one person looking at one thing. Most small software is several people sharing state — a team todo, a ticket board, an RSVP list. So an app can declare record collections in an h0.json at the root of its deploy, and its frontend reads and writes them at /.h0/data/… on its own origin. No server code, no connection string, no schema to migrate — and because the URLs are relative, no CORS.

Declare the collections

Ship this file with the rest of the site. It's validated at deploy: a bad manifest fails the upload with the exact line to fix, before anything goes live.

{
  "records": {
    "tickets": {
      "fields": {
        "title":    { "type": "string", "required": true, "maxLength": 200 },
        "done":     { "type": "boolean", "default": false },
        "status":   { "type": "string", "enum": ["todo", "doing", "done"], "default": "todo" },
        "assignee": { "type": "string" },
        "notes":    { "type": "string", "maxLength": 5000 }
      },
      "access": {
        "read":   "viewers",
        "create": "viewers",
        "update": "members",
        "delete": "members"
      }
    }
  }
}
  • Field types — string, number, boolean, json (an arbitrary object). Constraints: required, default, maxLength, enum. A field host0 doesn't know about is rejected by name.
  • Schema changes are additive — a new collection or a new optional field applies on the next deploy. Removing a collection stops serving it and keeps its data: hidden, still exportable, never silently destroyed.
  • No h0.json, no records — a plain static site deploys exactly as before.

Who can do what

Each action gets an audience. Audiences are evaluated against the app's own visibility and share list — there is no second permission system to keep in sync, so removing someone's invite revokes their writes at the same instant it revokes their viewing.

AudienceMeans
viewersAnyone who can open the app — on a public app that includes visitors who aren't signed in
membersThe owner plus the emails the app is shared with, even when the app itself is public
ownerThe app owner only — you, not each visitor
creatorEach signed-in person, on their own records only — a list returns just the caller's records (the owner's too), so one URL serves everyone their private data
["a@b.com", …]An explicit list of emails

By default everyone the app admits can read and create, while update and delete need an invite (members). The owner always passes every rule.

On a public app, viewers includes people who aren't signed in — that's the RSVP/poll case, and it's deliberate. For a team tool, set the writes you care about to members and invite the team by email. When a rule needs an identity the caller doesn't have, the API answers 401 sign_in_required with a signin_url — send the visitor there and they land back on the page they were on.

  • An API key never authenticates a record call. authorization: bearer h0_… is for /api/v1 on the platform origin; identity on the app's own origin is the browser session from /.h0/me. A records call carrying an API key is simply treated as anonymous.
  • An invited email has to have a host0 account before the invite lets them in — sharing does not create one, and no invite email is sent today. Tell the person to sign up with that exact address.
  • Request bodies are flat (the declared fields); responses wrap them in data alongside id, created_at, created_by_email and friends. created_by_email is on the envelope, not inside data.
  • h0.json is served as an ordinary static file, so anyone can read your collection names, fields and access rules. That's by design — it holds no secrets. Never put one in it.

The endpoints

All of them live on the app's own origin (https://k3x9q2m7ta.host0.app/), so use relative URLs.

MethodPathWhat it does
GET/.h0/data/{collection}List records, newest first. ?limit= and ?order=asc|desc. → {records: [...], count}
POST/.h0/data/{collection}Create one — body is the record's declared fields. → the record
GET/.h0/data/{collection}/{id}Read one record.
PATCH/.h0/data/{collection}/{id}Merge the fields in the body into the record; absent fields are left alone, so two people editing different fields don't clobber each other.
DELETE/.h0/data/{collection}/{id}Delete one record. → 200 {id, deleted: true} — a JSON body like every other response, never an empty 204.
GET/.h0/meWho is calling — {anonymous: true, signin_url} or {anonymous: false, user_id, email, owner}.

Every record reads and writes as one envelope:

{
  "id": "9d1f…",                       // host0 assigns it
  "collection": "tickets",
  "data": { "title": "Ship it", "done": false },   // your declared fields
  "created_by": "…" | null,            // null = written by an anonymous visitor
  "created_by_email": "ada@team.com" | null,
  "created_at": "2026-08-03T21:00:43Z",
  "updated_at": "2026-08-03T21:04:02Z"
}

A working team todo

The whole client side. Collaboration in v1 is polling: everyone sees each other's changes within a few seconds.

const api = "/.h0/data/tickets";

// records are untrusted input — they were written by other people.
const esc = (s) => String(s).replace(/[&<>"']/g,
  c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

// On a PUBLIC app nothing forces a visitor to sign in — reading is open. So an
// invited teammate arrives anonymous and their first edit would 401 with no
// explanation. Ask who's here on load, and offer the sign-in link when nobody
// is. (On a private app they're already signed in and this just names them.)
let me = { anonymous: true };
async function whoami() {
  me = await (await fetch("/.h0/me")).json();
  who.innerHTML = me.anonymous
    ? `<a href="${esc(me.signin_url)}">Sign in to edit</a>`   // attributes too, not just text
    : `Signed in as ${esc(me.email)}`;
}

// Any records call can answer 401 when the action needs an identity the
// visitor doesn't have. That is a redirect, not an error.
async function send(url, options) {
  const res = await fetch(url, options);
  if (res.status === 401) {
    location.href = (await res.json()).signin_url;   // returns to this page
    return null;
  }
  const body = await res.json();
  if (!res.ok) { show(body.message); return null; }  // messages are written to be shown
  return body;
}

async function load() {
  const page = await send(api);
  if (!page) return;
  list.innerHTML = page.records.map(r =>
    `<li data-id="${esc(r.id)}">
       <input type="checkbox" ${r.data.done ? "checked" : ""}>
       ${esc(r.data.title)}
       <small>${esc(r.created_by_email ?? "someone")}</small>
     </li>`).join("");
}

async function add(title) {
  if (await send(api, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ title }),      // flat: the declared fields
  })) load();
}

async function toggle(id, done) {
  if (await send(`${api}/${id}`, {
    method: "PATCH",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ done }),       // only the field that changed
  })) load();
}

// collaboration in v1 is polling — everyone sees each other within a few seconds
whoami();
load();
setInterval(load, 4000);

Records are untrusted input. They were typed by other people — escape them when you put them in the DOM (the example does). The XSS surface is your own page.

Limits and getting your data out

Up to 20,000 records per app, 32 KB per record, 20 collections. Anonymous visitors of a public app can write 120 times an hour per app. The live numbers are in GET /api/v1 under limits.records.

# the owner's view, ignoring the app's own access rules
curl -sS https://host0.ai/api/v1/apps/k3x9q2m7ta/records \
  -H "authorization: bearer $HOST0_API_KEY"

# full json dump of one collection
curl -sS "https://host0.ai/api/v1/apps/k3x9q2m7ta/records/tickets?format=export" \
  -H "authorization: bearer $HOST0_API_KEY" -o tickets.json

The dashboard app page shows the same thing with an export button. Static files plus a records export means you can always leave whole.

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

CodeHTTPMeaning
bad_request400Malformed body or invalid field (incl. manifest validation and invalid paths)
size_mismatch400Uploaded bytes don't match the manifest's declared size
hash_mismatch400Uploaded bytes don't match the manifest's SHA-256
unauthorized401Missing or invalid API key
invalid_code401The emailed code didn't verify (codes expire after an hour)
authorization_pending400Device login: the user hasn't approved yet — keep polling every {interval} seconds
invalid_device_code400Device login: unknown or already-used device code — start a new login
access_denied403Device login: the user denied it — start a new one only if they ask
expired_token410Device login: the code expired (10 minutes) — start a new login
slow_down429Device login: polling too fast — wait the returned {interval} seconds between polls
sign_in_required401Records: the caller is anonymous and this action needs an identity; details: {signin_url}
forbidden403Records: signed in, but not in the audience this action allows
app_limit_reached403The account is at its max apps — deploy into an existing app, or delete one you no longer need
storage_quota_exceeded403The deploy would push the account past its stored-bytes quota — delete apps you no longer need, or deploy fewer/smaller files
record_limit_reached403The app is at its max records — delete records you no longer need
app_not_found404No app with that id or slug in your account
deployment_not_found404No deployment with that id in your account
file_not_in_manifest404PUT path wasn't declared in the deployment's manifest
key_not_found404No active API key with that id in your account
share_not_found404That email isn't on the app's share list
collection_not_found404Records: the live deploy declares no such collection
record_not_found404Records: no record with that id in the collection
slug_taken409Another 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_pending409The deployment already finalized or failed — start a new one
upload_incomplete409Finalize before every file uploaded; details: {missing: [paths]}
too_large413An 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_large413Records: one record exceeds the per-record byte cap
preflight_failed422The deploy breaks the platform contract; details: {problems: [{paths, message}]}
invalid_manifest422h0.json is malformed; details: {problems: [messages]} naming each line to fix
invalid_slug422The slug is malformed or reserved — 1–63 lowercase letters, digits or hyphens, no hyphen at the start or end
invalid_record422Records: the body doesn't match the declared fields; details: {problems: [messages]}
rate_limited429Too 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_limited429Too many deploys started in the last hour — wait, then deploy again
internal500Something broke on our side — retry

Endpoints

GET/api/v1No auth

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 — …"
}
POST/api/v1/auth/deviceNo auth

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

POST/api/v1/auth/device/tokenNo auth

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

POST/api/v1/auth/request-codeNo auth

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

POST/api/v1/auth/verify-codeNo auth

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 optional

Response

{"api_key": "h0_…", "key_prefix": "h0_ab12cd34e", "message": "Save this key — it won't be shown again."}

Errors: bad_request, invalid_code, rate_limited

POST/api/v1/appsAuth: bearer key

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 out

Response

{"app_id": "<uuid>", "slug": "k3x9q2m7ta", "url": "https://k3x9q2m7ta.host0.app/", "visibility": "public"}

Errors: bad_request, unauthorized, invalid_slug, slug_taken

GET/api/v1/appsAuth: bearer key

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

GET/api/v1/apps/{app_id_or_slug}Auth: bearer key

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

PATCH/api/v1/apps/{app_id_or_slug}Auth: bearer key

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 | public

Response

{"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

GET/api/v1/slugs/{slug}Auth: bearer key

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

DELETE/api/v1/apps/{app_id_or_slug}Auth: bearer key

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

POST/api/v1/apps/{app_id_or_slug}/sharesAuth: bearer key

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

DELETE/api/v1/apps/{app_id_or_slug}/shares/{email}Auth: bearer key

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

POST/api/v1/apps/{app_id_or_slug}/deploymentsAuth: bearer key

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}]})

PUT/api/v1/deployments/{deployment_id}/files/{path}Auth: bearer key

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

POST/api/v1/deployments/{deployment_id}/finalizeAuth: bearer key

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]})

GET/api/v1/deployments/{deployment_id}Auth: bearer key

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

GET/api/v1/keysAuth: bearer key

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

DELETE/api/v1/keys/{key_id}Auth: bearer key

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-site

The 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=0

After 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.