CLI

Scripting

Use the CLI in scripts and CI: JSON output, exit codes, error handling, and unattended authentication.

Updated Aug 10, 2026

Every command that returns data has a machine-readable mode (--json) and a conventional exit code. (login and logout have no --json — they print a fixed status line.) What it does not have is a machine-readable error format, retries, or automatic paging — so a script has to check the exit status itself, walk pages itself, and pace its own calls. This page covers what a non-interactive caller can rely on.

Five commands write: posts create, posts delete, slots create, slots delete and media upload. Read Before you automate a write before putting any of them in a loop.

Authenticate unattended

Set ZILFU_TOKEN in the environment and drop the --token flag entirely:

export ZILFU_TOKEN="$(cat /run/secrets/zilfu_token)"

zilfu spaces list --json

--token <token> works too, but it lands in shell history and is visible in ps output to anyone on the machine. Use the environment variable, or zilfu login --token <token> once on a persistent machine to write the stored config — see Get started for the precedence rules and where credentials are stored.

An empty ZILFU_TOKEN is used as-is rather than falling back to the stored config, because only null/undefined values fall through. ZILFU_TOKEN="" in a CI environment produces No API token found. even when ~/.config/zilfu/config.json holds a perfectly good token. Unset the variable instead of blanking it.

zilfu health is the only API call that needs no token (zilfu logout needs none either — it just deletes the local file).

A CLI token is full-access: there is no read-only token, so a script that only lists things still holds one that could publish. Give automation its own token from Additional tokens rather than your API key — both to scope the blast radius of a leak and because the 120/min API budget is per token.

JSON output

--json writes pretty-printed JSON (two-space indent, one trailing newline) to stdout and nothing else. It never emits NDJSON.

Command --json emits
zilfu whoami --json Object — id, name, email
zilfu health --json Object — {"status": "ok"}
zilfu spaces list --json Bare array of space objects
zilfu accounts list --space <id> --json Bare array of account objects
zilfu posts list --space <id> --json The whole paginated envelope — data, links, meta
zilfu posts create … --json Bare array of the posts created, one per --account
zilfu posts delete … --json {"deleted": true, "id": <post id>}
zilfu media upload --file <path> --json Object — url, type, mime
zilfu slots list --space <id> --json Bare array of slot objects (id, day_of_week, time)
zilfu slots create … --json Bare array of slot objects, one per requested day
zilfu slots delete … --json {"deleted": true, "id": <slot id>}
zilfu analytics overview --space <id> --json Bare array of per-account summary rows
zilfu analytics account … --json Object — the whole report
zilfu analytics posts … --json Object — contract, account, range, collection, sort, columns, data, meta

Three of those shapes are worth reading twice:

  • posts list keeps its envelope. The API's response wrapper is unwrapped everywhere else, but not here: dropping meta would leave a caller unable to tell a full page from the last one. The rows are at .data, not at the root, so a jq '.[]' written against an older CLI now fails.
  • analytics posts is an object, not a list. The rows are at .data too; .columns, .sort and .meta sit beside them, and .sort.options is the only place the account's valid sort keys are published.
  • The two delete commands print an acknowledgement the CLI invented. The API answers 204 No Content, so there is no server payload; {"deleted": true} asserts nothing beyond "the request did not fail".

--json has to come after the leaf subcommand (zilfu posts list --json, never zilfu --json posts list). See Commands.

--json and the table are different shapes for most commands, not just narrower ones. On posts list and posts create the table prints a status label while --json carries the numeric code the API stores (scheduled is "status": 0 — the mapping is in Commands). On slots the table prints day as MonSun while --json keeps day_of_week as 17. On the analytics commands the table is several tables plus prose, and --json is one document; a null metric renders as - in the table and stays null in JSON, which is the distinction that matters — it is not a zero.

Exit codes

Code Meaning
0 The command succeeded, including when it returned no results
1 Every failure — API error, bad argument, missing token, unreachable host, refused write
130 Ctrl-C at the interactive token prompt of zilfu login

There is no exit-code granularity. 401, 403, 404, 422, 429, 5xx, an invalid --space, a post that was too long for X, a missing token and a host that never answered all exit 1. To tell them apart, parse the Error <status>: prefix on stderr. A network-level failure — DNS, connection refused, TLS — reports status 0, so Error 0: means no HTTP response happened at all, and an error with no prefix at all was raised locally before any request was made.

Error output

Errors always go to stderr as plain text, with a status prefix:

Error 401: Unauthenticated.

A 422 adds one indented line per field message:

Error 422: The per_page parameter must be between 10 and 100.
  - per_page: The per_page parameter must be between 10 and 100.

The write commands are the ones that can realistically produce one — a 422 from posts create is the API rejecting content the CLI's own checks did not cover. Most of the obvious cases never get that far: a bad --status, an out-of-range --per-page, a --time that isn't HH:MM, copy that is too long for one of the platforms in the call, an account id that isn't in the space, and a --at in the past are all refused locally, and those messages carry no status prefix:

Content for account 13 (x) is 402 characters; x allows 280.
Use --account-text 13=<text> to tailor the copy for this account.

Note that local errors can be multi-line — No API token found. is three lines — so a script parsing stderr should not assume one line per failure.

Not everything on stderr is a failure. posts create writes advisory notes there — a disconnected account, a comment that will be dropped for some platforms — and then completes successfully with exit 0. Decide on the exit code, never on stderr being non-empty.

--json never applies to errors. There is no JSON error envelope, no error code field, and stdout stays empty on failure.

Usage text is printed to stdout, not stderr, when an argument is missing or wrong. zilfu posts list --json > out.json with --space omitted writes a usage block into out.json and exits 1 (ANSI-coloured on a normal shell; plain when CI, NO_COLOR, or TERM=dumb is set). Always check the exit code before parsing stdout:

if ! zilfu posts list --space 42 --json > posts.json; then
  echo "zilfu failed" >&2
  exit 1
fi

Empty results are not errors

An empty list prints No results. to stdout and exits 0; with --json it prints [] (or, for posts list, an envelope whose data is empty). An empty record prints (empty). None of these are failures — a script that treats "no output" as an error will misfire on a space with nothing in it.

count=$(zilfu posts list --space 42 --json | jq '.meta.total')

Filter and page

posts list and analytics posts take the API's filter and paging parameters directly, so jq is for reshaping what came back, not for making up for what the CLI could not ask:

# Everything awaiting approval for two accounts, 100 to a page
zilfu posts list --space 42 --status pending_approval \
  --account 12 --account 13 --per-page 100 --page 1 --json

# Last month's posts for one account, best first
zilfu analytics posts --space 42 --account 12 \
  --from 2026-07-01 --to 2026-07-31 --sort interactions --dir desc --json

Paging is manual. Nothing follows links.next for you, so walk it yourself and stop on meta.last_page:

page=1
while :; do
  body=$(zilfu posts list --space 42 --per-page 100 --page "$page" --json) || exit 1
  echo "$body" | jq -c '.data[]'
  [ "$page" -ge "$(echo "$body" | jq '.meta.last_page')" ] && break
  page=$((page + 1))
done

analytics posts pages the same way, with its own meta.current_page / meta.last_page and a page size between 10 and 100.

Some shaping recipes:

# Scheduled posts only, as id + scheduled time
zilfu posts list --space 42 --status scheduled --json \
  | jq -r '.data[] | "\(.id)\t\(.scheduled_at)"'

# Accounts that need reconnecting
zilfu accounts list --space 42 --json \
  | jq -r '.[] | select(.disconnected_at != null) | .handler'

# Accounts parked because the plan lapsed (reconnecting will not fix these —
# upgrade, or switch another account off to make room)
zilfu accounts list --space 42 --json \
  | jq -r '.[] | select(.quota_parked_at != null) | .handler'

# The space id for a space you know by name
zilfu spaces list --json | jq -r '.[] | select(.name == "Acme") | .id'

# Which metrics this account can actually be sorted by
zilfu analytics posts --space 42 --account 12 --json \
  | jq -r '.sort.options[] | select(.sortable) | .key'

Do not filter on a metric value you read out of analytics posts without checking its status. metrics.<key>.value and interactions.total are null whenever the figure behind them cannot be trusted, and jq will happily compare null to a number. Filter on .status == "ok" first.

Before you automate a write

The write commands are ordinary HTTP calls with no dry-run flag, no confirmation prompt and no undo. What that means in practice:

  • posts create is not idempotent. Re-running it after a timeout creates a second set of posts. There is no request key to deduplicate on. If a run's outcome is uncertain, list the space and check before retrying.
  • One invocation is all-or-nothing. The posts for every --account are created in a single transaction, and for anything that is not a --draft the plan's post allowance is checked for the whole batch up front — so a call that would cross the limit creates nothing and answers 422. Read the returned ids out of --json and record them; there is no other handle on what a run produced.
  • slots create is idempotent. The endpoint upserts by day and time, so re-running the same command changes nothing and returns the same rows. It is safe in a provisioning script that runs on every deploy.
  • The two deletes are immediate and unrecoverable. No prompt, no undo, no soft-delete you can restore from the CLI.
  • media upload is two calls plus an upload. The presigned URL expires 15 minutes after it is issued, and the PUT goes straight to storage without your token. A failure there prints Upload failed: <status> <text> and exits 1 — the media is simply not there, and nothing was created in Zilfu.
  • Slot writes are owner-only. They are authorised against space ownership rather than permissions, so a token belonging to a member is refused with 403 however much that member can otherwise do.
  • Publishing without permission does not publish. If the token's user can create but not publish, posts create returns rows with status pending_approval and a null scheduled_at. Exit code 0; nothing goes out. A script that treats a successful create as "published" will be wrong for those users — check the status in the response.

Timeouts, retries, and rate limits

The CLI sets no request timeout and never retries. A hung server ties up the command until Node's own fetch timeout fires (about five minutes), then fails with Error 0: fetch failed — wrap invocations in your own timeout if a stuck job would block a pipeline:

timeout 30 zilfu spaces list --json

A 429 surfaces as Error 429: Too Many Attempts. and nothing more. The underlying error object carries no response headers, so Retry-After, X-RateLimit-Remaining and X-RateLimit-Reset are unreachable from the CLI. Pace your own calls, and note that two ceilings apply:

Limit Applies to Cap
api Every command that calls the API 120/min, per token
publish posts create 30/min, per user

The publish ceiling is keyed by user, not by token, so splitting a backfill across several tokens does not raise it. Both are detailed in Rate limits. A retry loop around posts create is especially dangerous here: it is neither idempotent nor cheap against that bucket.

Note also that every posts create makes two API calls, not one — it lists the space's accounts first, to resolve each account's platform — so a loop of n invocations spends 2n against the 120/min budget.

Do not parse the table

Table rows have trailing whitespace stripped per line, so a row whose last cells are empty simply ends early — the column count varies from row to row:

id  account_id  status     scheduled_at                 published_at
--  ----------  ---------  ---------------------------  ---------------------------
10  12          scheduled  2026-08-01T09:00:00.000000Z
11  12          published                               2026-07-01T09:00:00.000000Z
12  13          draft

Row 10 has four fields, row 11 has four fields carrying different data, and row 12 has three. awk '{print $4}' returns a scheduled time on one row and a published time on the next. Nulls render as empty strings, and the table is a lossy projection anyway.

Three more reasons the non-JSON output is not a data format: posts list prints a Showing 1-15 of 62. footer after the table; the analytics commands print several tables, headings and free prose in one run, with the valid sort keys as a trailing sentence; and numbers are formatted for humans, so 4,201 is thousands-separated and a rate reads 3.4%. Use --json for anything a program reads.

One more pipeline hazard: closing the pipe early can kill the process. zilfu spaces list | head -1 may abort with a Node EPIPE stack trace on stderr and exit 1 even though the command worked. Buffer to a file first, or tolerate the non-zero status.