CLI

Commands

Every Zilfu CLI command, the flags it takes, and what its output does and does not include.

Updated Aug 10, 2026

Sixteen commands. Five of them write through the API — posts create, posts delete, slots create, slots delete, and media upload — and the other nine only read. login and logout write and delete your local credentials file; login calls the API once, to validate the token before storing it. See Get started for both.

There are no positional arguments anywhere in the CLI. Every input is a flag.

Global flags

Flag Effect
--token <token> Use this token for the request, overriding ZILFU_TOKEN and the stored config
--base-url <url> Send the request to this API base URL, overriding ZILFU_BASE_URL and the stored config
--json Print pretty JSON to stdout instead of the human-readable table (or, where the output is a single record, the field list)

All three are accepted by every command that calls the API — that is everything except login and logout.

login defines its own flags (--web, --token, --base-url) and takes no --json. logout takes no flags at all: it deletes the config file and prints one line. Neither command errors on a flag it doesn't know — the parser accepts unknown flags silently — so zilfu logout --json is accepted, logs you out normally, and simply ignores the flag — there is no JSON output and no warning that the flag was meaningless.

Global flags must come after the leaf subcommand. zilfu health --json prints JSON; zilfu --json health silently ignores the flag and prints the table, and zilfu posts --json list --space 1 drops the --json the same way. A flag that takes a value fails louder: the value is read as a command name, so zilfu --token abc whoami and zilfu posts --space 1 list both print the usage block, Unknown command abc / Unknown command 1, and exit 1.

A group name on its own is not a command. zilfu spaces prints the usage block, then No command specified. on stderr, and exits 1 — it does not fall back to list. The same is true of accounts, posts, media, slots and analytics. Write the leaf out every time.

Every failure exits 1, whatever caused it. See Scripting.

zilfu whoami

The user the token belongs to.

zilfu whoami
id     7
name   Ada Lovelace
email  ada@example.com

Three fields, and that is the whole record. Use it to confirm which account a token belongs to before you trust a script with it.

zilfu health

The only command that does not require credentials — it runs fine with no token at all. It still resolves one if you have it, and sends it: a stored token, ZILFU_TOKEN, or --token is attached as a Bearer header on the health request like on any other command.

zilfu health
status  ok

That is the entire healthy response. There is no "degraded" status line: when the API is unhealthy it answers 503, and the CLI turns any non-2xx into an error. So an unhealthy API prints nothing at all on stdout, even with --json, and instead writes to stderr and exits 1:

Error 503: Zilfu API 503 Service Unavailable

That is how you tell unhealthy from unreachable: a bad DNS name, a refused connection, or a malformed --base-url all report status 0, because no HTTP response came back at all. A bad DNS name or a refused connection prints Error 0: fetch failed; a --base-url that isn't a URL prints Error 0: Failed to parse URL from <value>/health.

zilfu spaces list

The command that gives you the space id everything else needs.

zilfu spaces list
id  name            timezone
--  --------------  -----------
1   Acme Marketing  Europe/Rome
2   Nova Labs       UTC

The timezone column is the space's own timezone. It is what slots create --time is interpreted in, and the timezone the analytics commands resolve their window — and their "today" — in. It does not reformat post timestamps: posts list prints the UTC instants the API sent, unchanged. Not paginated: every space you can access is listed.

Passing --space

--space is required on every command that touches a space: accounts list, posts list, posts create, posts delete, slots list, slots create, slots delete, analytics overview, analytics account, and analytics posts. It takes a positive integer space id from zilfu spaces list. On media upload it is optional, and means something narrower — see that command.

There is no default space, no ZILFU_SPACE environment variable, and nothing stored in the config file. You pass it on every invocation.

A value that isn't a positive integer fails before any request is made:

zilfu posts list --space abc
Invalid space id: abc

0, negative numbers, and decimals are rejected the same way. --account, --post and --slot are coerced identically, and report the same message with their own label. Omitting a required flag fails at the parser instead, with the usage block and Missing required argument: --space.

One trap worth knowing: the parser takes the next token as the flag's value, whatever it is. zilfu posts list --space --token abc fails with Invalid space id: --token.

zilfu accounts list

The social accounts connected to a space.

zilfu accounts list --space 1
id  social     display_name  handler   is_active
--  ---------  ------------  --------  ---------
12  instagram  Acme          acme      true
13  linkedin   Acme Inc.     acme-inc  true
14  threads    Acme          acme      false

social is one of threads, x, instagram, linkedin, tiktok, facebook, pinterest, youtube. is_active prints as true or false; false means the account is parked — either your plan lapsed and it was taken back to fit the cap, or you switched it off yourself. Parked accounts keep their history and reconnect for free, but they do not publish. A dead connection is a different thing and shows as disconnected_at. Not paginated. YouTube is in limited release, so a youtube row only appears for a workspace that already has a channel connected.

This is the id source for --account on posts create and on the analytics commands.

zilfu posts list

Top-level posts in a space, newest scheduled time first.

zilfu posts list --space 1
id   account_id  status            scheduled_at                 published_at
---  ----------  ----------------  ---------------------------  ---------------------------
478  12          draft
481  13          scheduled         2026-08-01T09:00:00.000000Z
477  12          pending_approval  2026-07-30T08:00:00.000000Z
479  12          published         2026-07-26T17:30:00.000000Z  2026-07-26T17:30:04.000000Z

Timestamps are UTC, not the space's timezone. Empty cells are nulls — a draft has neither a scheduled nor a published time, and rows with no scheduled time sort to the top. Only root posts are listed: thread replies are children of the row above them and never appear as rows of their own.

Flag Effect
--status <status> Filter by status, as a name or a number. Repeat to match any of several
--account <id> Filter by account id. Repeat to match any of several
--from <date> Only posts scheduled or published on/after this date
--to <date> Only posts scheduled or published on/before this date
--per-page <n> Results per page, 1-200 (default 15)
--page <n> Page of results to fetch (default 1)

--status takes the label or the code — --status scheduled and --status 0 are the same filter, and pending-approval is accepted for pending_approval. A name the CLI doesn't know fails locally and lists the valid ones rather than spending a round trip on a 422.

--from and --to are only checked to be dates. The API matches a post whose scheduled or published time falls in the window, so a post that was scheduled inside the range and published outside it still matches.

--per-page above 200 is rejected locally. The API would silently clamp it, which reads as "the page size I asked for was honoured" when it wasn't.

Below the table, a footer line reports where you are:

Showing 1-15 of 62. Pass --page 2 for the next page.

A repeated --status or --account is not the same request as a single one. One value sends the API's status/account_id parameter; two or more send statuses[]/account_ids[]. The CLI picks the right form for you — but do not mix them by hand against the REST API, because the two are separate WHERE clauses and sending both ANDs them into an empty result.

Filters do not compose with a stale page number. --page is applied to the filtered set, so adding a filter changes what page 2 contains. Read the footer rather than assuming.

Post statuses

Posts are stored as numeric codes. The table prints a label for each; --json gives you the raw number.

Code Label printed
0 scheduled
1 published
2 error
3 draft
4 processing
5 pending_approval

A code the CLI has no label for prints as the bare number.

zilfu posts create

Creates one post per account in a single call — scheduled, drafted, or published immediately.

zilfu posts create --space 1 --account 12 --account 13 \
  --text "Ship day." --at 2026-08-14T18:00:00Z
id   account_id  status     scheduled_at
---  ----------  ---------  ---------------------------
502  12          scheduled  2026-08-14T18:00:00.000000Z
503  13          scheduled  2026-08-14T18:00:00.000000Z

One row per --account, because one item becomes one post. Repeating the same id is de-duplicated before the request is sent — otherwise the same copy would publish twice to the same account and be charged to your plan twice.

Content and targeting

Flag Effect
--account <id> Account to post to. Required, repeatable
--text <text> The copy, shared by every account
--account-text <id>=<text> Copy for one account, overriding --text for it. Repeatable
--media <url> Media URL to attach. Repeatable
--reply <text> A threaded reply. Repeatable, in order
--reply-media <n>=<url> Media for the n-th --reply, 1-based. Repeat with the same n for several files

--text is optional only when every account named by --account has its own --account-text. Otherwise the CLI names the accounts left without copy and stops.

--media and --reply-media take a URL, not a path — run media upload first, or point them at an already-public file. The CLI decides image-or-video from the extension (jpg, jpeg, png, webp, mp4, mov, ignoring any query string), and refuses a URL it cannot classify.

Timing

Flag Effect
--at <time> When to publish — ISO 8601 or epoch milliseconds. Omit to publish immediately
--draft Save as a draft instead

--draft and --at are mutually exclusive; a draft has no schedule. Two --at mistakes are caught locally, because the API accepts both and neither ever publishes:

zilfu posts create --space 1 --account 12 --text hi --at 1784311200
--at 1784311200 looks like epoch seconds; the API reads epoch milliseconds, so this would schedule for 1970-01-21T15:38:31.200Z.
Multiply by 1000, or pass an ISO 8601 timestamp like 2026-08-14T18:00:00Z.

A time that has already passed is refused for the same reason: the scheduler only picks up posts whose time is still ahead.

First comment

Flag Effect
--comment <text> Comment to post after the post goes live
--comment-delay <minutes> How long to wait, 1-10080 (7 days)

The two are all-or-nothing: neither works without the other. There is one comment for the whole call, not one per account, so its length is capped by the strictest comment-capable platform in the call — Threads 500, LinkedIn 1250, Instagram 2200, Facebook 8000 — falling back to 500 when not one platform in the call declares a limit of its own. Accounts on platforms that cannot post a follow-up comment don't lower the cap; they simply don't get one, and the CLI says so on stderr rather than failing the call.

A comment is only actually delivered on Threads, Instagram and Facebook. LinkedIn's driver can post one but the platform is not on the rollout, so its comment is usually dropped — the CLI warns about that separately, again on stderr.

Pinterest

Flag Effect
--board-id <id> The board the pin is created on. Required for Pinterest
--title <text> Pin title, max 100 characters
--link <url> Destination URL the pin links to
--cover-image-url <url> Cover image for a video pin, when --media has no image

TikTok

Flag Effect
--privacy-level <level> PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR or SELF_ONLY. Required for TikTok
--allow-comments Allow comments (--no-allow-comments to disable)
--allow-duet Allow duets — video only (--no-allow-duet to disable)
--allow-stitch Allow stitches — video only (--no-allow-stitch to disable)
--branded-content Mark as a paid partnership
--brand-organic Mark as promoting your own brand

The five TikTok toggles are tri-state. Absent means "don't send it, let TikTok decide"; --allow-duet sends true; --no-allow-duet sends false. This matters because a flag the CLI sent as false on your behalf would silently turn a toggle off. Pass the flag only when you mean it.

--privacy-level is required by the CLI, not by the API. TikTok's own default is SELF_ONLY, so a post created without one publishes successfully and is visible to nobody. Case and hyphens are normalised, so public-to-everyone works.

Other

--topic-tag <text> sets the Threads topic tag. --post-type <text> is stored on the post and read by nothing — set it only if your own tooling reads it back.

What is checked before the request

posts create first calls accounts list for the space, so it knows each account's platform. That costs one extra request, and it turns two classes of mistake into an immediate message:

  • An account id that is not in this space is refused, with the ids that are in it.
  • An account that is disconnected still creates the post, but prints a note on stderr — publishing will fail until it is reconnected.

With the platform known, content length is checked before the call. The ceilings are the API's own — 280 (X), 500 (Threads, Pinterest), 2200 (Instagram, TikTok), 3000 (LinkedIn), 63206 (Facebook) — applied to the main copy and to every --reply, and counted in characters, so an emoji counts once rather than twice.

More usefully, the CLI also checks the rules the API accepts happily and only enforces at publish time, where a failure surfaces as a post stuck in error hours later rather than as a response:

Rule Applies to
A text-only post is impossible Instagram, TikTok, Pinterest
Several files must include at least one image LinkedIn, which sends them as a multi-image post
--board-id required; a video pin needs an image or --cover-image-url Pinterest
--privacy-level required; not SELF_ONLY with --branded-content TikTok

When copy is too long for one platform in the call, the error names the fix:

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

Notes and warnings go to stderr, and only hard failures stop the call. A --json run therefore still prints clean JSON on stdout while explaining a disconnected account or a dropped comment on stderr.

If your token's user can create posts but not publish them, an immediate or scheduled create is routed into approval: the posts come back with status pending_approval and an empty scheduled_at, because the time you asked for is held on the review rather than on the post. Nothing publishes until an approver acts. Drafts are unaffected. See Rate limits for the separate 30/min publish ceiling this endpoint counts against.

zilfu posts delete

zilfu posts delete --space 1 --post 502
Deleted post 502.

Immediate and unconditional — there is no confirmation prompt and no undo. It deletes a single post row, so deleting one account's copy of a multi-account post leaves the others alone.

The API answers 204 No Content, so there is no server payload to print. With --json the CLI prints an acknowledgement it made up itself, {"deleted": true, "id": 502}. Nothing in that object came from the server beyond the fact that the request succeeded.

zilfu media upload

Uploads a local file and prints a URL you can hand to posts create --media.

zilfu media upload --file ./launch.mp4
url   https://<your-media-host>/uploads/7/01K2FQ8Z6R3M4N5P6Q7R8S9T.mp4
type  video
mime  video/mp4
Flag Effect
--file <path> Path to the file. Required
--space <id> Also add the upload to this space's media library

Zilfu accepts jpg, jpeg, png, webp, mp4 and mov. The type is decided by the file extension, not by the file's contents, and anything else is refused locally — the API would reject it anyway.

Without --space, the file is uploaded and the URL is yours to use; it does not appear in the space's media library. With --space, it is also ingested there under its file name.

The upload itself does not go to the Zilfu API. The CLI asks the API to sign a URL, then PUTs the bytes straight to storage — that request carries no token, the signature is the whole authorisation, and the signature expires 15 minutes after it is issued. A failure at that step prints Upload failed: <status> <text> and says so.

The three printed fields are exactly the three posts create needs. The signed URL's own path and expires_at are not printed in either mode.

zilfu slots list

The recurring weekly posting schedule for a space.

zilfu slots list --space 1
id  day  time
--  ---  -----
31  Mon  09:00
32  Wed  09:00
33  Fri  17:30

Sorted by day then time. The id column is what slots delete --slot takes. --json keeps the API's raw day_of_week, an ISO-8601 day number where 1 is Monday and 7 is Sunday — the Mon/Wed labels exist only in the table.

zilfu slots create

Adds a slot at one time on one or more days.

zilfu slots create --space 1 --days mon,wed,fri --time 09:00
id  day  time
--  ---  -----
31  Mon  09:00
32  Wed  09:00
34  Fri  09:00
Flag Effect
--days <days> Days for this slot — mon,wed,fri or 1,3,5. Comma-separated or repeatable. Required
--time <HH:MM> Time of day in the space's timezone. Required

One time, several days: to schedule 09:00 and 17:00 you run the command twice. Day names are matched whole, so mon and monday both work but monkey is rejected rather than quietly read as Monday. Duplicates in --days are collapsed.

--time is a 24-hour HH:MM. A single-digit hour is padded for you (9:00 is sent as 09:00, which the API would otherwise reject), seconds are tolerated only when they are 00, and an impossible time like 99:99 is refused locally — the API only checks the shape of the string and would store that verbatim.

The endpoint upserts. A day that already has a slot at that time is left alone and returned unchanged, so the output lists every requested day, not just the new ones — 31 Mon 09:00 above may well have existed already. Re-running the same command is a no-op, which makes this safe in a provisioning script.

zilfu slots delete

zilfu slots delete --space 1 --slot 33
Deleted slot 33.

Ids come from slots list. Like posts delete, this is a 204 with no body, so --json prints the CLI's own {"deleted": true, "id": 33}.

Changing a schedule is owner-only. Both slot writes are authorised against space ownership, so a member holding every permission in the space is still refused, and a read-only member is refused a step earlier by the workspace gate — including on slots list, which sits behind the same gate. Both answer 403; the CLI prints a hint on stderr naming which of the two you hit.

zilfu analytics overview

One summary row per account in a space, never merged.

zilfu analytics overview --space 1 --from 2026-07-01 --to 2026-07-31
Window  2026-07-01 to 2026-07-31 (requested)

id  account    social     collection            posts  interactions  remedy
--  ---------  ---------  --------------------  -----  ------------  ------
12  Acme       instagram  ok                    18     4,201
14  Acme       threads    paused (token stale)  6      -             Reconnect the account
Flag Effect
--from <date> Window start (default: 6 days ago)
--to <date> Window end (default: today, in the space's timezone)

Then two further sections, Totals and Rates, each one row per account and metric, carrying the platform's own label for the metric. They are printed side by side and never pooled: likes means different things on Facebook and LinkedIn, and only the label says so.

Those seven columns are deliberately the only ones that mean the same thing on every platform. Everything else lives in the per-account sections, or in --json.

The header says (requested), not what was measured, and the wording is exact. The API clamps to to the space's today, falls back to the default week for a range that is inverted or wholly in the future, and caps the span at 365 days — all silently, and the summary rows carry no window of their own to read it back from. analytics account and analytics posts do, and print the real one.

A - in a numeric column is not a zero. It means the API could not vouch for the number — collection paused, a permission missing, a metric deprecated — and every analytics command prints it rather than inventing a 0. In --json the same cell is null.

zilfu analytics account

The full report for one connected account.

zilfu analytics account --space 1 --account 12
account     Acme @acme (instagram, id 12)
range       2026-08-04 to 2026-08-10 (7 days, Europe/Rome)
previous    2026-07-28 to 2026-08-03
collection  ok, last collected 2026-08-10T04:12:09+00:00, every 4h over 90 days
coverage    18 posts (0 imported) on 6 of 7 days
contract    analytics.v2

Takes --account alongside --space, plus the same --from/--to as overview. Below the header it prints, in order: Totals, the interaction score under the platform's own name for it, Rates, the full Metrics catalogue, Audience when the platform reports one, and an Insights block.

Unlike overview, range here is read off the response, so it is the window that was actually measured after clamping.

One thing the table deliberately summarises rather than prints: the daily series is reduced to one line saying how many points there are and what they span. The points themselves are only in --json.

A platform with no analytics driver reports it plainly rather than printing empty tables:

This platform has no analytics driver, so there is nothing to report.

Rates are already multiplied by 100 and print with a %. Their movement is quoted in percentage points (+1 pp), because 2% to 3% is not "+50%"; counts move in percent.

zilfu analytics posts

One row per published post, with this platform's metric columns.

zilfu analytics posts --space 1 --account 12 --sort interactions --dir desc
Flag Effect
--from / --to The window, as above
--sort <key> published_at, interactions, or any metric key this account declares
--dir <asc|desc> Default desc
--per-page <n> Rows per page, 10-100 (default 20)
--page <n> Page number (default 1)

The metric columns are not fixed by the CLI — they come from the account's own catalogue, so an Instagram account and a LinkedIn account print different tables. Two columns that share a label are disambiguated with their key rather than one overwriting the other.

--per-page outside 10-100 and a --dir that isn't asc/desc are rejected locally. --sort is not: which keys are valid depends on the account, so it is sent verbatim and the API's 422 names every key it would have accepted. The valid keys are also printed under the table on a successful run:

Sort keys: published_at, interactions, likes, comments, saves, views
Accepted but currently unmeasured: shares

A key in that second line is accepted and sorts — on nulls, since nothing is measuring it — so it ranks nothing. Any column whose health is not ok is listed under the table with its reason.

Post excerpts are flattened to one line and truncated to 48 characters for the table; --json has the untouched text.

The tables are a projection

Each table shows a fixed set of columns chosen by the CLI, not everything the API returned:

Command Dropped from the table
spaces list created_at, updated_at
accounts list profile_url, avatar, disconnected_at
posts list, posts create Everything except the printed columns — content, metadata, type, order, cluster_id, parent_id, user_id, created/updated timestamps, analytics and analytics_metrics, and the nested account, media, children, scheduled_comments and review objects. posts create also drops published_at
slots list, slots create Nothing, but day_of_week is renamed day and printed as MonSun instead of 17
media upload The signed URL's path and expires_at; the media-library record created by --space
analytics overview headline_metric, the account's avatar and disconnected_at, each metric's key, status and remedy, and the whole coverage object beyond the post count
analytics account The daily series points, the audience points behind the latest reading, and each metric's definition, accumulation and remedy
analytics posts permalink, thumbnail, imported, each metric's status/reason, and the per-post interactions.components breakdown

Only --json is lossless. If a field you need isn't in the table, it is very likely already in the JSON — check there before reaching for the REST API.