Communitygithub.com

host0-ai/skills

host0 lets agents publish websites and apps to live URLs in seconds. Publish a folder and get a live URL at {slug}.host0.app — private by default, open to anyone with the link (unlisted) or public on the web on request. Apps can also declare record collections in an h0.json and get a shared database on their own origin, with no server code. Use when asked to "publish this", "deploy this", "host this", "put this online", "deploy to host0", "make this public", "give me a link", "share this site with someone", "roll back the last deploy", or to build a small team tool (todo list, tracker, sign-up sheet) several people use together.

What is skills?

skills is a Claude Code agent skill that host0 lets agents publish websites and apps to live URLs in seconds. Publish a folder and get a live URL at {slug}.host0.app — private by default, open to anyone with the link (unlisted) or public on the web on request. Apps can also declare record collections in an h0.json and get a shared database on their own origin, with no server code. Use when asked to "publish this", "deploy this", "host this", "put this online", "deploy to host0", "make this public", "give me a link", "share this site with someone", "roll back the last deploy", or to build a small team tool (todo list, tracker, sign-up sheet) several people use together.

Works with✓Claude Code✓Codex CLI✓Cursor✓Gemini CLI
npx skills add https://github.com/host0-ai/skills/tree/HEAD/skills/host0

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

host0

host0 hosts whatever your agent created — HTML, sites, apps with a shared database, Markdown, PDFs — at a link you can share, as easily as sharing a Google Doc. Works with any agent.

The core primitive is an app: publish any file or folder — a directory of static files, or one file on its own that opens in a browser (a PDF, an image, a single HTML page, video, audio, Markdown, CSV, text; an archive or another download-only type is rejected — extract a zip and publish the folder) — and get a live URL at https://{slug}.host0.app/ (local dev: http://{slug}.localhost:3000/). Every app is private by default — only the owner (signed in to host0) and people invited by email can open it. Opening it to anyone with the URL (no sign-in; unlisted and never indexed) is opt-in: --visibility link. --visibility public goes further — public on the web, search engines may index it — and is only for things the user wants found in search. A new app's URL is random (k3x9q2m7ta.host0.app); rename it to something readable (team-todo.host0.app) when the user wants a nicer link — see "Rename the URL" below.

An app can also hold shared data: declare record collections in an h0.json and its frontend gets a database on its own origin, no server code involved. See "Shared data" below — that's what turns a page into a tool a team uses together.

Current docs

Full docs live at https://host0.ai/docs — read them when the user asks how something works, what's possible, or before telling them a feature is unsupported.

The API also describes itself. The machine-readable index:

curl -sS {base}/api/v1

It returns the current endpoint list, file caps, and the platform contract (what host0 serves today). Send the API key (-H "authorization: bearer $HOST0_API_KEY") and limits and plan are the user's own — caps depend on the account's plan; without a key you get the defaults for a new account. GET {base}/api/v1/account adds what the account has used this month. If this skill text and live API behavior disagree, trust the live API.

To install or update this skill: npx skills add host0-ai/skills (any agent the skills CLI supports; source: https://github.com/host0-ai/skills), or fetch two files: https://host0.ai/skill.md → save as SKILL.md, and https://host0.ai/publish.sh → save as scripts/publish.sh next to it, then chmod +x scripts/publish.sh. The script's URL is /publish.sh — there's no /scripts/ in it.

Requirements

  • Required binaries: curl, jq (the script dies with a clear message if either is missing)
  • An API key, from $HOST0_API_KEY or ~/.host0/credentials. With none, publish.sh starts a device login for you — the user approves a link + code in their own browser (see "Getting an API key")
  • Bundled helper: ./scripts/publish.sh
  • Network: outbound HTTPS to host0.ai (the API) and host0.app (uploads and the live apps). In a sandbox with an egress allowlist, if a request fails with SSL_ERROR_SYSCALL, a proxy error, or a connection refused/reset every time, tell the user which domain to allow — don't loop on it.
  • Transient failures happen: some cloud sandboxes see occasional connection resets / SSL_ERROR_SYSCALL / connect timeouts reaching host0.ai. publish.sh retries those by itself (you'll see host0: network hiccup … retrying 2/4 on stderr) — just let it run. If you're calling the API with raw curl, retry the request yourself; for GETs and the device-login poll you can add --retry 3 --retry-all-errors, never for POSTs that create something.

Publish an app

./scripts/publish.sh {dir} [--name "my app"]
./scripts/publish.sh {file}  [--name "my app"]   # one file: a pdf, an image, a page

Prints the live URL on stdout (e.g. https://k3x9q2m7ta.host0.app/ — new apps get a random slug; --name is only the display name).

Add --client {your-agent} (e.g. --client claude-ai) when you're not Claude Code, Codex, Cursor or Gemini CLI — the script detects those itself. It only labels deploy stats.

Under the hood this is a three-step flow: manifest → upload files → finalize. The app is not live until finalize succeeds.

File structure: index.html must sit at the root of the directory you publish — the directory's contents become the site root. Publish my-site/ where my-site/index.html exists, not a parent folder containing my-site/.

A single file: pass the file itself (./scripts/publish.sh report.pdf) — no index.html needed. The app's root URL serves that file (in a browser tab it opens in host0's file viewer — see the badge note below; ?download gets the original, and PDFs keep range support for large ones), and it's also reachable at /{filename}. Use this whenever the user just wants to share one document or image.

Deploy built output, not source: if the project uses a build step (React/Vite/Next export, .ts/.tsx/.vue/.svelte), build first and publish the output directory (dist/, build/, out/). Never publish node_modules/, package.json, or secrets (.env, key files) — the API rejects server code and secrets with an actionable message.

Free during the beta, with a badge: host0 is free during the beta, and every app is on the free plan, which adds a small "Hosted on host0" badge fixed to the bottom-right corner (about 28px tall, 12px from the edges) of every HTML page, so leave a little room there (no fixed button or chat bubble flush in that corner) and don't try to hide it. A PDF, image, video, audio or text file (.txt, .csv, .md, .json) opened directly in a browser tab opens in a host0 file viewer: the file fills the window, with a slim bar under it (file name, Download, "Hosted on host0") that never covers it. The raw file is still served to <img>/<video> tags, embeds, fetch(), curl, link previews and search engines; ?download on any file URL downloads the original. Other files (zip, fonts, JS, CSS) are served as they are. Whether the user's plan shows the badge is plan.badge in GET {base}/api/v1 (with their key). If the user asks about pricing or removing the badge: when that index has a plans list, it names each plan, its price and what it changes (a plan with badge: false removes it) — point them at {base}/pricing; when there's no plans list, host0 is free during the beta, and paid plans may come later and may let them remove it.

Analytics, built in: host0 counts visits to every app without cookies — a tiny same-origin script (/.h0/a.js) is added to each HTML page, and opened PDFs are counted on the server. The owner sees visitors, pages, referrers, countries and devices in the dashboard's Analytics tab, with GET {base}/api/v1/apps/{app_id_or_slug}/analytics?period=7d (period: 24h, 7d, 30d, 90d, 365d; 400 invalid_period otherwise; optional filters page=/path, referrer=host or (direct), country=DE or (unknown), device, browser narrow every number — 400 invalid_filter if malformed), or the MCP get_analytics tool. Don't remove the script. If the app sets a Content-Security-Policy, allow 'self' in script-src and connect-src. When the user asks "how many people opened it?", answer from that endpoint.

Update an app

./scripts/publish.sh {dir} --slug {slug}     # or --app-id {uuid}

Without a flag, the script resolves the app from .host0/state.json in the published directory (for a single file: .host0/state.json next to the file, keyed by the file name, so two files in one folder stay two apps), and creates a new app only when nothing matches. Unchanged files are hash-skipped server-side — re-publishing a large site with one edited file uploads one file.

Add --message "what changed" (up to 200 characters) to give the new version a note — e.g. --message "Added a due date column". Do it whenever you publish a change the user asked for; it's what they'll see in the version list.

Versions and rollback

Every publish that goes live is a numbered version of the app (v1, v2, …) — publish_result.version is this one, publish_result.previous_version the one it replaced. Earlier versions are kept for a number of days that depends on the plan — GET {base}/api/v1 (with the key) → limits.account.version_retention_days (0 = only the live version); older ones are deleted automatically.

When the user wants to undo a change ("roll back", "put the old one back", "that broke it"), restore an earlier version — it goes live again for everyone as a new version within a minute, with no re-upload:

# list them, newest first (version, live, message, client, restored_from_version, created_at)
curl -sS {base}/api/v1/apps/{app_id_or_slug}/versions \
  -H "authorization: bearer $HOST0_API_KEY"

# restore version 3 (also accepts v3); the body is optional
curl -sS -X POST {base}/api/v1/apps/{app_id_or_slug}/versions/3/restore \
  -H "authorization: bearer $HOST0_API_KEY" \
  -H "content-type: application/json" \
  -d '{"message": "Back to before the redesign"}'
  • The response has version (the new one), restored_from_version and previous_version — tell the user "version 3 is live again as version 9", and that restoring version 8 undoes it.
  • Confirm with the user before restoring: it changes what everyone who opens the app sees.
  • Shared data is never changed by a restore. The restored version's h0.json decides which collections are served — one it doesn't declare is hidden (still in the export), and comes back when a newer version is restored.
  • Restoring what's already live returns already_live: true and adds no version — don't retry it in a loop.
  • 404 version_not_found: that version isn't kept any more — list the versions and offer one that is; never present an older, pruned version as restorable. 409 version_not_restorable: its files are gone — re-publish them from the user's files instead. 403 versions_not_in_plan: the user's plan keeps only the live version — re-publish the files they want live, and see "When an error has upgrade_url".
  • Set or clear a note later with PATCH {base}/api/v1/apps/{app_id_or_slug}/versions/{n} and {"message": "…"} (or null). The MCP server's list_versions and restore_version tools do the same as the calls above, and the app's Versions tab in the dashboard has a Restore button.

Access control

A host0 app uses one visibility at a time:

  • private (the default): only the owner and invited emails; visitors sign in to view. Every new app starts here.
  • link — "Anyone with the link": anyone with the URL can view, no sign-in. The app is unlisted and never indexed (every response carries X-Robots-Tag: noindex), and its URL starts out random — but a link travels, and a renamed, readable URL can be guessed — keep anything sensitive private.
  • public — "Public on the web": anyone can view and search engines may index it (the app's own pages are served with X-Robots-Tag: all). Opt-in, and capped per account (403 public_app_limit_reached, with max_public_apps in the body). Use it only when the user wants the app found in search — a portfolio, a landing page, docs, a public tool. Never for anything sensitive.

When the user wants a link anyone can open (a page to send around, a demo, a shared PDF), publish with --visibility link — that's what "make it public" or "share a link" almost always means. Choose public only when they say they want it on Google / in search results; if it's unclear, ask. Shared data works the same on link and public. Set visibility on publish with --visibility private|link|public, or any time via the API:

curl -sS {base}/api/v1/apps/{app_id_or_slug} -X PATCH \
  -H "authorization: bearer $HOST0_API_KEY" \
  -H "content-type: application/json" \
  -d '{"visibility": "link"}'

If going public returns public_app_limit_reached, tell the user the account is at its limit of apps public on the web, and offer link instead (anyone with the URL, not indexed) or moving another public app back to link.

Rename the URL

A new app's URL is a random slug like k3x9q2m7ta.host0.app. To give it a readable one, check the name, then rename:

curl -sS {base}/api/v1/slugs/team-todo -H "authorization: bearer $HOST0_API_KEY"
# → {"slug":"team-todo","available":true}   (or available:false + a reason)

curl -sS {base}/api/v1/apps/{app_id} -X PATCH \
  -H "authorization: bearer $HOST0_API_KEY" \
  -H "content-type: application/json" \
  -d '{"slug": "team-todo"}'
# → {"app_id":"…","slug":"team-todo","url":"https://team-todo.host0.app/","old_slug":"k3x9q2m7ta",…}

Slugs are 1–63 lowercase letters, digits or hyphens, with no hyphen at the start or end. A taken slug returns 409 slug_taken; a malformed or reserved one 422 invalid_slug. The live deployment, records and shares move with the app, and publish.sh keeps updating it (it tracks the app id). For 30 days the old URL redirects (301) to the new one and stays reserved for the user, then it's released — say so before renaming a link people already have. A deleted app's URL is reserved for 30 days too (it 404s). A slug another account used in the last 30 days returns 409 slug_taken ("was recently used by another app"). The user can also rename it on the Settings tab of the app's dashboard page.

The app's display name (shown in the dashboard and GET /api/v1/apps) is separate from its URL — change it with PATCH {base}/api/v1/apps/{app_id} {"name": "Team todo"} (1–100 characters); the URL doesn't change.

Sharing

Invite people by email with POST /api/v1/apps/{app_id_or_slug}/shares (body {"emails": ["[email protected]"]}); remove one with DELETE /api/v1/apps/{app_id_or_slug}/shares/{email}.

New shares receive individual invitation emails with sign-in and sign-up links. Check invite_delivery in the response: accepted means the email server accepted it, not guaranteed inbox delivery. failed, not_sent, cooldown, or rate_limited leave access granted; share the app's sign-in link or resend to an existing member with {"emails":["[email protected]"],"resend":true}. Existing shares aren't emailed again unless resend is true. Wait 60 seconds between emails to the same member. Invite at most 10 addresses per request; the account's default email limit is 500 attempts per hour.

The share list does two jobs, and this matters for team tools: on a private app it decides who may view, and on any app — link and public included — it decides who counts as members for shared-record rules (see "Shared data" below). GET /api/v1/apps/{app} returns owner_email and shares; together they are the app's members.

Shared data (a database, without a backend)

An app is not limited to static content. Put an h0.json at the root of the directory you publish, and the app gets record collections its frontend reads and writes at /.h0/data/{collection} on its own origin. No server code, no connection string, no schema migrations, and because the URLs are relative, no CORS.

This is how you build a small tool a team shares: a todo list, a ticket board, a sign-up sheet.

Declare the collections

{
  "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 (arbitrary object). Constraints: required, default, maxLength, enum.
  • The manifest is validated at deploy — a mistake fails the upload with the exact line to fix, before anything goes live. Read the message; it says what to change.
  • Adding a collection or an optional field is applied by the next publish. Removing a collection stops serving it but keeps its data (still exportable).
  • No h0.json → no records, and the site publishes exactly as before.

Shared or per-person? Ask first

Decide whose data it is before writing h0.json — if the user hasn't said, ask them (one short question: "Should everyone see the same list, or should each person get their own?").

  • Shared — everyone the app admits works on the same records: a team board, a poll, a guestbook. Leave read on viewers (or members).

  • Per-person — each visitor signs in and gets their own private records: a habit tracker, a journal, a reading list that anyone can use from the same URL. Set every action to creator:

    { "records": { "habits": {
        "fields": { "name": { "type": "string", "required": true } },
        "access": { "read": "creator", "create": "creator", "update": "creator", "delete": "creator" }
    } } }
    

    A list then returns only the caller's own records (the owner's view too — the owner sees everyone's through the export). When /.h0/me answers {"anonymous": true}, show a sign-in screen with its signin_url instead of loading data — don't fetch and redirect on page load.

owner is neither: it means only you, the app's owner. Every other visitor is shut out, signed in or not. Use it for an app that is yours alone.

Who can do what

Each action takes an audience:

AudienceMeans
viewersAnyone who can open the app — on a link or public app that includes visitors who are not signed in
membersThe owner plus the emails the app is shared with, even when anyone can open the app itself
ownerThe app owner only — you, not each visitor
creatorEach signed-in person, on their own records only (see "Shared or per-person")
["[email protected]", …]An explicit list of emails

Defaults: read and create are viewers, update and delete are members. The owner always passes. Audiences are evaluated against the app's own share list, so removing an invite revokes writing at the same moment it revokes viewing.

For a team tool, invite the team by email (POST /api/v1/apps/{app}/shares) and leave writes on members. The app can stay open to anyone with the link (link) — reading is open, editing is not.

The one thing that trips agents up. On a link or public app nothing routes a visitor through the sign-in gate — reading is open. So an invited teammate arrives anonymous, and their first edit 401s. Call /.h0/me when the page loads and, if it answers {"anonymous": true}, render its signin_url as a visible sign-in link — one the visitor chooses to follow, never an automatic redirect on page load. If you only handle 401 on the read path, you will ship a tool nobody can edit and the only symptom is a silent failure on a checkbox.

Two more facts about identity that are easy to assume wrongly:

  • 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; a records call carrying an API key is simply anonymous. There is no shell-side write path — drive the app in a browser, or use the owner export to read.
  • The invitee needs a host0 account to sign in. Sharing sends an invitation but doesn't create an account. New invitees can sign up from the email with that exact address, then open the app's signin_url.

The endpoints

MethodPathWhat it does
GET/.h0/data/{collection}List, newest first (?limit=, ?order=asc|desc) → {records, count}
POST/.h0/data/{collection}Create — body is the declared fields
GET/.h0/data/{collection}/{id}Read one
PATCH/.h0/data/{collection}/{id}Merge the body's fields into the record; absent fields are untouched
DELETE/.h0/data/{collection}/{id}Delete one → 200 {"id": …, "deleted": true} (a JSON body, never an empty 204)
GET/.h0/me{anonymous: true, signin_url} or {anonymous: false, user_id, email, owner}

Request bodies are flat; responses are wrapped. You POST/PATCH the declared fields directly ({"title": "Ship it"}), and every read and write comes back as one envelope with your fields under data and the rest — including created_by_email — as siblings:

{
  "id": "9d1f…",
  "collection": "tickets",
  "data": { "title": "Ship it", "done": false },
  "created_by": null,
  "created_by_email": "[email protected]",
  "created_at": "2026-08-03T21:00:43Z",
  "updated_at": "2026-08-03T21:04:02Z"
}

h0.json is also served as an ordinary static file at /h0.json, so anyone can read the collection names, fields and access rules. That's fine by design — it holds no secrets. Never put one in it.

A working team todo

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

// who's here? on a link or public app nobody is forced to sign in, so an invited
// teammate lands anonymous. ask, and offer them the link.
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 — that's 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();
}

whoami();
load();
setInterval(load, 4000);   // collaboration in v1 is polling

Rules to follow when you generate a records app

  • Ask shared or per-person before writing h0.json when the user hasn't said. Per-person = every action creator; owner means only the app's owner.
  • Call /.h0/me on load and show signin_url when anonymous. The single most common way a records app ships broken.
  • Escape record values when rendering them — in attributes too, not just text. The XSS surface is the app's own DOM.
  • Handle 401 on every call, not just reads: the body carries signin_url — send the visitor there rather than showing a raw error.
  • Show message on failure: every error body has a human-readable message.
  • PATCH only what changed — that's what stops two people editing the same record from clobbering each other.
  • Read created_by_email off the envelope, not out of data.
  • Don't build a login screen or store passwords. Identity is /.h0/me, and access is the app's share list.

Getting the data out

# collections + row counts
curl -sS {base}/api/v1/apps/{app_id_or_slug}/records \
  -H "authorization: bearer $HOST0_API_KEY"

# full json dump of one collection
curl -sS "{base}/api/v1/apps/{app_id_or_slug}/records/tickets?format=export" \
  -H "authorization: bearer $HOST0_API_KEY" -o tickets.json

The Data tab of the app's dashboard page shows the same, with an export button per collection.

Getting an API key

Device login (default — works everywhere, including cloud sandboxes like claude.ai)

The user signs in on host0 in their own browser and approves a short code. You never see or send their email or password.

  1. Run ./scripts/publish.sh --login — or just run the publish; with no key it starts the login itself. It exits with code 3 (waiting for the user) and prints on stderr:

    login_result.status=pending
    login_result.verification_url=https://host0.ai/device?code=WDJB-MJHT
    login_result.user_code=WDJB-MJHT
    login_result.expires_in=600
    
  2. Show the user login_result.verification_url as a clickable link and the login_result.user_code: "Open this link, sign in, check the page shows WDJB-MJHT, and approve." The code expires in 10 minutes.

  3. In the same turn, run ./scripts/publish.sh --login-wait (or the same publish command again). It waits up to 90s (--wait {seconds}) for the approval, saves the key to ~/.host0/credentials (chmod 600) and, for a publish, carries on to the live URL.

  4. Exit code 3 again means the user hasn't approved yet — run the same command again; don't start a new login (that would replace the code they're looking at). An expired code is replaced automatically, with a new link to show.

  5. login_result.status=denied (exit 1) — the user said no. Ask before starting another one.

  6. A publish that fails with host0: the api key from … was rejected (revoked, or saved from another account): a publish only starts a login when there's no key at all, so run ./scripts/publish.sh --login yourself and continue from step 2 — the approved key replaces the saved one.

The raw REST pair, if you can't run the script:

curl -sS -X POST {base}/api/v1/auth/device -H "x-host0-client: {your-agent}"
# → {"device_code":"…","user_code":"WDJB-MJHT","verification_uri_complete":"https://host0.ai/device?code=WDJB-MJHT","expires_in":600,"interval":5,…}
curl -sS --retry 3 --retry-all-errors {base}/api/v1/auth/device/token \
  -H "content-type: application/json" -d '{"device_code": "…"}'
# every `interval` seconds: authorization_pending (400) → keep polling; slow_down (429) → use the new interval;
# access_denied (403) / expired_token (410) → stop. approved → {"api_key":"h0_…","key_prefix":"…","email":"…"} exactly once.

Save the returned key yourself, immediately (mkdir -p ~/.host0 && printf '%s' "h0_…" > ~/.host0/credentials && chmod 600 ~/.host0/credentials) and never echo it.

Email code (fallback — only for an agent on the user's own machine, never from a cloud sandbox)

If the device login can't be used and you're running on the user's own computer:

  1. Ask the user for their email address.
  2. Request a one-time sign-in code:
curl -sS {base}/api/v1/auth/request-code \
  -H "content-type: application/json" \
  -d '{"email": "[email protected]"}'
  1. Tell the user: "Check your inbox for a 6-digit host0 code and paste it here."
  2. Verify the code and get the API key:
curl -sS {base}/api/v1/auth/verify-code \
  -H "content-type: application/json" \
  -d '{"email": "[email protected]", "code": "123456"}'
  1. The returned api_key (starts h0_) is shown once — save it yourself, immediately (do not ask the user to run this), replacing h0_your_key_here with the actual key from the response:
mkdir -p ~/.host0 && printf '%s' "h0_your_key_here" > ~/.host0/credentials && chmod 600 ~/.host0/credentials

API key storage

The publish script reads the key from these sources (first match wins):

  1. --api-key {key} flag (CI/scripting only — avoid in interactive use)
  2. $HOST0_API_KEY environment variable
  3. ~/.host0/credentials file (recommended for agents)

When none is set, a publish starts a device login instead (--no-login makes it fail). A key picked up by a device login during this run reports publish_result.api_key_source=device; afterwards it's the credentials file (api_key_source=credentials). A login in progress lives in ~/.host0/device.json (chmod 600) until it's approved, denied or expired — internal, never show it.

State file

After every publish, the script writes .host0/state.json inside the published directory (single-file publishes: in the file's folder, with "source": "{filename}" on the entry; directory entries carry "source": "."):

{
  "apps": {
    "k3x9q2m7ta": {
      "app_id": "…",
      "slug": "k3x9q2m7ta",
      "url": "https://k3x9q2m7ta.host0.app/",
      "base_url": "https://host0.ai",
      "source": ".",
      "updated_at": "2026-08-01T10:22:31Z"
    }
  }
}

It exists so repeat publishes update the same app. Without --slug/--app-id the script considers only entries whose base_url matches the API base it is publishing to, newest updated_at first, and uses the first one the active API key can actually read.

An entry the key cannot read (deleted app, or a state file written by another account) is reported on stderr and dropped from the file rather than silently shadowing a good entry — publish_result.state_pruned counts how many were dropped this run. If every cached entry is unusable the script says so before creating a new app, so a state_pruned above 0 together with action=create means "this published somewhere new" — tell the user, and re-run with --slug <slug> if they meant to update an existing app.

If the lookup fails for any other reason (network, 5xx) the script exits instead of creating a duplicate app.

Treat the file as internal cache only: never present this local file path as a URL, and never use it as the source of truth for visibility or auth.

What to tell the user

Read the login_result.* and publish_result.* lines from script stderr:

  • Exit code 3 with login_result.status=pending: give the user login_result.verification_url as a link and login_result.user_code, say it expires in 10 minutes and that they should check the page shows the same code, then run the same command again.

  • login_result.status=approved: say "Signed in as login_result.email". The key is saved; it never goes into the conversation.

  • login_result.status=denied: the user denied it in the browser — ask before trying again.

  • Always share publish_result.url from the current run — that is the live URL.

  • Say which version is live (publish_result.version). When publish_result.previous_version is set, add that they can say "roll back" to put version {previous_version} back — then restore it (see "Versions and rollback"), and confirm first, since it changes what everyone sees.

  • When publish_result.action=create: the URL is random — offer to rename it to something readable (see "Rename the URL"); never promise a name-based URL before publishing.

  • When publish_result.visibility=link: tell the user anyone with the URL can view it, and that it's unlisted and never indexed but not secret — suggest private if the content is sensitive.

  • When publish_result.visibility=public: tell the user it's public on the web — anyone can open it and search engines may list it. Suggest link if they only meant to share the URL.

  • When publish_result.visibility=private: tell the user only they (signed in to host0) and invited emails can open it, and viewers sign in with the email that was invited. Offer to open it to anyone with the link (link) or to invite people by email.

  • When the publish fails with public_app_limit_reached: the files are live but the app kept its previous visibility; offer --visibility link instead.

  • When a publish fails, publish_result.error is the machine code (e.g. file_too_large, deploy_too_large, storage_quota_exceeded, public_app_limit_reached, app_limit_reached) — tell the user in plain words which limit was hit, using the script's message (it names the file and the numbers). When publish_result.upgrade_url is there too, follow "When an error has upgrade_url" below.

  • When publish_result.action=create and publish_result.state_pruned is above 0: say plainly that the cached app could not be reached with this key so a new app was created at a new URL, and offer to re-publish with --slug <slug> if they wanted the old one.

  • When a publish fails: if the run had just created the app, the script deletes it again so no empty app is left behind, and reports publish_result.cleanup=deleted (with publish_result.cleanup_slug). If cleanup=failed, retry the cleanup yourself with DELETE {base}/api/v1/apps/{cleanup_slug} — or tell the user the app name from publish_result.cleanup_slug and that the app's Settings tab in the dashboard has a delete button. No cleanup* line appears on a successful publish, or when the run updated an app it did not create.

  • Mention the dashboard at the platform origin (e.g. https://host0.ai/dashboard) for managing apps in a browser.

  • Never point users at .host0/state.json.

When an error has upgrade_url

Some limits depend on the user's plan (file size, deploy size, storage, apps public on the web, version history, analytics). When a paid plan would lift the one that was hit, the error body has an upgrade_url (publish_result.upgrade_url from the script; the MCP tools put the same link at the end of the message). Then:

  1. Say which limit was hit, in plain words ("report.pdf is 4.2 MB and your plan takes files up to 3 MB").
  2. Offer the free fix first: compress or resize the file, leave big assets out, use link instead of public, move another public app back to link, or delete apps they no longer need.
  3. Only then give upgrade_url as a link, for the user to open themselves if they want to.

Never retry the same publish in a loop — it fails the same way until something changes. Never buy, upgrade or enter payment details for the user. No upgrade_url means no plan lifts it from here: just explain the limit and the free fix.

Important — never do these

  • Never send the user's email address or any other personal data from a cloud sandbox. Sign in with the device login — it sends nothing personal.

  • Never open or approve a device-login link yourself, and never ask the user for a host0 sign-in code or session. Only the user approves, in their own browser.

  • Never commit ~/.host0/credentials or .host0/state.json to source control.

  • Never echo the API key into the conversation unless the user explicitly asks for it.

  • Avoid --api-key in interactive sessions; the credentials file is the preferred storage.

  • Never send credentials to a non-default --base-url. The script refuses; only pass --allow-nonhost0-base-url when the user has explicitly confirmed the endpoint (e.g. local development).

  • Deploy built output, never project source, node_modules/, or .env files.

publish.sh options

FlagDescription
--name {text}App name when creating (default: directory basename, or the file name without its extension)
--slug {slug}Update the existing app with this slug
--app-id {uuid}Update the existing app with this id
--visibility {mode}Set private, link (anyone with the URL, never indexed), or public (public on the web, indexable) after publish
--message {text}A note for this version — what changed (up to 200 characters; longer fails with bad_request before anything uploads)
--client {name}Agent name for attribution, e.g. claude-ai, chatgpt (sends x-host0-client: {name}/{script version}; default $HOST0_CLIENT, else detected for Claude Code, Codex, Cursor, Gemini CLI…) — pass it when the script can't detect you
--api-key {key}API key override (prefer the credentials file)
--base-url {url}API base (default: https://host0.ai)
--allow-nonhost0-base-urlAllow sending auth to a non-default --base-url
--loginStart a device login (or re-show the pending one) and exit 3
--login-waitWait for the user's approval, save the key, exit 0 (3 = still pending, 1 = denied)
--wait {seconds}How long --login-wait / a publish waits for the approval (default 90, or $HOST0_LOGIN_WAIT_SECONDS)
--no-loginWith no API key, fail instead of starting a device login

Exit codes: 0 ok · 1 error · 3 waiting for the user to approve the device login.

Beyond publish.sh

Everything else — listing apps, app detail, shares, deployment status, versions — is plain REST per the live index at GET {base}/api/v1, authenticated with authorization: bearer h0_….

DELETE /api/v1/apps/{app_id_or_slug} deletes an app completely, live or not: its URL stops working (404), and its deployments, shares and shared records are deleted with it (the slug stays reserved for the user for 30 days). It cannot be undone — only do it when the user asks, confirm first, and export records first if they matter. To just take an app out of view, make it private instead. The publish script uses the same call to clean up an app whose first deploy failed (an empty app still counts against the account's app cap). The app's Settings tab in the dashboard has the same delete button.

The host0 MCP server manages, it doesn't deploy

This skill is how agents deploy to host0 — the MCP server has no deploy or create tool. (People can also publish by dropping files on their host0 dashboard in the browser.) If the user also has host0 connected as an MCP server ({base}/mcp), its tools manage apps that already exist: list_apps, get_app, get_deploy_status, get_analytics, list_versions, restore_version, set_visibility, rename_app, share, unshare, and delete_app (deletes an app completely, live or not). Use whichever is at hand for those — they call the same service as the REST endpoints above. To put new files live, always use publish.sh.

Related Skills