The fq command line
A standalone binary that drives a FlexiQ server over gRPC — enqueue, read and cancel jobs, and operate queues, dead letters, workers, schedules and overrides, with no SDK and no database credential.
A standalone binary that drives a FlexiQ server over gRPC — enqueue, read and cancel jobs, and operate queues, dead letters, workers, schedules and overrides, with no SDK and no database credential.
fq is one binary. It speaks flexiq.v1.ProducerService and
flexiq.admin.v1.AdminService over gRPC and links no FlexiQ SDK: no language
runtime, no native extension, no database driver. If you run flexiq-server and
write your application in something the SDKs do not cover, this is your command
line.
It is also the only command line that talks to the server. An SDK's CLI
opens the database off a DSN, which is the wrong door for an operator holding a
scoped API token and no database credential. fq holds a token and nothing
else.
Talking to the server instead of the database means fq can only do what the
wire exposes. What the wire cannot do yet says
where that stops.
It ships with the server, from the same build, so their versions cannot drift. Two ways to reach it:
# From the server image, which carries both binaries.
docker run --rm --entrypoint /usr/local/bin/fq \
ghcr.io/byteveda/flexiq-server:latest --version# Or lift the binary out and put it on your PATH.
id=$(docker create ghcr.io/byteveda/flexiq-server:latest)
docker cp "$id:/usr/local/bin/fq" ./fq
docker rm "$id"Each server-v* GitHub release also attaches fq-<version>-linux-amd64 and
fq-<version>-linux-arm64. They are statically linked, so there is nothing to
install alongside them.
Two things: where the door is, and a token.
export FLEXIQ_ENDPOINT=http://127.0.0.1:50051
export FLEXIQ_TOKEN=$(flexiq-server token create --name my-producer --scope produce)token create writes the token to stdout and everything else — what it minted,
and the reminder that this is the only time it is shown — to stderr, so the
substitution above captures the credential and nothing else.
The scheme picks the transport, and fq will not guess one.
| Endpoint | Transport |
|---|---|
http://host:port | Plaintext |
https://host:port | TLS, verified against the platform's root store unless --tls-ca names another one |
unix:/path/to.sock | Unix domain socket |
flexiq-server can terminate TLS on the gRPC door itself now —
TLS and mTLS covers the server side — or
leave it to a proxy or a service mesh in front of it; https:// is the right
scheme either way. An endpoint with no scheme is refused rather than guessed:
error: `127.0.0.1:50051` names no transport. Write http://host:port for plaintext, https://host:port for TLS, or unix:/path/to.sock for a Unix socketThe credential rides every request in an authorization header. Sent over
http:// to another machine, it is readable — and replayable until it expires —
by anything between you and the door. fq warns when that is what you asked
for:
warning: sending FLEXIQ_TOKEN in cleartext to queue.example. Use https:// or unix: unless the path to this host is already private.It warns rather than refuses: a sidecar or mesh proxy on the same host is
reached over plaintext by design, and a door with TLS terminated — whether by
flexiq-server itself or by a proxy in front of it — is reached as
https://. Loopback and Unix sockets never warn — nothing leaves the machine.
The token comes from FLEXIQ_TOKEN and there is no flag for it. A secret in
argv is a secret in ps and in shell history. Mint one with
flexiq-server token create; every command below
needs the produce scope.
There is no --namespace. A namespace is fixed when the token is minted and
one server process serves exactly one, so a flag would be a control the server
ignores. Reads carry the namespace back, and --json shows it.
Three global flags, all optional, for a door with TLS terminated — in process or by a proxy — that needs more than the platform's root store:
| Flag | Env | Meaning |
|---|---|---|
--tls-ca | FLEXIQ_TLS_CA | A PEM CA bundle to verify the server against, replacing the platform root store — the private-CA case |
--tls-cert | FLEXIQ_TLS_CERT | A client certificate, for mTLS |
--tls-key | FLEXIQ_TLS_KEY | Its key |
Without --tls-ca, https:// still verifies against the platform's native
roots, exactly as before. Any of the three set beside an http:// or unix:
endpoint is refused — "TLS files are set, but <endpoint> is not an
https:// endpoint" — because a flag that looks like it changes the
connection and is silently ignored is worse than a refusal.
fq enqueuefq enqueue send_email a@b.c --kw subject=Welcome -q mail --priority 5 --max-retries 3id queue task status priority retries created
------------------------------------ ----- ---------- ------- -------- ------- ------------------------
01a08c9f-4946-70c3-ac34-a47e42e63bf8 mail send_email pending 5 0/3 2026-09-10T18:40:44.358Z| Flag | Meaning |
|---|---|
-q, --queue | The queue. Omitted means the server's default. |
--priority | Higher runs first. |
--max-retries | Attempts after the first. |
--delay-ms | Wait this long before the job becomes eligible to run. |
--timeout-ms | How long one attempt may run before the scheduler reclaims it. |
--expires-in-ms | After this long the job is cancelled instead of dispatched. |
--result-ttl-ms | How long the result is kept after completion. |
--unique-key | Dedupe against the queue's active jobs. |
--metadata | Opaque JSON text, carried through byte for byte. |
--notes | Free prose stored with the job. |
--depends-on | A job id this job waits on. Repeatable. |
--kw KEY=VALUE | A keyword argument. Repeatable. Splits on the first =. |
Every positional argument and every --kw value is read as JSON, falling back
to a string:
| You type | The task receives |
|---|---|
3 | the number 3 |
hello | the string "hello" |
a@b.c | the string "a@b.c" |
true | the boolean true |
null | null |
'{"x":1}' | an object |
'[1,2]' | an array |
'"3"' | the string "3" |
That last row is the escape hatch: '"3"' is already the JSON spelling of the
string 3, so there is no separate --string flag.
fq sends the wire's structured body arm, so the server builds the payload
envelope with its own encoder and this binary carries no second copy of the
format. Two consequences:
Both change the bytes of the payload, and an automatic auto: idempotency key
is a hash of those bytes. So a job enqueued by fq may not deduplicate against
the same logical call made from an SDK. When that matters, set --unique-key
explicitly and the ambiguity goes away.
fq jobsfq jobs list
fq jobs list --status pending --queue mail --task send_email --limit 50
fq jobs get <id> --payload --result
fq jobs cancel <id>| Flag | Command | Meaning |
|---|---|---|
--status | list | pending, running, complete, failed, dead or cancelled. The enum's own spelling (JOB_STATUS_PENDING) is accepted too. |
-q, --queue | list | Only this queue. |
-t, --task | list | Only this task name. |
--limit | list | How many rows to ask for. The server may return fewer. |
--page-token | list | Continue a previous listing. |
--payload | get | Also fetch the request payload. |
--result | get | Also fetch the return value. |
Listings are newest first and never carry a payload. Paging is by an opaque
token the server issues, not by an offset, so fq prints the next call for you:
next page: --page-token MTc4OTA2NTY0NDM3MTowMWEwOGM5Zi00OTUzLTc1MjMtODc0Ny1mMjlhOGY5Y2E4OGMfq jobs cancel is idempotent. A pending job becomes cancelled; a running one
keeps running with cancelRequested set, for the task to notice; a job that
already finished is unchanged. Running it twice is not an error.
A job in another namespace reads as not found, indistinguishable from one that never existed — the door refuses to be an existence oracle.
fq tailfq tail <id> [<id>...]
fq tail --queue orders
fq tail <id> --jsonFollows jobs as they change state, over the WatchJobs stream. It never polls.
fq tail <id> on a done job returns
at once. An id that does not exist, or belongs to another namespace, prints
not found.--queue, it prints every transition of the queue's jobs until
interrupted. It sees only what the server process it reaches handles itself.2026-09-25T19:20:14.389Z 01a0da02-d734-7505-9467-d76f976470c8 orders send enqueued -> pending attempt 0
2026-09-25T19:20:14.402Z 01a0da02-d734-7505-9467-d76f976470c8 orders send cancelled -> cancelled attempt 0Each line is the time, the job, its queue and task, what happened, the status
that left it in, and the attempt. --json prints one proto3 JSON object per
line.
tail reconnects on its own when the connection drops, such as when a laptop
sleeps or the server restarts. It says so on stderr, backing off from 1 to 30
seconds:
fq: stream lost (Unavailable: the server is shutting down (SHUTTING_DOWN)); reconnecting in 1s
fq: missed transitions while disconnected; following from nowAn id tail reopens with the jobs still unfinished, and the server's snapshot
reports anything that finished while it was away. A queue tail resumes from its
last position. If the server no longer holds that position (a restart, another
replica, or too long away), tail prints the second line above and carries on
from now. A refusal that retrying cannot fix, such as a bad token, exits 1.
fq queuesfq queues # the whole namespace
fq queues mail # one queuestate count
--------- -----
pending 2
running 0
completed 0
failed 0
dead 0
cancelled 0An omitted argument means aggregate the namespace, not list what is in it;
fq queues --list is the listing, and fq throughput the rates — both on the
admin door, below.
Everything below calls the admin door, so the token
needs inspect to read and admin to change — a produce token is refused
SCOPE_DENIED. Durations are milliseconds, like every other flag here.
fq queues --list # every queue, with a paused column
fq pause emails # stop dispatch; fq resume emails lifts it
fq throughput --window-ms 600000 # finished jobs per queue, and per minute
fq workers
fq drain <WORKER_ID> # stop claiming, finish, unregisterA drain is read by the worker on its next heartbeat, so fq drain answers
with the worker draining; fq workers stops listing it once it has stopped.
queue paused pending running completed failed dead cancelled
------- ------ ------- ------- --------- ------ ---- ---------
dlq no 0 0 0 0 1 0
emails no 1 0 0 0 0 0
reports no 0 0 1 0 0 0The dead-letter queue:
fq dlq list # newest first; --all follows every page
fq dlq show <ID> --payload
fq dlq replay <ID> # enqueue it again; prints the new job
fq dlq delete <ID>
fq dlq purge --task charge # or --before <RFC3339>, --older-than-ms, --allpurge takes exactly one filter, so emptying the whole queue has to say
--all.
fq dlq list, fq periodic list and fq overrides list take --queue and
--task, and fq throughput takes --queue. A token narrowed to some queues
or tasks must name one it reaches:
fq dlq list --queue billing
fq periodic list --queue billingPeriodic tasks, with arguments read the way fq enqueue reads them:
fq periodic put nightly-report daily --task build_report --cron '0 0 2 * * *' \
--timezone Europe/Berlin
fq periodic pause nightly-report # resume, trigger, delete, show, listput never resumes a paused task, and trigger fires one run now without
moving the schedule. Overrides:
fq overrides set-task send_email --rate-limit 100/m --timeout-ms 30000
fq overrides list
fq overrides clear-task send_emailset-task and set-queue replace the override — a flag left out is no
longer overridden — and a worker reads overrides when it starts, so a change
reaches no worker already running.
fq quota get
fq quota set --max-pending 10000 --on-excess drop --enqueue-rate 500/s
fq quota clearset also takes --max-running, --max-archived-rows and
--max-dead-rows. It replaces the quota, so a flag left out lifts that
limit. get prints a limit / value table, with - for a limit that is not
set.
The audit trail — which token made each write:
fq audit list --job <JOB_ID> # who enqueued it, who cancelled it
fq audit list --token-id <TOKEN_ID> # everything one token did; --all follows every page
fq audit list --target queue:emails # or --kind, --since, --untilAPI tokens, which need the tokens scope
instead of inspect or admin — neither implies it:
fq tokens create --name ci --scope read --expire-days 7
fq tokens list
fq tokens show <TOKEN_ID>
fq tokens revoke <TOKEN_ID>create follows flexiq-server token create: the secret is the whole of
stdout, so FLEXIQ_TOKEN=$(fq tokens create ...) captures it, and the summary
with the token's id goes to stderr.
Minted 'ci' (id a19670679cb5c765). This is the only time the token is shown; only its hash is kept.--scope repeats, and takes the grant spelling flexiq-server token create
takes, including produce:queue=emails-*. --expire-days is 1 to 365 and
defaults to 90. A token can only mint what it holds, so a request wider than
the calling token is refused, and so is one that would outlive it — the refusal
names how many days are left:
error: PermissionDenied: this credential cannot grant `admin`: none of its own grants covers it. A token may only hand on, or take away, grants it holds itself. (SCOPE_DENIED, scope=admin)
error: InvalidArgument: expire_days 90 would outlive this credential, which expires at 2026-11-07T22:29:34Z; a minted token must expire by then — ask for at most 29 days (INVALID_REQUEST)list shows revoked and expired tokens too, with a status column. revoke
is idempotent, and revoking a token does not revoke the tokens it minted.
Without --json, every command prints an aligned table, and an empty result
prints (none).
With --json, every command prints proto3 JSON — the same encoding the
server's own /v1 HTTP facade returns. lowerCamelCase keys, RFC 3339
instants, durations as a decimal number of seconds with an s, bytes as
base64, enum values by name, int64 values as strings, and a field that is
absent on the wire absent from the object rather than null:
fq jobs get 01a08c9f-4953-7523-8747-f29a8f9ca88c --payload --json{
"job": {
"cancelRequested": false,
"createdAt": "2026-09-10T18:40:44.371Z",
"hasDeps": false,
"id": "01a08c9f-4953-7523-8747-f29a8f9ca88c",
"maxRetries": 0,
"namespace": "demo",
"payload": "AoKBGQfqoWZmb3JtYXRjcGRm",
"priority": 0,
"queue": "default",
"retryCount": 0,
"scheduledAt": "2026-09-10T18:40:44.371Z",
"status": "JOB_STATUS_PENDING",
"taskName": "generate_report",
"timeout": "300s"
}
}One jq expression therefore works against fq and against curl on the same
deployment.
Every refusal from the door carries a machine-readable reason beside its gRPC
code, and fq prints both:
error: PermissionDenied: this credential does not carry the `produce` scope (SCOPE_DENIED, scope=produce)error: NotFound: job not found: 01a08c9f-0000-0000-0000-000000000000 (JOB_NOT_FOUND)UNAUTHENTICATED is one status for every credential failure — missing,
unknown, revoked, expired, or minted for another namespace — so that the door
cannot be used to probe which tokens exist. fq says so rather than guessing:
error: Unauthenticated: no usable credential was presented (UNAUTHENTICATED)
the server accepted no credential. Check FLEXIQ_TOKEN: it may be unset, revoked, expired, or minted for another namespace.Exit status is 0 on success, 2 for a usage error, and 1 for everything
else.
fq is deliberately no larger than the wire. What is not on it cannot be a
subcommand here:
| Missing | Why |
|---|---|
Follow a queue across every replica (fq tail --queue) | A queue watch hears only the server process it reaches. An id watch also re-reads every watched job on a timer, so it covers every process; a queue has no bounded set of ids to re-read. Tail ids, or run one replica. |
| Follow a workflow run | WatchJobs follows jobs. A run is not one, so fq has no verb for it. |