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 --jsonprints JSON;zilfu --json healthsilently ignores the flag and prints the table, andzilfu posts --json list --space 1drops the--jsonthe same way. A flag that takes a value fails louder: the value is read as a command name, sozilfu --token abc whoamiandzilfu posts --space 1 listboth 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
--statusor--accountis not the same request as a single one. One value sends the API'sstatus/account_idparameter; two or more sendstatuses[]/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 separateWHEREclauses and sending bothANDs them into an empty result.
Filters do not compose with a stale page number.
--pageis 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.
| 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-duetsends true;--no-allow-duetsends false. This matters because a flag the CLI sent asfalseon 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 |
|
--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
--jsonrun 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
createis routed into approval: the posts come back with statuspending_approvaland an emptyscheduled_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 printsUpload 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:00above 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 answer403; 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 clampstoto 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 accountandanalytics postsdo, 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 a0. In--jsonthe same cell isnull.
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 Mon…Sun instead of 1…7 |
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.