Skip to main content

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; --json is the stable, key-sorted machine contract. Pipe it into jq.
  • 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 (read for reporting, write for 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 a RATE_LIMITED call immediately (exit 6).
  • --max-wait <seconds> — cap total wait time (a hostile retryAfterSeconds can 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