# What is flypod (/docs)
flypod turns a directory of files into a live URL.
```sh
npx flypod ./dist
# → https://ab12cd34.flypod.dev
```
No account. No config file. No build pipeline. You point it at a folder of
static files and it returns a URL you can open immediately.
## What you can deploy [#what-you-can-deploy]
flypod serves **static sites**: HTML, CSS, browser JavaScript, images, fonts,
anything that runs in a browser. A folder of Markdown also works: if there is no
`index.html`, flypod renders the `.md` files into a small styled docs site.
There is **no server-side code**: no functions, no databases, no backend. If
your app needs a server, flypod is not the host for it.
## How it works in one breath [#how-it-works-in-one-breath]
* Every deploy is an **immutable, content-hashed version**. Redeploying the
same files is a no-op.
* Anonymous deploys are **ephemeral**: they expire after 14 days. Sign in to
make new deploys permanent.
* Ship a new version with [`flypod update`](/docs/cli/update), list history with
[`flypod versions`](/docs/cli/versions), and undo with
[`flypod rollback`](/docs/cli/rollback). flypod remembers which site a folder
belongs to, so you rarely pass an ID.
## Three ways to use it [#three-ways-to-use-it]
## Start here [#start-here]
## For AI agents [#for-ai-agents]
This site ships [`/llms.txt`](/llms.txt) (index) and
[`/llms-full.txt`](/llms-full.txt) (full text). Append `.md` to any docs URL to
get raw Markdown. When working with flypod programmatically:
* **Deploy:** run `npx flypod
`. The live URL is printed to **stdout**;
everything else goes to stderr. For structured output use
`npx flypod --json` and parse stdout.
* **Ship a change to the same site:** use `flypod update`, not a fresh deploy.
flypod links the working directory to its site automatically.
* **Authenticate** with `flypod login`. The CLI gives you a short code, opens a
browser approval page, and saves the session outside the repo. Stateless jobs
can receive an existing session through `FLYPOD_TOKEN`; otherwise the deploy
is anonymous.
* **Assume deploys are temporary** (14-day TTL) unless the site is owned by an
authenticated account.
* Exit code is `0` on success, non-zero on failure. See
[Use with agents & CI](/docs/guides/use-with-agents-and-ci).
* **Onboard a repo's agents** to flypod with
[`flypod install skill`](/docs/cli/install-skill). It writes usage
instructions into `AGENTS.md`, a Claude Code skill, and other detected agent
config files.
# Quickstart (/docs/quickstart)
You need [Node.js](https://nodejs.org) (which provides `npx`). No install, no
account, no config.
## Deploy [#deploy]
Point flypod at a folder of static files:
```sh
npx flypod ./dist
```
It zips the folder, uploads it, and prints a live URL:
```
https://ab12cd34.flypod.dev
```
Open it. Your site is live. The URL is on **stdout**; the progress banner and
hints go to stderr, so `URL=$(npx flypod ./dist)` captures just the URL.
Run `npx flypod` with no argument and it auto-detects a build directory:
the first of `dist/`, `build/`, `out/`, `.output/public`, `.vercel/output/static`,
`public/`, or `_site/` that contains an `index.html`.
## Ship a change [#ship-a-change]
Edit your files, then push a new version to the **same site**:
```sh
flypod update
```
flypod remembers which site this folder deployed to, so you don't pass an ID.
Each update is a new immutable version, and the new one goes live. Re-running
with no changes is a no-op ("already live").
## Undo [#undo]
Roll back to the previous version:
```sh
flypod rollback
```
List the full history (newest first, live version marked):
```sh
flypod versions
```
## Make it permanent [#make-it-permanent]
Anonymous deploys expire after **14 days**. To keep a site forever, deploy while
logged in:
```sh
flypod login # approve this machine in your browser
flypod ./dist # this deploy is now owned and never expires
```
Already deployed something anonymously? See
[Accounts & claiming](/docs/guides/accounts-and-claiming) to attach it to your
account.
## Next steps [#next-steps]
# Troubleshooting (/docs/troubleshooting)
Jump to the symptom you hit. Each section is self-contained: what you see, why it happens, and how to fix it.
## `Unauthorized` on `flypod update` or `flypod rollback` [#unauthorized-on-flypod-update-or-flypod-rollback]
**Why:** you are not logged in, the saved `manage_token` is wrong or missing, or the site is owned by another account.
**Fix:** log in, pass the token explicitly, or confirm ownership.
```sh
flypod login
# or
flypod update ./dist --token
```
If you still get `Unauthorized` while logged in, the site belongs to a different account, so you cannot manage it.
## `flypod update` says the site is gone (404) [#flypod-update-says-the-site-is-gone-404]
**Why:** the local folder-to-site link is stale. The site it pointed at expired or was deleted.
**Fix:** flypod auto-drops the stale link. Just deploy a fresh site.
```sh
flypod
```
## "No changes" on `flypod update` [#no-changes-on-flypod-update]
**Why:** the content hash matches the version that is already live, so there is nothing to ship.
**Fix:** nothing. This is expected, not an error. Change the build, then deploy again.
## `flypod rollback` refuses [#flypod-rollback-refuses]
**Why:** there is only one version, or the live version is already the oldest one. There is nowhere to roll back to.
**Fix:** deploy a new version first, then roll back.
```sh
flypod update ./dist
```
## Visiting a site returns 410 Gone [#visiting-a-site-returns-410-gone]
**Why:** an anonymous deploy expired. Anonymous sites have a 14-day TTL.
**Fix:** redeploy. To avoid expiry next time, deploy while logged in. To save sites you already deployed anonymously, run [`flypod login`](/docs/cli/accounts) — it auto-claims every anonymous site this CLI remembers. See [/docs/concepts/ephemerality](/docs/concepts/ephemerality).
## A site returns 451 [#a-site-returns-451]
**Why:** an operator disabled the site for abuse.
**Fix:** nothing self-serve. The site was taken down deliberately.
## A deploy is rejected with 403 [#a-deploy-is-rejected-with-403]
**Why:** the abuse guard flagged the content (for example a credential or login form whose `action` posts to another domain), or a bot challenge failed.
**Fix:** remove the flagged content (e.g. point form actions at the same origin), or retry the challenge.
## Upload rejected as too large [#upload-rejected-as-too-large]
**Why:** the deploy exceeds the size cap: **200 files / 10 MB uncompressed / 5 MB zip** per deploy.
**Fix:** trim the build (drop source maps, large media, or unused assets) so it fits under the cap.
## `No build directory found` [#no-build-directory-found]
**Why:** auto-detection looks for an `index.html` inside one of `dist/`, `build/`, `out/`, `.output/public`, `.vercel/output/static`, `public/`, or `_site/`, and found none.
**Fix:** pass the directory explicitly.
```sh
flypod ./path
```
## Markdown didn't render as a docs site [#markdown-didnt-render-as-a-docs-site]
**Why:** rendering only happens when there is **no `index.html`** at the deploy root. If an `index.html` is present, flypod serves the files as-is and skips rendering.
**Fix:** remove the root `index.html` (or deploy a directory without one) to get the rendered docs site. See [/docs/guides/render-markdown](/docs/guides/render-markdown).
# TypeScript demo (/docs/api/demo)
A complete, runnable example of deploying with the flypod library from
TypeScript. It reads a folder with `collectDist()` and ships it with
`deploySite()` (two calls) and prints a live URL.
The deploy is **anonymous and ephemeral** (no account, expires in \~14 days), so
you can run this right now with nothing to set up.
## Download and run [#download-and-run]
[**Download the example (.zip)**](/examples/library-demo.zip), or grab and run it
from the terminal:
```sh
curl -L https://docs.flypod.dev/examples/library-demo.zip -o flypod-demo.zip
unzip flypod-demo.zip && cd flypod-library-demo
bun install
bun run deploy
```
```sh
curl -L https://docs.flypod.dev/examples/library-demo.zip -o flypod-demo.zip
unzip flypod-demo.zip && cd flypod-library-demo
npm install
npm run deploy
```
It prints the live URL:
```
Deploying "./site" with flypod…
✓ Live at https://ab12cd34.flypod.dev
1 files · expires in ~14 days
Save this manage token to update or roll back the site later:
fk_…
```
Open the URL. That's the `site/` folder, live. Swap in your own build output,
or pass a path: `bun run deploy ./my-dist`.
The deploy is public and ephemeral. Don't upload anything secret. The zip is a
self-contained project: `deploy.ts`, a `site/` folder, and a `package.json`.
## The code [#the-code]
The whole program is `deploy.ts`:
```ts title="deploy.ts"
import { collectDist, deploySite, type DeployResult } from "flypod";
const dir = process.argv[2] ?? "./site";
// 1. Read the directory into a map of { path -> bytes }.
const files = await collectDist(dir);
// 2. Ship it. With no apiKey this is an anonymous, ephemeral deploy.
const result: DeployResult = await deploySite({ files });
console.log(result.url); // https://ab12cd34.flypod.dev
console.log(result.manage_token); // save to update / roll back later
```
See the [library reference](/docs/api/library) for every field on
`DeployResult` and the full `deploySite` options.
## Check it in one file [#check-it-in-one-file]
No project? The smallest possible deploy needs no files on disk: build the map
inline:
```ts title="demo.ts"
import { deploySite } from "flypod";
const files = {
"index.html": new TextEncoder().encode(
"flypodHello from flypod
",
),
};
const { url } = await deploySite({ files });
console.log(url);
```
Run it:
```sh
npm install flypod tsx
npx tsx demo.ts # or, with Bun: bun demo.ts
```
## Verify it's live [#verify-its-live]
The URL on stdout is a real, live site. Confirm it end to end:
```sh
URL=$(npx tsx demo.ts)
curl -s -o /dev/null -w "%{http_code}\n" "$URL" # → 200
```
## Make it permanent [#make-it-permanent]
Anonymous deploys expire after \~14 days. Pass an account session token obtained
through `flypod login` to make the deploy owned and permanent:
```ts
const result = await deploySite({
files,
apiKey: process.env.FLYPOD_TOKEN,
});
```
See [Accounts & claiming](/docs/guides/accounts-and-claiming) for more on
ownership.
# HTTP API (/docs/api/http)
Base URL: `https://flypod.dev`. Authenticated requests use `Authorization: Bearer `, where the token is a per-site `manage_token` or a Better Auth account session token. See [Tokens & ownership](/docs/concepts/tokens-and-ownership).
## Endpoints [#endpoints]
| Method | Path | Auth | Body |
| ------ | ---------------------- | ------------------------ | ------ |
| `GET` | `/healthz` | None | (none) |
| `POST` | `/sites` | Optional account session | zip |
| `POST` | `/sites/:id/deploys` | Required | zip |
| `POST` | `/sites/:id/rollback` | Required | JSON |
| `GET` | `/sites/:id` | Required | (none) |
| `POST` | `/sites/:id/claim` | Identity + `claim_token` | JSON |
| `POST` | `/account/bulk-attach` | Identity | JSON |
| `GET` | `/account/sites` | Identity | (none) |
## GET /healthz [#get-healthz]
Liveness check.
```sh
curl https://flypod.dev/healthz
```
```json
{ "ok": true }
```
## POST /sites [#post-sites]
Deploy a new site. Send a zip of the site as the raw request body with `content-type: application/zip`. Anonymous unless you send an account session.
```sh
curl -X POST https://flypod.dev/sites \
-H "content-type: application/zip" \
--data-binary @site.zip
```
Send `Authorization: Bearer ` to make the deploy owned and permanent.
Returns the deploy JSON: `url`, `site_id`, `version_id`, `expires_at`, `manage_token`, optional `claim_token`, optional `owner_account_id`, `next_actions`, plus warnings and render info.
Save the `manage_token` from the response. It's the bearer token for redeploys, rollback, and reads on this site.
## POST /sites/:id/deploys [#post-sitesiddeploys]
Redeploy. Uploads a new version that becomes live. Requires a bearer token (`manage_token` or account session). Body is a zip.
```sh
curl -X POST https://flypod.dev/sites//deploys \
-H "authorization: Bearer " \
-H "content-type: application/zip" \
--data-binary @site.zip
```
## POST /sites/:id/rollback [#post-sitesidrollback]
Repoint the live site to a prior version. Requires a bearer token. JSON body with the target `version_id`.
```sh
curl -X POST https://flypod.dev/sites//rollback \
-H "authorization: Bearer " -H "content-type: application/json" \
-d '{"version_id":""}'
```
An unknown version returns `404 Unknown version`.
## GET /sites/:id [#get-sitesid]
Returns the site record and its version list. Requires a bearer token.
```sh
curl https://flypod.dev/sites/ -H "authorization: Bearer "
```
## POST /sites/:id/claim [#post-sitesidclaim]
Attach a single anonymous deploy to an account using its `claim_token`. Requires an authenticated identity and a valid `claim_token` (from the original anonymous deploy response).
```sh
curl -X POST https://flypod.dev/sites//claim \
-H "authorization: Bearer " -H "content-type: application/json" \
-d '{"claim_token":""}'
```
Rate-limited per IP. Most clients use [`POST /account/bulk-attach`](#post-accountbulk-attach) instead — see below.
## POST /account/bulk-attach [#post-accountbulk-attach]
Attach multiple anonymous sites to the caller's account in one request, using each site's `manage_token` as proof of ownership. This is the endpoint `flypod login` calls to auto-claim every anonymous site the local CLI remembers.
```sh
curl -X POST https://flypod.dev/account/bulk-attach \
-H "authorization: Bearer " -H "content-type: application/json" \
-d '{"sites":[{"site_id":"abc","manage_token":"fk_..."},{"site_id":"def","manage_token":"fk_..."}]}'
```
Body: `{ sites: [{ site_id, manage_token }, …] }`, 1 to 100 items. Returns:
```json
{
"attached": ["abc"],
"skipped": [
{ "site_id": "def", "reason": "invalid_token" }
]
}
```
`reason` is one of `not_found`, `already_owned`, `already_yours`, `invalid_token`, or `invalid_input`. Rate-limited per IP.
## Account [#account]
| Endpoint | Description |
| -------------------- | -------------------------------- |
| `GET /account/sites` | List your sites. Identity-gated. |
## Serving behavior [#serving-behavior]
Served responses carry:
```
cache-control: public, max-age=60, must-revalidate
```
The `ETag` is `":"`.
| Status | Meaning |
| ------ | ------------------ |
| `404` | Unknown host. |
| `410` | Site expired. |
| `451` | Operator-disabled. |
# API & library (/docs/api)
flypod has two programmatic surfaces. Both deploy to the same backend and share one auth model.
| Surface | Import / base | Use when |
| ---------- | ---------------------------- | ------------------------------------------------------------ |
| JS library | `import { … } from "flypod"` | You deploy from Node/Bun and want types + directory helpers. |
| HTTP API | `https://flypod.dev` | You deploy from any language, a shell, or CI with `curl`. |
## Base URL [#base-url]
The public host is `https://flypod.dev`. Deployed sites are served at `https://.flypod.dev`.
## Auth model [#auth-model]
Authenticated requests use a bearer token:
```sh
Authorization: Bearer
```
The token is one of two things:
| Token | Scope | Source |
| --------------------- | ------------------------------------------ | ----------------------------------------- |
| `manage_token` | A single site (update, rollback, read). | Returned in the deploy response. Save it. |
| Account session token | Your account; deploys are owned/permanent. | `flypod login` device authorization. |
Anonymous deploys need no token. Pass an account session token to make a deploy owned and permanent. See [Tokens & ownership](/docs/concepts/tokens-and-ownership).
## Reference [#reference]
# Library (/docs/api/library)
`flypod` is a tiny, dependency-light npm package that turns a folder of static
files into a live URL from JS/TS. It's the same deploy primitive the
[CLI](/docs/cli/deploy) wraps, exposed as functions you can call directly.
```sh
npm install flypod # or: bun add flypod
```
Everything ships from one entry point. It's ESM-only and runs on Node 18+ and Bun:
```ts
import { deploySite, collectDist, resolveDeployDir, BUILD_DIR_CANDIDATES } from "flypod";
import type { DeployResult, DeployOptions } from "flypod";
```
## The mental model [#the-mental-model]
A deploy is two steps: **read files into a map, then ship the map.**
```ts
const files = await collectDist("./dist"); // { "index.html": Uint8Array, ... }
const { url, manage_token } = await deploySite({ files });
```
`deploySite` **creates a new site** every time it's called. Save the
`manage_token` it returns — that token is what lets you update or roll back the
site later. Two things worth knowing up front:
* **Deploys are anonymous by default** (public, expires in \~14 days). Pass an
account token to make a site owned and permanent — see
[Owned deploys](#owned-deploys).
* **The library only deploys.** There's no `update()` or `rollback()` function.
To ship a new version to an existing site or roll one back, use the
[HTTP API](/docs/api/http) (`POST /sites/:id/deploys`) or the
[CLI](/docs/cli/update) with the `manage_token`.
Want to run something now? The [TypeScript demo](/docs/api/demo) is a
downloadable, working example you can deploy in one command.
## deploySite [#deploysite]
```ts
deploySite(opts: DeployOptions): Promise
```
Zips a files map, uploads it as a new site, and returns the live URL plus the
tokens you need to manage it. **Throws** `Error("deploy failed: ")` if
the server responds with a non-2xx status, so wrap it in `try/catch` if you want
to handle failures.
### DeployOptions [#deployoptions]
| Field | Type | Required | Description |
| --------- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `files` | `Record` | Yes | Relative path → file bytes. Build it with [`collectDist`](#collectdist), or construct it by hand. |
| `apiKey` | `string` | No | An account token. Pass it to make the deploy **owned and permanent**. See [Owned deploys](#owned-deploys). |
| `baseUrl` | `string` | No | API host to deploy against. Defaults to `https://flypod.dev`; override it to target a self-hosted server. |
| `handle` | `(req: Request) => Promise` | No | Advanced: a custom transport used in place of global `fetch` (for tests, in-process calls, or a proxying fetch). Defaults to `fetch`. Most callers never set this. |
`apiKey` is a bit of a misnomer — it's sent verbatim as `Authorization: Bearer `, so the value is an **account session token** (from `flypod login` or
`FLYPOD_TOKEN`), not a separate API key. The field name is kept for backward
compatibility. See [Tokens & ownership](/docs/concepts/tokens-and-ownership).
### DeployResult [#deployresult]
| Field | Type | Description |
| ------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | `string` | The live URL, e.g. `https://ab12cd34.flypod.dev`. Serves immediately. |
| `manage_token` | `string` | **Save this.** Bearer token that authorizes updates, rollback, and reads for this one site. |
| `site_id` | `string` | Stable site identifier. Used in HTTP API paths (`/sites/:id/...`). |
| `slug` | `string` | The site's URL slug (the subdomain in `url`). |
| `version_id` | `string` | Identifier of the version this deploy created — pass it to rollback later. |
| `expires_at` | `number \| null` | Epoch-ms expiry for anonymous deploys; `null` when the site is owned (never expires). |
| `owner_account_id` | `string?` | The owning account's ID. Present only on owned (authenticated) deploys. |
| `claim_token` | `string?` | Anonymous deploys only. A fallback for claiming this site from a *different* machine via [`flypod claim`](/docs/cli/accounts). The usual login flow attaches sites with the `manage_token` and never needs this. |
| `next_actions` | `unknown[]` | Suggested follow-up actions returned by the server (informational). |
### Example [#example]
```ts
import { deploySite, collectDist } from "flypod";
const files = await collectDist("./dist");
const result = await deploySite({ files });
console.log(result.url); // https://ab12cd34.flypod.dev — live now
console.log(result.manage_token); // save this to update / roll back later
```
## collectDist [#collectdist]
```ts
collectDist(path: string): Promise>
```
Reads files off disk into the `{ path → bytes }` map that `deploySite` expects.
Pass a **directory** and it's walked recursively (paths are relative to the
directory, with `/` separators); pass a **single file** and you get a
one-entry map.
```ts
const site = await collectDist("./dist"); // whole folder
const one = await collectDist("./index.html"); // { "index.html": Uint8Array }
```
You don't have to use `collectDist` — any `Record` works, so
you can generate files in memory (see the [inline demo](/docs/api/demo#check-it-in-one-file)).
## resolveDeployDir [#resolvedeploydir]
```ts
resolveDeployDir(opts?: { cwd?: string; explicit?: string }): Promise
```
Figures out **which folder to deploy** so callers don't have to hardcode `dist`.
It's async. Behavior:
* With `explicit` set, that path is returned as-is.
* Otherwise it scans [`BUILD_DIR_CANDIDATES`](#build_dir_candidates) in order
(relative to `cwd`, default `process.cwd()`) and returns the **first folder
that exists *and* contains an `index.html`**.
* If nothing matches, it **throws** — pass the folder explicitly in that case.
```ts
import { resolveDeployDir, collectDist, deploySite } from "flypod";
const dir = await resolveDeployDir(); // auto-detect, e.g. "/proj/dist"
const files = await collectDist(dir);
await deploySite({ files });
```
## BUILD\_DIR\_CANDIDATES [#build_dir_candidates]
The ordered list `resolveDeployDir` searches. `public` ranks low because some
tools use it for *source* assets — requiring an `index.html` disambiguates a
built site from source.
```ts
["dist", "build", "out", ".output/public", ".vercel/output/static", "public", "_site"]
```
## Owned deploys [#owned-deploys]
By default a deploy is anonymous: public, `noindex`, and it expires in \~14 days.
To make a site **owned and permanent**, pass an account session token as
`apiKey`. The standard source is the `FLYPOD_TOKEN` environment variable, which
`flypod login` and CI both use:
```ts
const result = await deploySite({
files,
apiKey: process.env.FLYPOD_TOKEN,
});
// result.expires_at === null, result.owner_account_id is set
```
See [Tokens & ownership](/docs/concepts/tokens-and-ownership) for how tokens and
ownership fit together, and [Accounts & claiming](/docs/guides/accounts-and-claiming)
to attach sites you already deployed anonymously.
# Account commands (/docs/cli/accounts)
Accounts are optional. Anonymous deploys still work without one and expire after
14 days. A connected CLI creates owned deploys with no expiry.
## flypod login [#flypod-login]
```sh
flypod login
```
The CLI requests a device code, prints the code and approval URL, and waits for
you to approve the request in a signed-in browser. It then saves the returned
session token in `session.json` (mode `0600`) inside your per-user flypod config
directory. Nothing is written to the project.
If you already hold a Better Auth session token, you can save it directly:
```sh
flypod login --token
```
For a stateless agent or CI job, prefer the environment instead of a session
file:
```sh
FLYPOD_TOKEN= flypod ./dist --json
```
Session tokens act as your account. Store them in a secret manager, never commit
them, and do not confuse them with a site's `manage_token`.
### Auto-attach on login [#auto-attach-on-login]
After login, the CLI finds anonymous sites remembered on this machine and sends
their stored `manage_token`s to `POST /account/bulk-attach`. Successfully
attached sites become owned and permanent. The tokens stay in the mode-`0600`
project registry and travel only over HTTPS.
## flypod logout [#flypod-logout]
```sh
flypod logout
```
Deletes the saved session from `session.json`. It does not revoke other browser
or CLI sessions and does not delete sites.
## flypod list [#flypod-list]
```sh
flypod list
```
Lists owned sites as JSON on stdout. Requires a saved login or `FLYPOD_TOKEN`.
## flypod claim [#flypod-claim]
```sh
flypod claim
```
Attaches one anonymous deploy to the authenticated account. This is mainly a
cross-machine fallback: normal login auto-attaches sites the local CLI already
remembers.
## Credential precedence [#credential-precedence]
For account operations and deploys, flypod resolves credentials in this order:
1. `FLYPOD_TOKEN` for the current process.
2. The session saved by `flypod login`.
3. `FLYPOD_API_KEY`, accepted only as a legacy compatibility fallback.
4. No credential, which produces an anonymous deploy.
See [Tokens and ownership](/docs/concepts/tokens-and-ownership) for the
difference between account sessions, `manage_token`, and `claim_token`.
# flypod [path] (/docs/cli/deploy)
The default command deploys a directory or single file. Logged in, the deploy is owned and permanent. Anonymous, it lives 14 days; running `flypod login` later automatically claims every anonymous site this CLI remembers, so you don't need to track tokens to make a deploy permanent.
## Synopsis [#synopsis]
```sh
flypod [path]
```
With no path, flypod auto-detects the first of these directories that contains an `index.html`:
```sh
dist/
build/
out/
.output/public
.vercel/output/static
public/
_site/
```
The URL prints to stdout. The banner and hints print to stderr. In a non-TTY context, animation is stripped automatically. On success, flypod saves a folder-to-site link so [`flypod update`](/docs/cli/update) knows where to ship next.
## Flags [#flags]
| Flag | Description |
| ----------------- | -------------------------------------------------------- |
| `--json` | Emit a single JSON object (see below). |
| `-q`, `--quiet` | Print the URL on stdout; print `manage_token` on stderr. |
| `-h`, `--help` | Print usage and exit. |
| `-v`, `--version` | Print the CLI version and exit. |
## Examples [#examples]
Deploy the auto-detected build directory:
```sh
flypod
```
```sh
https://k3p9x2.flypod.dev
```
Deploy an explicit directory:
```sh
flypod ./dist
```
Machine-readable output:
```sh
flypod ./dist --json
```
```json
{
"ok": true,
"url": "https://k3p9x2.flypod.dev",
"site_id": "k3p9x2",
"version_id": "v_8f1a",
"expires_at": null,
"manage_token": "mt_...",
"claim_token": null,
"owner_account_id": "acc_...",
"source": "dist",
"files": 12,
"bytes": 48213,
"elapsed_ms": 742
}
```
## Notes [#notes]
* Logged-in deploys have `expires_at: null` and an `owner_account_id`. Anonymous deploys carry an `expires_at` 14 days out and a `claim_token`.
* Use `--quiet` in scripts to capture the URL cleanly while keeping the `manage_token` out of stdout.
* To make an anonymous deploy permanent later, just run [`flypod login`](/docs/cli/accounts) — it auto-claims every anonymous site this CLI remembers.
* See [Tokens and ownership](/docs/concepts/tokens-and-ownership) for how account sessions, `manage_token`, and `claim_token` differ.
Authenticate first with [`flypod login`](/docs/cli/accounts) to get permanent, owned deploys from the start.
# flypod forget (/docs/cli/forget)
`flypod forget` removes the local folder-to-site link. The deployed site is not affected; this only clears local memory in `projects.json`.
## Synopsis [#synopsis]
```sh
flypod forget [--site ]
```
With no flag, flypod forgets the current folder's link. With `--site`, flypod forgets every folder linked to that site id.
## Flags [#flags]
| Flag | Description |
| -------------- | ------------------------------------------- |
| `--site ` | Forget every folder linked to this site id. |
| `-h`, `--help` | Print usage and exit. |
## Examples [#examples]
Forget the current folder's link:
```sh
flypod forget
```
Forget all folders linked to a site:
```sh
flypod forget --site k3p9x2
```
## Notes [#notes]
* This does not delete or expire the deployed site. It only removes local links so [`flypod update`](/docs/cli/update), [`flypod versions`](/docs/cli/versions), and [`flypod rollback`](/docs/cli/rollback) no longer resolve a site automatically from this folder.
* After forgetting, run [`flypod`](/docs/cli/deploy) to create a new deploy and link, or pass `--site ` to the version commands explicitly.
# CLI reference (/docs/cli)
flypod is a zero-auth static-site deploy primitive. One command ships a directory or single file to a public URL. Logged in, deploys are permanent and owned; anonymous, they live 14 days. Version **0.3.0**.
## Install [#install]
```sh
npx flypod
```
Or install globally:
```sh
npm i -g flypod
```
The binary is `flypod`. The first non-flag positional that matches a known subcommand is dispatched; otherwise the argument is treated as a deploy path. An unknown flag exits with code `2`. Success exits `0`.
## Commands [#commands]
## Global flags [#global-flags]
| Flag | Description |
| ----------------- | ----------------------------------------------------- |
| `--json` | Emit a single machine-readable JSON object on stdout. |
| `-q`, `--quiet` | Print only the essential value (URL) on stdout. |
| `-h`, `--help` | Print usage and exit. |
| `-v`, `--version` | Print the CLI version and exit. |
## Environment variables [#environment-variables]
| Variable | Description |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| `FLYPOD_TOKEN` | Use an account session token for this process (agents / CI). Takes precedence over a saved login. |
| `DEPLOY_URL` | Override the server base URL. Default `https://flypod.dev`. |
| `FLYPOD_CONFIG_DIR` | Override the config directory. |
| `NO_COLOR` | Disable colored output. |
| `FORCE_COLOR` | Force colored output. |
## Configuration files [#configuration-files]
flypod stores config in a per-user directory, never in your project repo. Override the location with `FLYPOD_CONFIG_DIR`.
| Platform | Default location |
| -------- | ---------------------------------------------------------- |
| macOS | `~/Library/Application Support/flypod` |
| Windows | `%APPDATA%/flypod` |
| Linux | `$XDG_CONFIG_HOME/flypod` (defaults to `~/.config/flypod`) |
Two files are written with mode `0600`:
* `session.json`: the account session created by `flypod login`.
* `projects.json`: the folder-to-site registry. LRU-capped at 64 entries; expired entries auto-prune.
Config files are never placed in your project repo. Nothing flypod writes needs to be committed.
## Authentication [#authentication]
Connect the CLI through browser device authorization:
```sh
flypod login
```
For a stateless agent or CI job, provide an existing account session token:
```sh
FLYPOD_TOKEN= npx flypod ./dist
```
`FLYPOD_TOKEN` takes precedence over a saved login. With neither, the deploy is anonymous. See [Account commands](/docs/cli/accounts).
# flypod install skill (/docs/cli/install-skill)
`flypod install skill` teaches the coding agents working in a repo how to deploy
with flypod. It writes a small, self-contained instruction file into each
agent's config format, so the next time you ask an agent to "deploy this," it
knows to run `flypod`.
```sh
flypod install skill
```
By default it writes two files: the universal **`AGENTS.md`** (read by Codex,
Cursor, Copilot, Gemini, Zed, and \~20 other tools) and a first-class **Claude
Code skill**. Any other supported agent that it detects in the repo is added on
top.
Run it from your project root. The command is non-interactive by default (safe
for agents and CI); when a human runs it in a terminal with no target flags, an
arrow-key picker appears to choose targets.
## Synopsis [#synopsis]
```sh
flypod install skill [targets] [flags]
```
## Targets [#targets]
Each target is one agent's instruction-file convention. `AGENTS.md` and Claude
are always written; the rest are written when detected (or when you pass their
flag).
| Target | Flag | File written | Default |
| --------------------- | ------------ | --------------------------------- | ----------- |
| AGENTS.md (universal) | `--agents` | `AGENTS.md` | Always |
| Claude Code | `--claude` | `.claude/skills/flypod/SKILL.md` | Always |
| Cursor | `--cursor` | `.cursor/rules/flypod.mdc` | If detected |
| GitHub Copilot | `--copilot` | `.github/copilot-instructions.md` | If detected |
| Windsurf | `--windsurf` | `.windsurf/rules/flypod.md` | If detected |
| Cline | `--cline` | `.clinerules/flypod.md` | If detected |
| Roo Code | `--roo` | `.roo/rules/flypod.md` | If detected |
| Gemini CLI | `--gemini` | `GEMINI.md` | If detected |
Passing one or more target flags writes exactly those targets (overriding the
defaults).
## Flags [#flags]
| Flag | Description |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `--all` | Write every supported target, detected or not. |
| `--global` | Write to your home directory instead of the project (for `AGENTS.md`, Claude, and Gemini, which have standard global locations). |
| `--list` | Print which targets are detected and where each would be written; write nothing. |
| `--dry-run` | Show the files that would be written, then exit without touching disk. (`--print` is an alias.) |
## Examples [#examples]
Install the defaults (AGENTS.md + Claude, plus anything detected):
```sh
flypod install skill
```
See what's detected without writing anything:
```sh
flypod install skill --list
```
Write only the Cursor and Claude files:
```sh
flypod install skill --cursor --claude
```
Install into every supported agent:
```sh
flypod install skill --all
```
## Idempotency [#idempotency]
The command is safe to re-run. Shared files like `AGENTS.md` get a delimited
`flypod` block that is replaced in place on each run, leaving the rest of the
file untouched. Dedicated files (the Claude skill, Cursor rules) are
flypod-owned and rewritten wholesale. A target that's already up to date is
reported as unchanged.
# flypod rollback (/docs/cli/rollback)
`flypod rollback` makes a previous version live again. With no argument it rolls back to the version immediately before the current live one.
## Synopsis [#synopsis]
```sh
flypod rollback [version]
```
The `version` argument accepts an exact `version_id` or an unambiguous prefix of one. With no argument, flypod targets the previous version. The target site is resolved from the folder-to-site link, or from `--site`.
## Flags [#flags]
| Flag | Description |
| ------------------------ | -------------------------------------------------------- |
| `--site ` | Target a specific site id instead of the linked one. |
| `--token ` | Authorize with a `manage_token` instead of your session. |
| `--json` | Emit a single JSON object. |
| `-q`, `--quiet` | Print only the essential value on stdout. |
| `-h`, `--help` | Print usage and exit. |
## Examples [#examples]
Roll back to the previous version:
```sh
flypod rollback
```
```sh
https://k3p9x2.flypod.dev
```
Roll back to a specific version by prefix:
```sh
flypod rollback v_5a
```
## Notes [#notes]
* An ambiguous or unknown prefix is rejected before any request is made; nothing changes server-side.
* flypod refuses to roll back when only one version exists, or when the live version is already the oldest.
* Use [`flypod versions`](/docs/cli/versions) to find the version id or prefix to target.
# flypod update (/docs/cli/update)
`flypod update` ships a new version to the site linked to the current folder, or to an explicit `--site`. The new version becomes live immediately.
## Synopsis [#synopsis]
```sh
flypod update [path]
```
The path follows the same auto-detection rules as [`flypod [path]`](/docs/cli/deploy). flypod resolves the target site from the folder-to-site link saved on the last deploy, or from `--site`.
If the content is byte-identical to what is already live, flypod prints `No changes` and ships nothing.
## Flags [#flags]
| Flag | Description |
| ------------------------ | -------------------------------------------------------- |
| `--site ` | Target a specific site id instead of the linked one. |
| `--token ` | Authorize with a `manage_token` instead of your session. |
| `--json` | Emit a single JSON object. |
| `-q`, `--quiet` | Print only the essential value on stdout. |
| `-h`, `--help` | Print usage and exit. |
## Examples [#examples]
Ship a new version to the linked site:
```sh
flypod update ./dist
```
```sh
https://k3p9x2.flypod.dev
```
No-op when nothing changed:
```sh
flypod update ./dist
```
```sh
No changes (already live)
```
Target a specific site with a manage token:
```sh
flypod update ./dist --site k3p9x2 --token mt_...
```
## Notes [#notes]
* A `401` or `403` response means you are not authorized to update that site.
* A `404` means the linked site no longer exists. flypod drops the stale link and tells you to run [`flypod`](/docs/cli/deploy) to create a fresh deploy.
* Use [`flypod versions`](/docs/cli/versions) to inspect history and [`flypod rollback`](/docs/cli/rollback) to revert.
# flypod versions (/docs/cli/versions)
`flypod versions` lists a site's versions, newest first. The live version is marked `[live]`.
## Synopsis [#synopsis]
```sh
flypod versions
```
The target site is resolved from the folder-to-site link saved on the last deploy, or from `--site`.
## Flags [#flags]
| Flag | Description |
| -------------- | --------------------------------------------------------------- |
| `--site ` | List versions for a specific site id instead of the linked one. |
| `--json` | Emit a single JSON object (see below). |
| `-h`, `--help` | Print usage and exit. |
## Examples [#examples]
List versions for the linked site:
```sh
flypod versions
```
```sh
v_8f1a 2026-06-18T14:22:03Z [live]
v_7c0d 2026-06-17T09:11:48Z
v_5a92 2026-06-15T18:40:12Z
```
Machine-readable output:
```sh
flypod versions --site k3p9x2 --json
```
```json
{
"ok": true,
"site_id": "k3p9x2",
"live_version_id": "v_8f1a",
"versions": [
{ "version_id": "v_8f1a", "created_at": "2026-06-18T14:22:03Z" },
{ "version_id": "v_7c0d", "created_at": "2026-06-17T09:11:48Z" },
{ "version_id": "v_5a92", "created_at": "2026-06-15T18:40:12Z" }
]
}
```
## Notes [#notes]
* Each plain-text row is `version_id created_at [live]`. Only the live version carries the `[live]` marker.
* Pass a `version_id` (or an unambiguous prefix of one) from this list to [`flypod rollback`](/docs/cli/rollback).
# Comments (/docs/concepts/comments)
flypod comments turn a deployed page into a lightweight review surface. When
comments are enabled, HTML responses receive a same-origin widget. A visitor can
highlight text, leave a note anchored to that passage, and continue the thread
without creating an account.
The shorter setup instructions live in [Enable comments](/docs/guides/enable-comments).
## What is available today [#what-is-available-today]
* A floating comments panel injected into flypod-hosted HTML.
* Stable pseudonymous visitor identity scoped to one deployed site.
* Text-selection and page-level anchors.
* Threaded replies, resolve/reopen state, and visitor rename.
* Anonymous, passcode, and owner-approved access modes.
* A JSON-LD companion feed for machine-readable review data.
## Hosted status [#hosted-status]
The comments library also contains an MCP transport, agent-token model, webhook
configuration, signing, and delivery primitives. Those pieces are not yet a
complete hosted flypod.dev feature:
* Hosted agent-token lookup is not wired, so owners cannot currently mint a
usable `fau_` token for the MCP surface.
* Hosted webhook dispatch is a no-op; configuring a URL does not deliver
events yet.
Do not treat the MCP or webhook surface as available on hosted flypod.dev until
those integration points are connected.
## Visitor identity [#visitor-identity]
The widget mints a `flypod_cmt` cookie on first interaction. It contains a
random identity, site ID, display pseudonym, and issued-at time protected by an
HMAC signature. The cookie is `HttpOnly`, `Secure`, `SameSite=Lax`, and scoped
to the deployed site host.
That host scope matters: a visitor to `site-a.flypod.dev` does not carry the same
comment identity into `site-b.flypod.dev`.
Visitors can rename themselves. The stable random identity remains unchanged,
so earlier comments display the new name without changing authorship.
## Anchoring [#anchoring]
Text threads use the [W3C Web Annotation Data Model](https://www.w3.org/TR/annotation-model/):
```json
{
"source": "https://abc.flypod.dev/about",
"selector": [
{
"type": "TextQuoteSelector",
"exact": "the canonical playbook",
"prefix": "This page is ",
"suffix": " for driving flypod"
}
]
}
```
The surrounding prefix and suffix help relocate the passage after small text
changes. Comments created without a selection use a page-level anchor.
## Modes [#modes]
The server enforces one of three modes:
| Mode | Read | Post | Visibility |
| --------- | -------------------- | -------------------------------- | --------------------------------- |
| Anonymous | Public | Visitors with a pseudonym cookie | Immediate |
| Passcode | Valid grant or owner | Valid grant or owner | Immediate |
| Approved | Public | Anyone with a pseudonym cookie | Non-owner posts wait for approval |
Configure an owned site from the CLI:
```sh
flypod comments enable --site --mode approved
flypod comments mode passcode --site --passcode
flypod comments off --site
```
Disabling comments hides the widget but preserves stored threads.
## Replies and moderation [#replies-and-moderation]
Replies belong to the original thread and render in chronological order. In
approved mode, non-owner root comments and replies remain visible to their
author and the owner while hidden from everyone else. Owners can approve,
reject, resolve, and reopen threads.
## Companion JSON [#companion-json]
The comments service can render a JSON-LD `OrderedCollection` of W3C
Annotations. The site-wide route is `/comments.json`; page-specific companions
use `/.comments.json`. HTML advertises the relevant companion with a
`Link` header when comments are enabled.
## Security boundaries [#security-boundaries]
1. Visitor identity is isolated by site host.
2. Comment bodies render as text, not trusted HTML.
3. Anchor selector types and body limits are validated server-side.
4. Passcode grants and identity cookies are HMAC-signed with distinct secrets.
5. Frozen sites block normal writes while leaving abuse reporting available.
## Not yet shipped on hosted flypod.dev [#not-yet-shipped-on-hosted-flypoddev]
* Usable owner-minted agent tokens for MCP.
* Outbound webhook delivery.
* Realtime push; clients currently poll.
* Embedding the same-origin widget on non-flypod hosts.
# Ephemerality & TTL (/docs/concepts/ephemerality)
flypod deploys are **ephemeral by default**. An anonymous deploy gets a 14-day
time-to-live and is then deleted. Ownership removes the expiry entirely; an
owned site is permanent.
## The 14-day TTL [#the-14-day-ttl]
When you deploy without authenticating, flypod sets:
```
expires_at = now + 14 days
```
When you deploy with an account session, it sets:
```
expires_at = null // permanent
```
So the TTL is a property of **ownership**, not of the content. The same files
expire if deployed anonymously and live forever if deployed by an account.
## The garbage collector [#the-garbage-collector]
A garbage collector periodically sweeps expired sites, deleting both their files
and their metadata. On the hosted Worker it runs about **every 5 minutes**, so
expiry is enforced shortly after `expires_at` passes, not lazily on next
request.
Once a site is expired, requests to it return `410 Gone`
(see [status codes](/docs/concepts/how-it-works)). After GC removes it, the data
is gone.
Treat anonymous deploys as disposable. If a URL needs to outlive 14 days, make
the site owned **before** it expires. There is no recovery after GC sweeps it.
## How to persist a site [#how-to-persist-a-site]
There are two ways to get a permanent (`expires_at = null`) site:
```sh
flypod login # approve this machine in your browser
flypod ./dist # owned, expires_at = null
```
Already deployed anonymously? Just run `flypod login`. The CLI auto-claims every
anonymous site it remembers locally, sending each site's stored `manage_token` to
`POST /account/bulk-attach` — no copy-paste, no tokens on the terminal. For the
dashboard, the equivalent **bulk-claim** runs on sign-up: every site you deployed
from that browser session re-points to your new account in one step. See
[Tokens & ownership](/docs/concepts/tokens-and-ownership) for the details.
# How it works (/docs/concepts/how-it-works)
flypod is two planes. The **deploy pipeline** turns an uploaded zip into an
immutable version and points a site at it. The **serve plane** maps an incoming
request's subdomain to that site and returns its files. Everything else
(versioning, TTL, ownership) hangs off these two.
## The deploy pipeline [#the-deploy-pipeline]
A deploy is a single `POST /sites` with a zip body. flypod processes it in
order:
**Ingest the zip.** Files are unpacked under size and count caps, with a
path-traversal guard so no entry can escape the site root.
**Render Markdown (if needed).** If the upload has no `index.html` but does
contain Markdown files, flypod renders them into a small styled site.
**Ensure an `index.html` exists.** Every site must resolve `/` to something, so
flypod guarantees an index page is present.
**Abuse scan.** Contents are checked against abuse rules before anything goes
live.
**Detect SPA.** flypod inspects the site to decide whether it's a single-page
app. This flag drives the serve-plane fallback (below).
**Store an immutable version.** The processed content is hashed and stored as a
[version](/docs/concepts/versioning) that never changes.
**Create or point the site.** A new site is created (or an existing one's live
pointer is moved) to serve that version.
The response carries the live URL, the credentials
([tokens](/docs/concepts/tokens-and-ownership)), the
[expiry](/docs/concepts/ephemerality), any warnings, and a render summary.
## The serve plane [#the-serve-plane]
Each site lives at `https://.flypod.dev`. The **subdomain selects the
site**. That's the entire routing model. A request to `/` serves the site's
`/index.html`.
Responses are cacheable and revalidate cheaply:
```http
ETag: ":"
cache-control: public, max-age=60, stale-while-revalidate=86400
```
The ETag is the live version id plus the path, so a request that matches gets a
`304 Not Modified`. Because the version id changes on every deploy, a new
version automatically invalidates old cache entries.
### SPA fallback [#spa-fallback]
If a request path has **no file extension** and no file matches, flypod falls
back to serving `index.html`, but **only if the live version was detected as a
single-page app**. This lets client-side routers handle `/about` without a
real `about.html`. Static sites get a normal `404` for the same request.
### Status codes [#status-codes]
| Status | When |
| ---------- | ------------------------------------------------- |
| `200` | File found and served |
| `304` | ETag matched (`If-None-Match`) |
| `404` | Unknown host, or a missing file on a non-SPA site |
| `410 Gone` | The site [expired](/docs/concepts/ephemerality) |
| `451` | The site was disabled by an operator |
### SEO and indexing [#seo-and-indexing]
flypod injects invisible attribution into served HTML: `meta` tags and JSON-LD,
no visible badge. Anonymous sites are additionally served with
`x-robots-tag: noindex`, keeping ephemeral deploys out of search engines.
# Tokens & ownership (/docs/concepts/tokens-and-ownership)
A site is either **anonymous** and ephemeral or **owned** by an account with no
expiry. Three credentials appear in that lifecycle.
## Credential map [#credential-map]
| Credential | Scope | Used for |
| --------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| Account session token | One account | Owned deploys, account site listing, and managing sites owned by that account. Created by the `flypod login` device flow. |
| `manage_token` | One site | Reading metadata, adding versions, rolling back, and proving control during login auto-attach. |
| `claim_token` | One anonymous site | Cross-machine fallback for attaching an anonymous site to an account. |
### Account session token [#account-session-token]
`flypod login` obtains a Better Auth session token after you approve a device in
the browser. The CLI saves it outside the project and sends it as a bearer token.
For stateless automation, `FLYPOD_TOKEN` can supply an existing session directly.
Treat this token like a password: it represents the whole account. The old
custom `fk_` API-key system and its key-creation subcommand are retired.
`FLYPOD_API_KEY` remains only as a legacy environment-variable fallback.
### manage\_token [#manage_token]
Every deploy returns a `manage_token`. It authorizes the version loop for that
specific site. Anonymous CLI deploys store it in `projects.json` so
`flypod update` and `flypod rollback` work without flags.
If you use the HTTP API directly, save the token yourself. The server stores
only its hash and cannot recover it later.
### claim\_token [#claim_token]
Anonymous deploys also return a `claim_token`. Use it with
`flypod claim ` when you want to attach a deploy from a
different machine. The normal same-machine login path uses the locally stored
`manage_token` instead.
## Anonymous vs owned [#anonymous-vs-owned]
| | Anonymous | Owned |
| --------------- | ------------------------------ | ------------------------------------------- |
| Created by | Deploy with no account session | Deploy with a saved login or `FLYPOD_TOKEN` |
| Managed via | `manage_token` | Account session or `manage_token` |
| Expiry | 14 days | None |
| Search indexing | `noindex` | Indexable |
# Versioning (/docs/concepts/versioning)
Every deploy produces a **version**: an immutable snapshot of the site's
content. A site has one **live pointer** that selects which version it serves.
Deploying, updating, and rolling back are all just operations on versions and
that pointer.
## Content-hashed version ids [#content-hashed-version-ids]
A `version_id` is the **first 16 hex characters of the SHA-256 hash of the
deploy's content**. The id is derived entirely from what you uploaded, which
makes deploys idempotent:
* Identical content always produces the **identical version id**.
* A redeploy of unchanged content stores **no duplicate**. The existing version
is reused.
* [`flypod update`](/docs/cli/update) with no changes reports
`No changes` and does nothing.
```sh
flypod update
# No changes (already live)
```
## Versions are immutable [#versions-are-immutable]
Once stored, a version never changes. There is no "edit a version"; you only
ever create new ones. Every deploy and every `update` creates a version (unless
the content already exists, per above).
## The live pointer [#the-live-pointer]
A site serves exactly one version at a time: the one its **live pointer**
references. The serve plane reads this pointer to pick which files to return,
and embeds the live version id in the [ETag](/docs/concepts/how-it-works).
Two commands move the pointer:
| Command | Moves the live pointer to |
| --------------------------------------- | ------------------------------------------------------- |
| [`flypod update`](/docs/cli/update) | The newest version (deploying first if content changed) |
| [`flypod rollback`](/docs/cli/rollback) | Any prior version in history |
## Update vs rollback [#update-vs-rollback]
`flypod update` ships forward: it deploys the current folder as a new version
(or reuses an existing one) and points the site at it.
`flypod rollback` moves the live pointer back to an earlier version. **Nothing
is deleted**. The versions you rolled past still exist, so you can roll forward
again at any time. List the full history, newest first, with
[`flypod versions`](/docs/cli/versions).
Because rollback only moves a pointer, it is instant and reversible. The version
you're "leaving" is not removed; rolling forward re-points the site to it.
# Accounts & claiming (/docs/guides/accounts-and-claiming)
Anonymous deploys expire after **14 days**. Deploys made through an authenticated
account are owned and have no expiry.
## Connect the CLI [#connect-the-cli]
```sh
flypod login
```
The command prints a short confirmation code and opens flypod in your browser.
Sign in, verify that the displayed code matches, and approve the device. The CLI
stores the resulting session in your user config directory, outside the repo.
Every later deploy from that CLI is attributed to the account:
```sh
flypod ./dist
```
Log out by deleting only the local CLI session:
```sh
flypod logout
```
## Keep sites you already deployed [#keep-sites-you-already-deployed]
Login automatically attaches anonymous sites this CLI remembers on the same
host. Each local folder link already contains the site's `manage_token`, so the
CLI can prove control without asking you to copy a secret:
```text
$ flypod login
Logged in.
Found 3 anonymous sites on this machine.
Attaching to your account…
Attached 3 sites.
```
Deleted, expired, and already-owned sites are skipped. Sites deployed from a
different machine are not in this CLI's registry.
### Claim a site from another machine [#claim-a-site-from-another-machine]
If you saved the original anonymous deploy's `claim_token`, attach it explicitly:
```sh
flypod login
flypod claim
```
## Agents and CI [#agents-and-ci]
Agents running on a connected machine automatically use its saved session. A
stateless job can receive an existing session token through its secret store:
```sh
FLYPOD_TOKEN="$FLYPOD_SESSION" flypod ./dist --json
```
Treat the value as an account credential. The legacy `FLYPOD_API_KEY` variable
is still read for compatibility, but new integrations should use
`FLYPOD_TOKEN`.
## Where local state lives [#where-local-state-lives]
`session.json` and the folder-to-site registry `projects.json` live in the
platform-specific flypod config directory with mode `0600`. Override the
directory with `FLYPOD_CONFIG_DIR`. Neither file belongs in your repo.
# Deploy a site (/docs/guides/deploy-a-site)
Point flypod at a directory of static files:
```sh
npx flypod ./dist
```
flypod zips the folder, POSTs it to the server, and prints a live URL:
```
https://ab12cd34.flypod.dev
```
Open it. The site is live. You can also deploy a **single file**:
```sh
npx flypod ./index.html
```
## Let flypod find the folder [#let-flypod-find-the-folder]
Run with no path and flypod auto-detects a build directory: the first of these
that contains an `index.html`:
```
dist/ build/ out/ .output/public .vercel/output/static public/ _site/
```
```sh
npx flypod
```
Auto-detection only matches a directory that actually contains an `index.html`.
If none do, pass the path explicitly.
## What the output looks like [#what-the-output-looks-like]
In a terminal (TTY), flypod shows a banner, a spinner, and post-deploy hints.
The split never changes:
* The **live URL** goes to **stdout**.
* The **banner, spinner, and hints** go to **stderr**.
So `URL=$(npx flypod ./dist)` captures just the URL. When stdout is not a TTY
(a pipe, a CI job, an agent), flypod auto-detects it and strips the animation.
No flag needed.
## Machine-readable output [#machine-readable-output]
For structured output, use `--json` and parse stdout:
```sh
npx flypod ./dist --json
```
```json
{
"ok": true,
"url": "https://ab12cd34.flypod.dev",
"site_id": "ab12cd34",
"version_id": "v_...",
"expires_at": "2026-07-02T00:00:00.000Z",
"manage_token": "...",
"claim_token": "...",
"owner_account_id": null,
"source": "./dist",
"files": 12,
"bytes": 48211,
"elapsed_ms": 734
}
```
For the bare minimum, use `--quiet`. The URL is the only thing on stdout, and
the `manage_token` is printed to stderr:
```sh
URL=$(npx flypod ./dist --quiet)
```
## Owned vs anonymous [#owned-vs-anonymous]
If you are logged in, the deploy is **owned and permanent**. If not, it is
**anonymous** and expires after **14 days**. Running `flypod login` later
automatically claims every anonymous site this CLI remembers — no tokens to
copy. The JSON output also includes a `claim_token` for the rare case where
you need to claim a site from a different machine. See
[Accounts & claiming](/docs/guides/accounts-and-claiming).
## The folder→site link [#the-foldersite-link]
After a successful deploy, flypod records a link between the current folder and
the site it deployed to. This lets [`flypod update`](/docs/cli/update),
[`flypod versions`](/docs/cli/versions), and
[`flypod rollback`](/docs/cli/rollback) work without an ID. The link lives in a
per-user config directory (**not in your repo**), and recording it is
best-effort: if it fails, the deploy still succeeds.
## Flags [#flags]
| Flag | Effect |
| ----------------- | ---------------------------------------- |
| `--json` | Emit a JSON object on stdout. |
| `-q`, `--quiet` | URL on stdout, `manage_token` on stderr. |
| `-h`, `--help` | Print usage. |
| `-v`, `--version` | Print the flypod version. |
## Next steps [#next-steps]
# Enable comments on a site (/docs/guides/enable-comments)
Comments run on any site you own. Enable them and every page gets a floating panel where visitors can highlight any passage and leave a note anchored to that text.
The full model is in [Concepts: Comments](/docs/concepts/comments).
## Turn it on [#turn-it-on]
### At deploy time [#at-deploy-time]
Pass `--comments` to deploy and enable in one step:
```sh
flypod ./dist --comments
```
Add `--mode` to choose a mode other than the default `anonymous`:
```sh
flypod ./dist --comments --mode approved
```
### On an existing site [#on-an-existing-site]
```sh
flypod comments enable --site
flypod comments enable --site --mode passcode --passcode mysecret
```
### From the dashboard [#from-the-dashboard]
1. Open [https://flypod.dev/account](https://flypod.dev/account).
2. Find the site card and click **Enable** in the Comments row.
3. The mode flips to **Anonymous** and the widget is live on the next page load.
The widget is injected into every HTML response from the site. Static assets, JSON endpoints, and JS bundles are untouched.
The widget loads from the same site host (`https://.flypod.dev/_flypod/widget.js`), so there is no third-party origin to add to your CSP.
## What visitors see [#what-visitors-see]
A **Comments** button in the bottom-right corner of every page. Clicking opens the panel.
Visitors can also highlight any passage to get a **Comment on this** pill. Clicking it opens a composer next to the selection with the quoted text at the top. The thread is anchored to that exact passage.
The **In-page** toggle (shown once at least one thread is anchored) floats a pin in the page margin next to every anchored passage. Click a pin to read and reply inline.
## Picking a mode [#picking-a-mode]
The mode controls who can read and post, and is enforced on the server. Change
it from the dashboard, the CLI, or at deploy time.
| Mode | Who can read / post | Right for |
| ------------- | --------------------------------------------------------- | ------------------------ |
| **Anonymous** | anyone, with a pseudonym | public drafts |
| **Passcode** | anyone holding the shared passcode (reads + writes gated) | semi-private review |
| **Approved** | anyone may post, but posts stay hidden until you approve | moderated / high-traffic |
```sh
flypod comments enable --site --mode approved
flypod comments mode passcode --site --passcode mysecret
flypod comments off --site # disables the widget (data is preserved)
```
The retired v0.1 mode names still work as input: `open` maps to **anonymous**,
and `review`/`members` map to **approved**.
## Identity [#identity]
Each visitor gets a pseudonym scoped to that site, like `calm-otter-1234`. The same person visiting a different flypod site gets a separate identity there — cookies are scoped per subdomain.
Visitors can rename themselves at any time. The new name applies to all their past comments immediately.
## Read the inbox [#read-the-inbox]
### From the dashboard [#from-the-dashboard-1]
The Comments row on the site card shows the current mode and a quick inbox view.
### As JSON-LD [#as-json-ld]
```sh
curl -s https://.flypod.dev/comments.json | jq
```
Returns a W3C `OrderedCollection` of annotations. Per page:
```sh
curl -s https://.flypod.dev/about.comments.json
```
In **Anonymous** and **Approved** modes the feed is public, with pending items
still hidden from third parties. **Passcode** mode requires a valid grant or
owner identity.
The package contains MCP and webhook primitives, but hosted flypod.dev does not
yet issue usable agent tokens or dispatch outbound webhooks. See the
[comments hosted status](/docs/concepts/comments#hosted-status).
## What does not work yet [#what-does-not-work-yet]
* **Editing posted comments.** A posted comment is immutable. Visitors can rename themselves; owners can moderate threads in **Approved** mode.
* **Hosted MCP and webhooks.** The underlying primitives exist, but their hosted authentication and delivery integration is unfinished.
* **Realtime push.** Clients poll today.
* **Embedding on non-flypod sites.** The widget assumes same-origin endpoints under `/_flypod/`. Embedding lands in v0.2.
# Render Markdown to a docs site (/docs/guides/render-markdown)
Deploy a folder of Markdown and flypod turns it into a styled HTML docs site:
```sh
npx flypod ./docs
```
## The rule [#the-rule]
flypod renders Markdown **only when the deployed folder has no root
`index.html`**. It picks up `.md`, `.markdown`, and `.mdx` files and converts
them to HTML using GFM (GitHub Flavored Markdown, via `marked`).
If the folder already has a root `index.html`, flypod serves it **as-is,
untouched**. No rendering happens. The Markdown-to-docs behavior is a fallback
for folders that have no HTML entry point.
## What you get [#what-you-get]
* **Multiple docs** → a navigation **sidebar** linking every page.
* **A `README` or `index` file** becomes the **home** page.
* **Each page's title** is its first `# H1`; if there is none, flypod prettifies
the filename.
* The **raw Markdown** is still served at its own path as `text/markdown`, so
the source stays fetchable.
## Example [#example]
A folder like this:
```
docs/
README.md # → home page
install.md # → /install
api.md # → /api
```
deploys to a docs site whose home is `README.md`, with `install` and `api` in
the sidebar. A page that starts with `# Installing flypod` gets the title
"Installing flypod"; a file named `getting-started.md` with no H1 gets
"Getting Started".
## Agents get Markdown, browsers get HTML [#agents-get-markdown-browsers-get-html]
Every rendered page is available in **two representations at the same URL**: the
styled HTML a browser sees, and the raw Markdown source an AI agent would rather
read. flypod picks between them per request using standard HTTP **content
negotiation** — no separate URL, no config.
An agent that sends `Accept: text/markdown` gets the Markdown back:
```sh
curl -H "Accept: text/markdown" https://.flypod.dev/install
```
```http
HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8
Vary: Accept
X-Markdown-Tokens: 214
```
* **The `Accept` header decides.** `Accept: text/markdown` (ranked at or above
`text/html`) returns Markdown; a browser's HTML-preferring `Accept` always
returns HTML. This is the same 27-year-old mechanism every HTTP client already
speaks, and the convention coding agents like Claude Code use.
* **Known AI crawlers** (GPTBot, ClaudeBot, PerplexityBot, and friends) get
Markdown even without an `Accept` header — cleaner input for them, unchanged
pages for everyone else.
* **`X-Markdown-Tokens`** gives an agent a rough token estimate up front so it
can budget its context window before reading the body.
* **`Vary: Accept`** is set on every response, so caches never hand a browser
the Markdown or an agent the HTML.
The home page (`/`) negotiates too, returning the source of your `README`/
`index` doc. This only applies to folders flypod rendered from Markdown — if you
ship your own `index.html`, your HTML is always served as-is, never replaced.
You can still fetch any source file directly at its `.md` path at any time.
## When to add an index.html instead [#when-to-add-an-indexhtml-instead]
If you want full control over layout (your own HTML, CSS, and routing), ship an
`index.html` and flypod serves your folder verbatim. See
[Deploy a site](/docs/guides/deploy-a-site).
## Next steps [#next-steps]
# Update & rollback (/docs/guides/update-and-rollback)
After the first deploy, you rarely type a site ID again. flypod links the
current folder to its site, so the loop is: edit → update → check → roll back.
## Ship a change [#ship-a-change]
Edit your files, then push a new version to the **same site**:
```sh
flypod update
```
The new version is built, uploaded, and goes **live** immediately. Each version
is immutable.
If the content is byte-for-byte identical to what's already live, `flypod update`
is a no-op and prints "No changes".
## List history [#list-history]
```sh
flypod versions
```
Output is newest-first, with the live version marked:
```
v_3f9a1c 2026-06-18T14:02:00Z [live]
v_2b7e44 2026-06-18T11:40:00Z
v_19cd02 2026-06-17T09:15:00Z
```
## Roll back [#roll-back]
With no argument, flypod rolls back to the **previous** version:
```sh
flypod rollback
```
To target an exact version, pass its id or an **unambiguous prefix**:
```sh
flypod rollback v_2b7e44
flypod rollback 2b7e
```
flypod refuses to roll back if the site has only one version, or if the live
version is already the oldest.
## Multiple projects [#multiple-projects]
If a folder isn't linked, or you're targeting a different site, pass `--site`:
```sh
flypod update --site ab12cd34
flypod versions --site ab12cd34
flypod rollback --site ab12cd34
```
For anonymous sites you don't own, authorize the action with the deploy's
`manage_token`:
```sh
flypod update --token
flypod rollback --token
```
## Self-heal on a stale link [#self-heal-on-a-stale-link]
If `flypod update` gets a `404` (the linked site no longer exists), flypod drops
the stale folder link and tells you to run `flypod` to deploy fresh. A `401` or
`403` means the action is unauthorized; pass `--token` or log in.
## JSON & quiet [#json--quiet]
`update`, `versions`, and `rollback` all accept `--json`. `update` and
`rollback` also accept `-q`/`--quiet`.
```sh
flypod update --json
flypod versions --json
```
## Flags [#flags]
| Command | Flags |
| --------------------------- | ------------------------------------------------------- |
| `flypod update [path]` | `--site `, `--token `, `--json`, `-q` |
| `flypod versions` | `--site `, `--json` |
| `flypod rollback [version]` | `--site `, `--token `, `--json`, `-q` |
## Next steps [#next-steps]
# Use with agents & CI (/docs/guides/use-with-agents-and-ci)
flypod is built to be driven by machines. Deploys are **non-interactive by
default** whenever stdout isn't a TTY: no prompts, no animation. This page is
the reference for running it from an agent or a CI job.
## Capture the URL [#capture-the-url]
The live URL is always on **stdout**; the banner, spinner, and hints go to
**stderr**. So you can capture the URL directly:
```sh
URL=$(npx flypod ./dist --quiet)
```
Or parse structured output:
```sh
npx flypod ./dist --json | jq -r .url
```
flypod auto-detects a non-TTY stdout and strips the animation. You don't need a
flag for that, but `--quiet` (URL only) and `--json` (structured) make the
contract explicit and are recommended in scripts.
## The two output modes [#the-two-output-modes]
| Mode | stdout | stderr |
| --------- | ------------------------- | -------------- |
| `--quiet` | the live URL | `manage_token` |
| `--json` | a JSON object (see below) | nothing |
The `--json` object:
```json
{
"ok": true,
"url": "https://ab12cd34.flypod.dev",
"site_id": "ab12cd34",
"version_id": "v_...",
"expires_at": "2026-07-02T00:00:00.000Z",
"manage_token": "...",
"claim_token": "...",
"owner_account_id": null,
"source": "./dist",
"files": 12,
"bytes": 48211,
"elapsed_ms": 734
}
```
## Exit codes [#exit-codes]
| Code | Meaning |
| -------- | -------------------------------------- |
| `0` | Success. |
| non-zero | Failure (deploy error, network, auth). |
| `2` | Unknown flag. |
Check the exit code, not the text:
```sh
if URL=$(npx flypod ./dist --quiet); then
echo "deployed: $URL"
else
echo "deploy failed" >&2
exit 1
fi
```
## Authenticate in a job [#authenticate-in-a-job]
Agents running on a developer machine use the session saved by the browser
device flow:
```sh
flypod login
npx flypod ./dist --quiet
```
For a stateless job, pass an existing account session token through the secret
store. No login file is written:
```sh
export FLYPOD_TOKEN="$FLYPOD_SESSION"
npx flypod ./dist --quiet
```
`FLYPOD_TOKEN` takes precedence over a saved login. The legacy
`FLYPOD_API_KEY` name is still accepted as a fallback for older integrations.
With no credential, the deploy is anonymous.
Authenticated deploys are **owned and permanent**. No 14-day TTL.
Anonymous deploys (no login) expire after 14 days. See
[Accounts & claiming](/docs/guides/accounts-and-claiming).
## Idempotent re-deploys [#idempotent-re-deploys]
To ship a new version to the **same site** instead of creating a new one, use
[`flypod update`](/docs/cli/update). flypod links the working directory to its
site, so no ID is needed:
```sh
npx flypod update --quiet
```
If the content is unchanged, `update` is a **no-op** ("No changes" message),
safe to run on every push. If the linked site is gone (`404`), flypod
drops the stale link and tells you to run `flypod` to deploy fresh. In an
ephemeral CI checkout where no folder link exists, pass `--site ` (and
`--token ` for anonymous sites).
## Server override [#server-override]
Point the CLI at a different backend with `DEPLOY_URL`:
```sh
DEPLOY_URL=https://example.internal npx flypod ./dist --json
```
Other recognized env vars: `FLYPOD_CONFIG_DIR` (credential/link location),
`NO_COLOR`, `FORCE_COLOR`.
## Minimal agent recipe [#minimal-agent-recipe]
```sh
# authenticate (session from your secret store)
export FLYPOD_TOKEN="$FLYPOD_SESSION"
# first deploy → capture site_id for later
SITE=$(npx flypod ./dist --json | jq -r .site_id)
# subsequent runs → idempotent update to the same site
npx flypod update --site "$SITE" --json | jq -r .url
```
## Next steps [#next-steps]