CLI: recipes & scripting
Patterns for driving Jentrix from bash and CI with the jentrix client:
idempotent writes, conflict merges, rate-limit handling, pagination, and agent
usage. For the flags and exit codes these rely on, see
CLI: command reference; the CLI overview is the
hub.
Scripting basics
- Always pass
--json. The human table layout is cosmetic and may change;--jsonis the stable, key-sorted machine contract. Pipe it intojq. - Branch on
$?, not on text. Every failure maps to a frozen exit code. - Use a scoped token. Give the script the least scope it needs (
readfor reporting,writefor mutations); pin it to one workspace where you can.
# every task key + title across a board, tab-separated
jentrix task list --board board_1 --json | jq -r '.tasks[] | [.key,.title] | @tsv'
# just the slugs of every workspace the token can see
jentrix workspace list --json | jq -r '.workspaces[].slug'
Idempotency
Creation commands that support an idempotency key expose --idempotency-key <k>
— the CLI mounts it wherever the tool's schema declares idempotencyKey (every
create_* and bulk_* tool in the product catalog; a few older creators on the
full surface predate it and do not — jentrix <noun> <verb> --help confirms). Retrying with the same key and the same arguments replays the
stored response instead of creating a duplicate; the same key with different
arguments is a CONFLICT (exit 5).
Derive keys from a stable source (a webhook delivery id, a source-row id) so a retried run is safe:
jentrix task create --column-id col_1 --title "From ticket 4821" \
--idempotency-key "ticket-4821" --json
# re-running the exact line returns the same task, exit 0 — no second row.
# import tasks from a CSV, idempotent per row id, so a re-run never duplicates
while IFS=, read -r row_id title; do
jentrix task create --column-id col_1 --title "$title" \
--idempotency-key "import-$row_id" --json | jq -r '.key'
done < rows.csv
The CONFLICT merge loop
Most update_* commands and task move take --if-unmodified-since <iso> (the
tool's expectedUpdatedAt) — mounted wherever the schema declares that field
(jentrix <noun> <verb> --help is authoritative; a few update tools such as
comment update don't carry it). If the entity changed since that timestamp,
the write is rejected with CONFLICT (exit 5) — and the entity's current
state is printed to stdout as JSON, so you can merge and retry in one step
without an extra read:
# 1) first attempt with the updatedAt you last saw
current=$(jentrix task update --task task_123 --title "New title" \
--if-unmodified-since "2026-07-01T12:00:00.000Z" --json) || {
# 2) exit 5: `current` on stdout carries error.current — read the fresh updatedAt
fresh=$(printf '%s' "$current" | jq -r '.updatedAt')
# 3) retry against the fresh timestamp
jentrix task update --task task_123 --title "New title" \
--if-unmodified-since "$fresh" --json
}
Omit --if-unmodified-since for a last-writer-wins update (the UI's default
behavior).
Rate limits: automatic retry
At 60 requests per minute per token, over-limit calls return RATE_LIMITED
carrying retryAfterSeconds. By default the CLI sleeps and retries (2
retries, capped at 60s total wait), so scripted loops mostly ride through a
burst without special handling. Exhausted retries surface as exit 6.
--no-wait— never sleep; fail aRATE_LIMITEDcall immediately (exit 6).--max-wait <seconds>— cap total wait time (a hostileretryAfterSecondscan never stall you past this).
# a tight loop that just keeps going — the default retry absorbs bursts
for id in $(cat task_ids.txt); do
jentrix task get --task "$id" --json | jq -c '{key,title,due}'
done
Pagination
List tools page with a cursor. A truncated page carries totalCount and a
notice string, plus the cursor to fetch the next page (the field name is on
each tool's --help; commonly cursor / nextCursor). Loop until the cursor
is empty:
cursor=""
while :; do
page=$(jentrix task list --board board_1 --response-format concise --json \
${cursor:+--args "{\"cursor\":\"$cursor\"}"})
printf '%s\n' "$page" | jq -r '.tasks[].key'
cursor=$(printf '%s' "$page" | jq -r '.nextCursor // empty')
[ -z "$cursor" ] && break
done
Prefer --response-format concise in loops: it is a fraction of the byte/token
cost and still carries ids, keys, titles, column/status, priority, and due
dates.
Branching on exit codes
Because the exit codes are frozen, a script can react precisely without parsing messages:
jentrix task get --task "$id" --json
case $? in
0) : ;; # ok
4) echo "gone: $id" >&2 ;; # NOT_FOUND — skip
3) echo "no access — check token scope/workspace" >&2; exit 3 ;;
7) echo "token is dead — mint a new PAT at /account/tokens" >&2; exit 7 ;;
*) echo "unexpected failure ($?)" >&2; exit 1 ;;
esac
jentrix session end adds one code that reports a success: 8 means the
session closed but with recorded capture debt. Never fold it into the failure
branch — the session IS closed.
jentrix session end "$ses" --json > close.json
case $? in
0) : ;; # closed, capture clean
8) echo "closed with a capture gap — see the session page" >&2 ;;
*) echo "the session did NOT close ($?)" >&2; exit 1 ;;
esac
# the same call from JSON alone, no exit code needed:
jq -r '.status, .captureStatus' close.json # COMPLETED / OFF_BY_DESIGN|COMPLETE|PENDING|ERROR
jq patterns
# find similar tasks to a free-text query (semantic search; empty when the
# embeddings provider is not configured — never an error)
jentrix tool find_similar_tasks \
--args '{"workspaceId":"ws_1","query":"login is broken"}' --json | jq '.tasks'
# what did the agents do in the last day (source-filtered activity feed)
jentrix activity list --board board_1 --args '{"source":"mcp"}' --json \
| jq -r '.activities[] | "\(.createdAt)\t\(.type)"'
# a board snapshot (board + columns + concise tasks) in one call
jentrix board snapshot --board board_1 --json | jq '{board:.name, columns:[.columns[].name]}'
Agents and the CLI
The CLI is a first-class agent substrate: an agent with only a shell can
operate Jentrix through jentrix with no bespoke API client and no tool schemas in
context. Two rules make this safe:
- Give the agent a read-scoped (ideally workspace-pinned) token. That
server-side scope is the real boundary — a read token physically cannot
mutate (writes return
FORBIDDEN, exit 3), no matter what the agent is prompted to do. - Don't let untrusted input reach
--url/--token. Those override the endpoint and credential for a call; keep them out of any command a model composes from external text. An on-host command guard can only ever be best-effort, so the read-scoped token is what you rely on.
The agent reads its token from STACKS_TOKEN / STACKS_MCP_URL (the same env
the CLI uses) and never passes --token / --url itself.
The cli-standup reference agent
agents/cli-standup.ts is a worked example: a bash-only
agent that produces a daily standup digest (humans vs. agents, split by activity
source) driving Jentrix entirely through jentrix. It discovers the surface
lazily — jentrix --help, then jentrix <group> --help — and pipes --json
through jq, instead of carrying ~130 tool schemas in context. It runs on both
the Claude and Codex runtimes with Bash and nothing else. See
Agent setup variants for hosting.
Using the CLI in CI
Because it installs cold and exits on frozen codes, the CLI drops into a pipeline step:
- run: npx @jentrix/cli task list --board "$BOARD" --json > tasks.json
env:
STACKS_TOKEN: ${{ secrets.STACKS_READ_TOKEN }}
STACKS_MCP_URL: https://tm.jentrix.ai/api/mcp # your deployment's origin + /api/mcp
Use a dedicated read-scoped token as a CI secret; never inline it or pass it via
--token on the command line (where it can leak into logs).
See also
- CLI: command reference — the flags and exit codes these recipes use.
- MCP tool reference — the tool catalog, scopes, idempotency, and concurrency the CLI inherits.