Use with agents & CI
The canonical playbook for driving flypod from an agent or CI job. Covers non-interactive output, capturing the URL, exit codes, authentication, and idempotent re-deploys.
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
The live URL is always on stdout when it is piped; the receipt and spinner go to stderr. So you can capture the URL directly:
URL=$(npx flypod ./dist --quiet)Or parse structured output:
npx flypod ./dist --json | jq -r .urlflypod 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
| Mode | stdout | stderr |
|---|---|---|
--quiet | the live URL | nothing (see Ephemeral config dirs for the one exception) |
--json | one line of JSON, on success and on failure | nothing |
--quiet prints no token. It used to emit the manage_token on stderr, and
-q is exactly the mode whose stderr ends up in a build log that outlives the
site — and the token can't be rotated, only outlived. --json does not print
it either, by default: a receipt is what gets pasted into a chat or a PR. If a
job needs the token, use --json --verbose, which is explicit and structured.
The --json object, for an anonymous deploy:
{
"ok": true,
"action": "created",
"url": "https://a3f1c2d4e5b60789.flypod.page",
"site_id": "a3f1c2d4e5b60789",
"version_id": "8f1a0c2e44b7d913",
"expires_at": 1782929906088,
"healed": ["spa-fallback"],
"warnings": [],
"link_saved": true,
"next": {
"update": "flypod update",
"versions": "flypod versions",
"rollback": "flypod rollback",
"keep": "flypod login --json"
}
}Things to parse carefully:
expires_atis epoch milliseconds, ornullwhen the site is owned. It is not an ISO string.warningslists problems that survived the deploy; check it before treating the URL as good.actionis"created"on a folder's first deploy. Every laterflypodin the same folder updates that site instead (same URL, new version) and answers with theflypod updatereceipt, whoseactionis"updated".flypod --newmakes a separate site.next.updateis the command that ships a change to this same URL; in a linked folder plainflypoddoes the same.next.keep, while the site expires, isflypod login --json(see Signing in from an agent).link_savedisfalsewhen the CLI could not write its config directory, which is common in CI containers. Thenextcommands then carry--site <id>, and (for an anonymous deploy) the receipt includesmanage_tokenso the job can pass it as--tokenon later commands.- Some fields appear only when they apply:
skipped(files left out of the upload),replaced_link(--newran in a folder that pointed at another site),comments_enabled/comments_mode/comments_skipped(with--comments), andtimeline/history_url(with--timeline).
A failure under --json is also one line on stdout:
{"ok":false,"code":"token_rejected","error":"The credential you sent was not accepted, so nothing was deployed.","fix":"Run `flypod login` again, or set a valid FLYPOD_TOKEN (unset it to deploy anonymously).","status":401}Branch on code; error is prose and may change. fix is the literal next
step when there is one, and a rate_limited failure may carry
retry_after_s.
Don't stash a token in job state or a transcript. To make deploys
permanent, authenticate the job (FLYPOD_TOKEN) or run
flypod login on the machine — login claims every
anonymous site that machine remembers, in one step, with no token handling.
Exit codes
| Code | Meaning |
|---|---|
0 | Success. |
1 | A runtime error to act on: no build directory, site gone, upload refused. |
2 | Usage error: an unknown flag on any command, an unknown subcommand, a missing argument, or a login that needs a browser under --json. |
3 | Auth: not signed in, or the credential was refused. |
4 | Retryable: rate limit, server error, network, or a login still pending. |
With --json, a failure also names a stable code to branch on. The full list is in Errors and exit codes.
Check the exit code, not the text:
if URL=$(npx flypod ./dist --quiet); then
echo "deployed: $URL"
else
echo "deploy failed" >&2
exit 1
fiAuthenticate in a job
Agents running on a developer machine use the session saved by the browser device flow:
flypod login
npx flypod ./dist --quietFor a stateless job, pass an existing account session token through the secret store. No login file is written:
# FLYPOD_TOKEN is read straight from the environment. Set it from the job's
# secret store; no login file is written.
npx flypod ./dist --quietFLYPOD_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.
Where the token comes from
flypod has no separate API keys and no dashboard button that mints a CI token.
FLYPOD_TOKEN is a login session: run flypod login on a machine you trust,
and copy the session_token value from the session.json it writes (see
Where local state lives)
into the job's secret store. It acts as your whole account, not one site.
It is still a session, so it can stop being accepted. When that happens the job
fails with token_rejected (exit code 3) rather than quietly deploying an
anonymous site; sign in again and replace the secret.
Signing in from an agent
An agent can't complete a browser sign-in on its own, so flypod login --json
doesn't wait for one. It exits 2 with code: "interactive_login_required" and
hands back what a person needs:
{"ok":false,"code":"interactive_login_required","error":"Sign-in needs a browser. Ask the user to open the URL and confirm the code.","fix":"Ask the user to open https://flypod.dev/device?user_code=ABCD-EFGH and confirm code ABCD-EFGH. Once they have, your next flypod command (a deploy, `flypod list`, anything) finishes signing in by itself; to finish it now, run `flypod login --device-code <device_code> --json`, which waits up to 25s for the confirmation.","verification_uri":"https://flypod.dev/device","verification_uri_complete":"https://flypod.dev/device?user_code=ABCD-EFGH","user_code":"ABCD-EFGH","device_code":"…","expires_in":600,"interval":5}Show the user verification_uri_complete and user_code. That is the whole
job: the CLI remembers the pending sign-in, and once the user has approved it,
the agent's next flypod command of any kind (a deploy, flypod list, …)
finishes signing in, keeps the anonymous sites this machine remembers, and
runs signed in. No polling loop is needed.
To finish at once instead, run flypod login --device-code <device_code> --json.
It waits up to 25 seconds for the approval, then saves the session and claims
the sites; still unconfirmed, it fails with authorization_pending (exit 4).
A plain flypod login run by a coding agent with no terminal hands back the
same thing, in prose, rather than waiting for a browser.
Authenticated deploys are owned and permanent. No 14-day TTL. Anonymous deploys (no login) expire after 14 days. See Accounts & claiming.
Idempotent re-deploys
To ship a new version to the same site instead of creating a new one, run
flypod again in the same folder, or flypod update, its
explicit spelling. flypod links the working directory to its site, so no ID is
needed:
npx flypod update --quietupdate is safe to run on every push: versions are content-addressed, so a run with
unchanged files resolves to the existing version id instead of growing the history.
The upload still happens, so it is not free — just not accumulating. If the linked site is gone
(404), update drops the stale link and tells you to run flypod to deploy
fresh; plain flypod drops it and makes the new site in the same run. In an ephemeral CI checkout where no folder link exists, pass
--site <id> — plus --token <manage_token> if the site is anonymous, which is
the case an authenticated job avoids entirely. Without either, update fails
with no_site (exit 1).
Ephemeral config dirs
A CI container usually has no persistent FLYPOD_CONFIG_DIR, so there is no
folder link to carry between runs. Two consequences worth designing around:
- Authenticate the job with
FLYPOD_TOKEN. Deploys are then owned and permanent immediately, there is no 14-day clock, and--site <id>alone is enough to update later — no per-site token to move around. - If the config dir is read-only or ephemeral and the deploy is anonymous,
flypod can't remember the site for you. It warns and prints the
manage_token(on stderr, even under-q), because at that point the job is the only holder of the site's only credential.
Server override
Point the CLI at a different backend with DEPLOY_URL:
DEPLOY_URL=https://example.internal npx flypod ./dist --jsonOther recognized env vars: FLYPOD_CONFIG_DIR (credential/link location),
FLYPOD_SHOW_TOKEN=1 (print the manage token in the terminal receipt),
NO_COLOR, FORCE_COLOR.
Minimal agent recipe
# FLYPOD_TOKEN comes from the job's secret store and is read from the
# environment. Nothing to set up here.
# 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 .urlIn a CI system the second command runs in a fresh container, so keep the site id somewhere the job can read it, such as a repository variable.
GitHub Actions
Store the session token as a repository secret named FLYPOD_TOKEN and the
site id from the first deploy as a repository variable named FLYPOD_SITE:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci && npm run build
- name: Ship a new version
id: flypod
env:
FLYPOD_TOKEN: ${{ secrets.FLYPOD_TOKEN }}
run: |
npx --yes flypod update ./dist --site "${{ vars.FLYPOD_SITE }}" --json > flypod.json
echo "url=$(jq -r .url flypod.json)" >> "$GITHUB_OUTPUT"
- run: echo "Live at ${{ steps.flypod.outputs.url }}"The run step fails when flypod exits non-zero, and flypod.json then holds
the failure with its code. For the very first deploy, run
npx flypod ./dist --json once with the same FLYPOD_TOKEN and save its
site_id as FLYPOD_SITE. Because the job is authenticated, the site is owned
from the start: no expiry, and no manage_token to carry between runs.