Operator CLI
tripl is a small command-line client for a tripl instance. Most of it talks to
a running instance over HTTP; two commands instead act on a directory and
the local Docker daemon, and those are the ones that bring an instance into
existence in the first place.
Four commands ask a question about the instance as a whole and change nothing:
tripl doctor— runs six diagnostic checks and tells you what is broken, why, and what to do about it. Exits non-zero when something is wrong.tripl status— a quick live view of every project: events, scan configs, open signals, firing monitors, reconciliation coverage. Always exits 0.tripl watch— follow mode. Prints what changes while you watch: replay chunk progress, jobs starting and finishing, signals opening, alert deliveries failing. Runs until you stop it. Never reports a verdict.tripl whoami— which account the configured key acts as, the organization it is bound to, and its scope. Seetripl whoami.
Two more act on a class of objects and are spelled <plural-noun> <verb>:
tripl scans—listthe scan configurations, print one'sjobs,runone now,cancelan active job.tripl drifts—listschema drifts,dismissone,reopenone.
One more records a single fact about a project and is one word:
tripl annotate— post a deploy or release marker onto every monitoring (Volume tab) chart in a project, from a CI step. Seetripl annotate.
scans run, scans cancel, drifts dismiss, drifts reopen and annotate
are the CLI's only mutating commands. Read Write safety before you use one. Every other
command that talks to an instance is read-only, and a tk_r_ key is enough for
all of them.
Two more are read-only in full, and answer questions about the content rather than the machinery:
tripl events—listthe catalog with the API's own filters,showone event with its field values resolved to field names.tripl plan— the shape events are declared to have:types, one type'sfields, the documentedvariables, the planbranches, andsearchacross all of it.
Both read one project at a time, so both need --project SLUG exactly once, and
every verb but plan branches takes --branch to read a plan branch instead of
the live main plan.
One more checks your code against the plan, and is one word:
tripl check— scans source files for tracking calls, or reads a file of captured events, and reports unknown events, unknown fields, missing required fields and values the plan does not allow. Built for a CI step, with SARIF output for code scanning. Seetripl check.
It sends a POST, but only to carry the calls it found to the validator: it
changes nothing, and a tk_r_ key is enough.
Two more turn the plan into files in your repository. Both only read the instance; the files they write are local:
tripl codegen— generates typed Swift, Kotlin and TypeScript tracking code from the plan, on top of your own tracking wrapper, with a--checkmode for CI. Seetripl codegen.tripl export— writes the plan as a JSON Schema bundle, one schema per event, or as the modeltripl codegen --modelreads. Seetripl export.
Two act on a host, not on an instance — no URL, no API key, no HTTP except a
/health poll at the end:
tripl install— writescompose.yaml,infra/rabbitmq/rabbitmq.confand a generated0600.envinto a directory, runsdocker compose pullanddocker compose up -din it, and waits until the instance answers. This is the executable form of Self-hosting & Deployment.tripl upgrade— moves an installed stack to a new image tag: pull, move theTRIPL_VERSIONpin, restart, wait.
Everything that talks to an instance is a pure HTTP client of the
/api/v1 surface, exactly like the
MCP server — it imports no backend code and never
touches the database. Since the two tools now
share one request layer, a path
or a response projection is defined in exactly one place for both. They also
read the same two environment variables, so a shell configured for one is
configured for the other. install and upgrade are the exception in both
directions: they run a subprocess (docker), and they read neither variable.
tripl doctor exists because of a four-day incident in which a scan config had
been failing behind a generic "Scan failed due to an internal error", events'
last_seen_at had frozen with no visible cause, an accepted schema drift had
silently deleted a field definition, and the scheduler's retry backoff looked
like a hung worker. Every one of those was visible through read-only REST calls.
This is the one invocation that surfaces them.
Install
The only runtime dependency is httpx. Python 3.12 or newer is required for
the pip path only — every uv-driven form below (uvx, and the
uv run --project checkout form) provisions a suitable interpreter itself, so a
host carrying uv and no Python at all still runs the CLI.
From PyPI. tripl is published, so this is the ordinary path:
uvx tripl doctor # run it without installing anything permanently
pip install tripl # or put it on PATH for good
From a checkout. The form to use when you are working on the CLI itself:
git clone https://github.com/tripl-io/tripl.git
uv run --project tripl/cli tripl --version
# tripl 0.1.0
From git, without a checkout. How you get a revision that is not released yet. Both forms below resolve, but neither is exercised by CI:
uvx --from 'git+https://github.com/tripl-io/tripl.git#subdirectory=cli' tripl doctor
pip install 'git+https://github.com/tripl-io/tripl.git#subdirectory=cli'
tripl install" are different thingsThis section is about getting the tripl command onto your machine.
tripl install is a subcommand of it that provisions a
tripl server on a host. You need the first before you can run the second —
uvx tripl install ... on the deploy host does both in one step.
The distribution and the console script are both tripl; the import package
is tripl_cli. That is deliberate — the service's own source package is
backend/src/tripl/, so a distribution installing an importable tripl would
shadow it in any environment holding both.
The service is packaged as a separate distribution, tripl-server, and is
never published to an index — it ships as a container image. So tripl on PyPI
means this CLI and only this CLI.
Configuration
Two values are needed: the instance URL and an API key. Each is resolved independently, through three layers, highest precedence first:
| # | Layer | Instance URL | API key |
|---|---|---|---|
| 1 | Command-line flag | --url (alias --base-url) | --api-key |
| 2 | Environment | TRIPL_BASE_URL | TRIPL_API_KEY |
| 3 | Config file | base_url | api_key |
Per field, not per source: tripl --url https://staging.example.com doctor with
the key still coming from the config file works exactly as you would expect. An
empty string counts as absent at every layer, so a Compose-materialised
TRIPL_BASE_URL="" falls through to the file instead of shadowing it with
nothing.
export TRIPL_BASE_URL=https://tripl.example.com
export TRIPL_API_KEY=tk_r_...
tripl doctor
TRIPL_URLOnly TRIPL_BASE_URL is read — the same variable the
MCP server uses. TRIPL_URL is
detected purely so that, when it is set and TRIPL_BASE_URL is not, the error
names the right variable instead of shrugging.
A URL pasted from the browser with /api/v1 already on the end is trimmed
rather than doubled, and a bare host with no scheme gets a corrected suggestion
in the error.
Config file
| Platform | Path |
|---|---|
| Linux / BSD / macOS | $XDG_CONFIG_HOME/tripl/config.toml, else ~/.config/tripl/config.toml |
| Windows | %APPDATA%\tripl\config.toml, else the ~/.config fallback |
XDG_CONFIG_HOME is honoured on every platform, but only when it is an absolute
path. macOS uses ~/.config rather than ~/Library/Application Support because
this is a file an operator opens in $EDITOR, the same choice git, gh,
aws, kubectl and docker make.
# ~/.config/tripl/config.toml
base_url = "https://tripl.example.com"
api_key = "tk_r_2f9c..."
Unknown keys and unknown tables are ignored, so an older CLI keeps working
against a file written by a newer one. --config PATH overrides discovery
entirely: a missing default file is fine, a missing --config path is an
error.
On POSIX, a config file that contains an api_key and is readable by other
users produces one warning on stderr. Fix it with chmod 600 ~/.config/tripl/config.toml. Passing --api-key on the command line is worse
still — it is visible to every user on the box through ps(1) and lands in your
shell history. Prefer the environment variable or the config file.
Which key to use
A read-only tk_r_ key is enough for doctor, status, watch, scans list, scans jobs and drifts list, and it should be your default. Those
six issue nothing but GET, so a write key buys them nothing and risks
everything.
A read key is also enough for the events and plan verbs, for
tripl check, which posts to a validator that changes nothing, and for
tripl codegen and tripl export, which only read the plan.
Six verbs mutate the instance and need a tk_w_ key backed by a user with
the editor or owner role: scans run, scans cancel, drifts dismiss,
drifts reopen, annotate and docs push. The other docs verbs (ls, cat
and pull) only read.
Give them a key of their own rather than promoting the one in your cron job —
see Write safety.
tripl install and tripl upgrade need no key and no URL at all. They act
on a directory and the local Docker daemon, so passing --url or --api-key
explicitly is exit 2 rather than a silently ignored flag. An ambient
TRIPL_BASE_URL exported for tripl doctor does not trip that — only a flag you
typed does.
Create keys in the app at Settings → API keys (see API keys & governance). The role behind the key still matters for two things:
- Owner role —
data_source.last_test_messageis redacted for anyone below owner, so the text of a failed connection probe is only visible to an owner's key. doctor says so explicitly rather than reporting "no message". - Project-bound keys — a key fenced to one project cannot list projects at
all. Pass
--project <slug>; see below.
tripl doctor
usage: tripl doctor [-h] [--url URL] [--api-key KEY] [--config PATH]
[--project SLUG] [--include-demo] [--json] [--strict]
[--timeout SECONDS] [--max-event-types N]
| Flag | Meaning |
|---|---|
--project SLUG | Check only this project. Repeatable. Required for a project-scoped key. |
--include-demo | Also check demo projects, which are excluded by default. |
--json | One JSON document on stdout, every human line on stderr. |
--strict | Exit 3 on warnings too — never on skipped checks. |
--timeout SECONDS | Per-request timeout, default 10.0, range 0.1–600. |
--max-event-types N | Event-type budget for the drift scan, default 200, range 1–10000. |
A healthy instance:
tripl doctor - https://tripl.example.com (from $TRIPL_BASE_URL)
PASS connectivity Reached https://tripl.example.com (from $TRIPL_BASE_URL); the API and its database are up.
PASS auth The API key authenticates as an instance-wide key (role: owner).
PASS projects 1 project selected.
PASS data_sources 1 data source(s) referenced by the selected scan configs; none reports a failed probe.
PASS scans 1 scheduled scan configs are collecting on time.
PASS drifts 1 event type(s) examined; no untriaged schema drift.
6 checks: 6 pass. No problems found.
An instance with the incident on it:
tripl doctor - https://tripl.example.com (from $TRIPL_BASE_URL)
PASS connectivity Reached https://tripl.example.com (from $TRIPL_BASE_URL); the API and its database are up.
PASS auth The API key authenticates as an instance-wide key (role: owner).
PASS projects 1 project selected.
FAIL data_sources 1 referenced data source(s); see below.
- fail: data_source_probe_failed 'warehouse-prod'
Data source 'warehouse-prod' (used by scan config 'prod events', 'checkout funnel') last failed its connection test at 2026-07-29T19:08:09Z: 'FATAL: password authentication failed for user "tripl"'.
FAIL scans 1 of 2 scheduled scan configs is not collecting.
- fail: scan_config_failing [prod] 'prod events'
Scan config 'prod events' (1h) has failed 5 consecutive scheduled runs since 2026-07-31T14:08:09Z. Last error: 'Scan failed due to an internal error.' - that is the backend's generic fallback, not the real cause, so the cause is in the worker log for job job-0.
- warn: scan_backoff_active [prod] 'prod events'
The scheduler has deliberately deferred the next attempt to not before 2026-07-31T22:08:09Z (about 4h after the last failure): 3 or more consecutive failures trigger a backoff, so the worker is not stuck.
WARN drifts 1 event type(s) examined; see below.
- warn: schema_field_deleted_by_accept [prod] 'app.screen_view'
Field 'user_id' was deleted from event type 'app.screen_view' on 2026-07-26T19:08:09Z when a missing_field drift was accepted (by user uid-7).
- warn: schema_drift_open [prod]
Project 'prod' has 1 untriaged schema drifts (oldest detected 2026-07-28T19:08:09Z): app.screen_view.cart_value (type_changed)
6 checks: 3 pass, 1 warn, 2 fail. Exit 3.
Re-run with --json for the machine-readable form of every finding.
Output is ASCII only and byte-identical whether stdout is a terminal or a
pipe — no colour, no spinners, no cursor control. tripl doctor | tee incident.log and what you saw on screen are the same artifact, and the log you
paste into a ticket carries no escape codes.
The six checks always run in this order and exactly once each; per-project
results are findings inside a check, never repeated checks. SKIP means "I
could not look", which is never the same thing as a pass — a run where nothing
reached a verdict is reported as fail, not as "0 failures".
1. connectivity — instance reachability
Unauthenticated GET /health at the instance root (not under /api/v1). This
is the only probe that carries no Authorization header, deliberately: it
separates "is the instance up and is its database reachable" from "is your
key good", two questions a 401 on an authenticated probe cannot tell apart.
If this check fails, every other check is skipped and the run ends. That is what bounds a dead-host run to roughly one timeout instead of one per endpoint.
| Finding | What it means | What to do |
|---|---|---|
instance_unreachable | DNS, TLS, connection refused or timeout. | Check the URL the header printed and where it came from — base_url_source names --url, $TRIPL_BASE_URL or the config file path. Then check the app container and whatever proxy fronts it: Runbook → Health checks. |
instance_database_unreachable | /health answered 503 {"status":"error","component":"database"}. The API is up; PostgreSQL is not. | This is a stack problem, not a tripl problem. See Troubleshooting → PostgreSQL. Almost every API request will fail until it is back. |
instance_not_tripl | Something answered, but not the way a tripl instance does. | The URL points at a proxy, a load-balancer error page, or the wrong host. Compare with curl -fsS "$TRIPL_BASE_URL/health". |
2. auth — API key
GET /auth/me. A 200 means the key is instance-wide and reports the backing
user's role; a 403 is a pass, because a project-scoped key is refused on
every route without a {slug} path parameter by design. Only the role is
printed — never an email, never a key prefix — because doctor output gets pasted
into tickets.
| Finding | What it means | What to do |
|---|---|---|
api_key_invalid | 401. The key is wrong, revoked, or expired. | The message names where the key came from. Mint a new tk_r_ key at Settings → API keys; note that keys can carry an expiry. |
auth_unexpected_status | Any other non-200. | Usually a proxy in front of the API rewriting the response. Inspect with curl -i -H "Authorization: Bearer $TRIPL_API_KEY" "$TRIPL_BASE_URL/api/v1/auth/me". |
3. projects — project selection and provisioning
Resolves which projects the rest of the run judges. Without --project, the
instance listing is used and demo projects are excluded — their runtime
ticks and collection cooldown are noise in exactly the checks that matter. The
exclusion is always printed, never silent.
| Finding | What it means | What to do |
|---|---|---|
project_selector_required (fail) | The key is project-scoped, so doctor cannot discover the slug. | Re-run with --project <slug>. See below. |
project_list_forbidden (fail) | GET /projects returned 403 for a key that is not project-scoped. | The backing user's role is insufficient. Use a key owned by a user with access, or name the project with --project. |
project_not_found (fail) | A slug passed to --project does not exist. | Check the spelling — it is the URL slug, not the display name. |
project_not_authorized (fail) | The key is fenced to a different project. | Use the right key, or the right slug. |
no_projects_selected (warn) | The instance has no non-demo projects. | Nothing is broken. Add --include-demo to check the demo workspace, or --project. |
project_generation_failed (fail) | Provisioning of a project did not finish. generation_error carries the reason. | The project's data is incomplete and every downstream check about it is unreliable. Re-create it, or see Demo workspace if it is a demo. |
project_generating (warn) | Provisioning is still in progress. | Wait and re-run. Counts reported for it are partial. |
4. data_sources — warehouse connection probes
Judges only the data sources that a selected project's scan configs actually reference; an unused source failing its probe is not this run's business.
last_test_at is written only on an explicit connection test or on a
create/update — nothing refreshes it in the background. A source that last
tested OK a month ago and has been rejecting the worker's credentials since
Tuesday still reports success. That is what data_source_probe_stale exists
to say.
| Finding | What it means | What to do |
|---|---|---|
data_source_probe_failed (fail) | The last connection test on a referenced source failed. | Start here — it explains the scan failures below it. evidence.last_test_message carries the warehouse's own error. If message_redacted is true, re-run with an owner-role key to see the text. Then see Troubleshooting → A scan run fails. |
data_source_probe_stale (warn) | The source last tested OK, but that test predates the moment its scan config started failing. | Do not trust the green. Press Re-test connection on that data source in the app and read the fresh result. evidence.streak_started_at is when the failures began. |
5. scans — scheduled metrics collection
The centre of gravity of the whole command, and the check the incident needed. For every scan config that has a collection interval, doctor reads the newest 200 jobs — the API's maximum — and counts the run of consecutive failed dispatcher jobs at the head of the history.
That is deliberately not the 40-row window the dispatcher itself walks. The
scheduler only needs the backoff delay, which stops growing at six consecutive
failures, so 40 rows are ample for it. doctor answers a different question —
how long has this been broken — and at 40 rows a 15m config failing for four
days would report 40 failures since this morning instead of 384 since Monday.
When every job in the window is a failure the run started even earlier, so the
finding says "at least N, at least since T" and sets
evidence.consecutive_failures_truncated.
Dispatcher jobs are identified positively, by result_summary.mode == "metrics_collection". Manual catalog scans, metrics replays, event-group
applies and demo runtime ticks share the same job table and are neither counted
as failures nor treated as the success that ends a streak — so pressing Run
now in the app can neither trigger the backoff nor clear it.
| Finding | What it means | What to do |
|---|---|---|
scan_config_not_dispatchable (fail) | The config has an interval but no time column, so the scheduler's query never selects it. It is not failing; it is invisible. The app shows the same config with a Needs a time column badge on its row in Govern → Scans. | Open the scan's configuration in the app and set a time column. Nothing will ever collect until you do. |
scan_config_failing (fail) | N consecutive scheduled runs failed. | See the walkthrough below. |
scan_backoff_active (warn) | The scheduler has deliberately deferred the next attempt. | Nothing. This is expected behaviour — see The retry backoff is not a hang. |
scan_watermark_stale (warn) | Collection is succeeding, but the last run only wrote data through evidence.time_to, more than three intervals behind now. Not raised for a demo project, whose watermark freezes while the demo is paused. | The query works and the source table is producing no rows in the scan window. Check the upstream pipeline that fills that table, and the scan's filters. This is the shape of "events frozen with no error". |
scan_history_window_full (warn) | Every job in the fetched history window is a manual run, a replay or a demo tick, so whether scheduled collection runs at all could not be determined. | Not a fault by itself. Check the config's job history in the app, or trigger a scheduled collection and re-run. |
scan_never_collected (fail) | The config is older than two intervals and no scheduled job has ever run for it. Not raised for a demo project, for the same reason as scan_not_dispatched. | The beat scheduler is probably not running: docker compose logs --tail=50 celery-beat. See Runbook → Health checks. |
scan_not_dispatched (fail) | Not failing, but nothing has been dispatched in over three intervals. Never raised for a demo project: the scheduler throttles demos and stops collecting a demo entirely while nobody opens it, so its silence is expected. | Same suspects as above — beat, the broker, or a worker that never picks the task up. See Troubleshooting → RabbitMQ. |
scan_interval_unknown (warn) | The backend reports an interval this CLI does not know. | Upgrade the CLI. Staleness and backoff for that config could not be judged, and doctor says so rather than judging with a wrong denominator. Whether it is failing needs no interval, so that is still reported alongside. |
Suppression is intentional: a failing config is trivially also "not recently
dispatched", so scan_not_dispatched is not raised alongside
scan_config_failing. One root cause, one finding.
What to do about scan_config_failing
The evidence is designed to be worked top to bottom:
-
last_error_is_generic: true— the message is the backend's own catch-all,"Scan failed due to an internal error.", which is what any uncurated exception collapses to. Do not quote it as the cause; that is the wall the original incident hit for four days. The real traceback is in the worker log for the job named bylast_failed_job_id:docker compose logs celery-worker | grep -F '<last_failed_job_id>'When
last_error_is_genericisfalse, the text is the curated cause and can be read at face value. -
data_source_id— cross-reference thedata_sourcescheck above. Adata_source_probe_failedfinding on the same id is your answer, and the scan failure is a symptom. -
last_success_atandconsecutive_failures— when it last worked, and how long it has been broken. Iflast_success_atisnull, it never worked and the config itself is probably wrong. -
Only after those: open the scan in the app and re-run it manually. A manual run neither counts toward the streak nor clears it, so it is a safe probe.
6. drifts — schema drift
One request per event type, bounded by --max-event-types (default 200). The
budget is spread evenly across the selected projects rather than spent in project
order, so no project is starved to zero reads by a larger one. What the budget
could not reach is reported, never silently omitted — and reported per
project, so "nothing found here" and "this project was barely looked at" never
print the same.
| Finding | What it means | What to do |
|---|---|---|
schema_field_deleted_by_accept (warn) | A missing_field drift was accepted, and accepting one deletes the field definition from the event type. | Verify this was intended. The deletion is invisible in every other surface and is the mechanism by which a field silently vanished from a tracking plan. resolved_at and resolved_by (a user id) say when and by whom. Re-add the field if it was a mistake. |
schema_drift_open (warn) | Untriaged drifts — status open, or snoozed past its snooze. | Triage them in the app. evidence.examples names up to three; untriaged_count is the real total. |
drift_scan_truncated (warn) | This project has more event types than the budget reached. One finding per affected project; evidence.examined and evidence.total are that project's own counts, and a project examined in full raises no finding at all. | Raise --max-event-types, or narrow the run with --project. Until then, treat the named project's drift result as partial. |
endpoint_unexpected_status
This code can appear in projects, scans and drifts, and it
always means the same thing: a read did not return 200, so the state it
reports is unknown — not empty. doctor never treats a non-200 as an empty
list. A 404 read as "no drifts" is exactly the class of mistake this command
exists to eliminate. evidence.path and evidence.status_code name the call.
The data_sources check is the one exception, and deliberately: a non-200
there becomes a skip with a skip_reason, because a read key that
cannot list data sources is a normal, unfixable fact about that key rather
than an instance fault. A skip never counts as a pass, so a run in which
every check skipped still exits non-zero.
Project-scoped keys
A key bound to a single project is refused on every instance-level route by
design. doctor detects that positively and first, off /auth/me, so it can
tell you the right thing instead of inferring it from some later 403:
tripl doctor --project prod
tripl doctor - https://tripl.example.com (from $TRIPL_BASE_URL)
PASS connectivity Reached https://tripl.example.com (from $TRIPL_BASE_URL); the API and its database are up.
PASS auth The API key authenticates and is scoped to a single project, so instance-wide endpoints are refused by design.
PASS projects 1 project selected.
SKIP data_sources GET /data-sources is instance-level and returns 403 for a project-scoped key by design; re-run with an instance-scoped key to see data-source probe results
PASS scans 1 scheduled scan configs are collecting on time.
PASS drifts 1 event type(s) examined; no untriaged schema drift.
6 checks: 5 pass, 1 skip. Exit 0.
--strict deliberately does not promote skips: a project-scoped key would
otherwise fail every strict run forever, for a reason the operator cannot fix.
tripl status, tripl watch, tripl scans list and tripl drifts list also
need --project with such a key. None of them has a verdict contract, so they
simply fail with the API's own 403 message — extended with the hint "If this
key is scoped to a single project, that 403 is expected" and the flag to type.
Pass --project.
The commands that act on one object — scans jobs, scans run,
scans cancel, drifts dismiss, drifts reopen — require --project exactly once from
everybody, project-scoped key or not, so they never reach the instance listing
and never produce that 403.
Request cost
A healthy one-project instance costs 8 requests: /health, /auth/me,
/projects, /data-sources, then per project /scans and /event-types, then
one /jobs call per dispatchable scan config and one /drifts call per event
type. At most 6 requests are in flight at once — a diagnostic that saturates
the instance it is diagnosing reports on the load it created.
The retry backoff is not a hang
This deserves its own section because mistaking it for a stuck worker cost a real investigation.
After 3 consecutive failed scheduled runs, the dispatcher stops retrying at
the config's normal cadence and starts backing off exponentially: the delay is
interval × 2^(streak − 3), floored at one interval and capped by the smaller
of 8 intervals and 24 hours. From the outside this looks exactly like a
worker that has stopped — no new jobs appear for hours — which is why doctor
reports it as a distinct, warn-level finding that says so in words:
- warn: scan_backoff_active [prod] 'prod events'
The scheduler has deliberately deferred the next attempt to not before 2026-07-31T22:08:09Z (about 4h after the last failure): 3 or more consecutive failures trigger a backoff, so the worker is not stuck.
How long the deferral is, by interval and streak length:
| Consecutive failures | 15m | 1h | 6h | 1d | 1w |
|---|---|---|---|---|---|
| 1–2 | no wait | no wait | no wait | no wait | no wait |
| 3 | 15m | 1h | 6h | 24h | 7d |
| 4 | 30m | 2h | 12h | 24h | 7d |
| 5 | 1h | 4h | 24h | 24h | 7d |
| 6 and beyond | 2h | 8h | 24h | 24h | 7d |
A 1d or 1w config therefore never backs off past its own cadence — the floor
and the ceiling meet.
The CLI carries its own copy of the scheduler's constants, so the JSON key is
deferred_by_seconds_estimate and the printed sentence says "about". The rule
that produced the number ships alongside it as evidence.backoff_after, so a
reader can always see the arithmetic. Do not build an alert threshold on the
exact value.
What to do: nothing about the backoff itself. Fix the cause named by
scan_config_failing; the first successful run clears the streak and the
config returns to its normal cadence immediately.
tripl status
usage: tripl status [-h] [--url URL] [--api-key KEY] [--config PATH]
[--project SLUG] [--include-demo] [--json] [--days N]
[--timeout SECONDS]
A snapshot, not a verdict. It always exits 0 when it completed, however bad
the numbers are — a daily digest that returns non-zero is a cron job somebody
disables. Use tripl doctor when you want an exit contract.
tripl status - https://tripl.example.com (from $TRIPL_BASE_URL)
prod (Prod)
events 412 total, 388 active, 17 event types
scans 4 configured, 0 failing
signals 0 significant open
monitors 0 firing
coverage 91.4% over 7 days (388/425 matched)
mobile (Mobile)
events 98 total, 95 active, 6 event types
scans 2 configured, 1 failing
signals 3 significant open
monitors 1 firing
coverage 91.4% over 7 days (388/425 matched)
--days N (default 7, max 180) sets the reconciliation coverage window. Demo
projects are excluded unless --include-demo is given, exactly as in doctor.
Every count comes from the project summary the backend computes, not from a
second calculation in the CLI. signals is the same significant-open-signal
count as the app's badge and monitors the same firing count — they agree by
construction, not by coincidence. This matters: the "significant" threshold is
already defined in two places in this repository, and a third copy in the CLI
would be a drift waiting to happen.
Cost is one request for the project listing plus one coverage request per
project — 2 requests for a single-project instance, 3 for the two shown
above. With --project, the listing is replaced by one read per named slug.
status and doctor count failing scans differentlystatus prints the server-side rollup failing_scan_config_count; doctor
derives its own count by walking each config's job history. They normally agree.
If they ever disagree in the field, doctor's number is the one with the
evidence attached — read the scans findings.
tripl whoami
usage: tripl whoami [-h] [--url URL] [--api-key KEY] [--config PATH] [--json]
[--timeout SECONDS]
Answers "whose key is this, and where does it act?" with one read of
GET /api/v1/auth/me. Nothing is written.
tripl whoami - https://tripl.example.com (from $TRIPL_BASE_URL)
user: Deploy bot <deploy@example.com>
key: write, reaches the whole organization
org: acme (role: admin)
- user is the account that minted the key. A key acts as that account, with its role, in one organization.
- key is the key's access (
readorwrite) and its reach: the whole organization, or one project for a project-bound key. - org is the organization the key is bound to, and the account's role there. A key reaches only that organization, whichever others its account belongs to.
A project-bound key cannot read /auth/me at all (every route without a
project slug refuses it with 403). whoami reports that as the answer rather
than as a failure, and exits 0:
key: read, bound to one project
user: unknown (a project-bound key cannot read /auth/me)
Against an instance older than organizations the access is read off the key's
tk_r_ / tk_w_ prefix and org is unknown. A rejected key (401) exits 1.
--json prints one document with reach (instance or project), access,
user (id, email, name, or null), org, role, is_platform_admin
and orgs (the slugs of every organization the account belongs to). Cost: 1
request.
tripl watch
usage: tripl watch [-h] [--url URL] [--api-key KEY] [--config PATH]
[--project SLUG] [--include-demo] [--scan NAME_OR_ID]
[--interval SECONDS] [--duration SECONDS]
[--stall-after SECONDS] [--json] [--timeout SECONDS]
doctor answers "what is broken" at one instant and then exits. watch
answers "what is happening right now": it stays attached and prints one line
every time something moves — a replay advancing from chunk 4 to chunk 5, a job
finishing, a signal opening, an alert delivery failing to page anyone. It is the
command for the half hour after the page, when the question is no longer "is
something wrong" but "is this scan hung, or just slow?" — the exact question an
operator answered by hand over ssh for four days during the 2026-07-28..31
incident.
It reaches no verdict. Whatever it observes, a completed run exits 0 and it
never exits 3. A green watch run proves strictly less than a green doctor
run — watch only ever saw what fell inside its polls — so building a CI gate on
it would be a slower, flakier doctor --strict. Use doctor when you want an
exit contract.
tripl does have a Server-Sent Events stream, and watch deliberately does not
use it. The replay chunk counter — this command's headline — is written straight
into the scan job's result_summary by the worker's progress heartbeat, with no
realtime event published alongside it, so it is invisible on that bus. The
bus also degrades to silence when Redis is absent while the socket still looks
healthy. Polling the REST API is more transport for strictly more information.
| Flag | Meaning |
|---|---|
--project SLUG | Follow only this project. Repeatable. Required for a project-scoped key. |
--include-demo | Also follow demo projects, which are excluded by default. |
--scan NAME_OR_ID | Follow only these scan configs, matched exactly on name and then on id. Repeatable. Narrows the job lines only. |
--interval SECONDS | Seconds between polls, default 10, range 2–3600. |
--duration SECONDS | Stop after this many seconds and exit 0, range 1–86400. Default: run until Ctrl-C. |
--stall-after SECONDS | Report a running job whose progress has not moved for this long, default 120, range 10–86400. |
--json | JSON Lines on stdout, one object per event; every human line on stderr. |
--timeout SECONDS | Per-request timeout, default 10.0, range 0.1–600. |
--scan never filters signals or deliveries. A metric-scope signal carries no
scan config at all, so filtering the whole feed by scan config would make exactly
the anomalies an incident is built from disappear.
A session: a replay is already running when you attach, it advances, a signal
opens, an alert delivery fails, the replay finishes, and you press Ctrl-C.
tripl watch - https://tripl.example.com (from $TRIPL_BASE_URL)
Following 1 project, 1 scan config. Polling every 10s; signals,
deliveries and the scan listing at most every 30s. 1 request per tick,
4 per slow tick. Ctrl-C to stop.
prod (Prod)
running 'nightly replay' job job-91c2 (metrics_replay) chunk 3 of 18 (16.7%) collecting, started 2m ago
pending none
signals 0 open across all scopes (baseline); the project summary counts 0 as significant
deliveries 0 failed in the newest 20 (baseline)
2026-07-31T19:10:41Z watch.started 1 project, 1 scan config, poll 10s.
2026-07-31T19:10:51Z job.progress [prod] 'nightly replay' job job-91c2 chunk 4 of 18 (22.2%) collecting 2026-07-05T00:00:00Z..2026-07-06T00:00:00Z, 2m elapsed.
2026-07-31T19:11:01Z job.progress [prod] 'nightly replay' job job-91c2 chunk 5 of 18 (27.8%) collecting 2026-07-06T00:00:00Z..2026-07-07T00:00:00Z, 2m elapsed.
2026-07-31T19:11:11Z job.progress [prod] 'nightly replay' job job-91c2 chunk 6 of 18 (33.3%) collecting 2026-07-07T00:00:00Z..2026-07-08T00:00:00Z, 2m elapsed.
2026-07-31T19:11:11Z signal.opened [prod] 'nightly replay' project_total drop at 2026-07-31T19:00:00Z: actual 412 vs expected 1180 (z=-6.1).
2026-07-31T19:11:11Z delivery.failed [prod] 'Checkout drop' -> slack 'oncall' failed: 'channel_not_found'. Nobody was paged; delivery del-4f21.
2026-07-31T19:11:21Z job.finished [prod] 'nightly replay' job job-91c2 completed after 3m (18 of 18 chunks).
2026-07-31T19:11:21Z watch.stopped stopped (interrupted) after 40s, 5 ticks, 12 requests.
The layout is fixed: RFC 3339 timestamp, event token, [project], message, with
the message always starting at column 39. Like doctor, output is ASCII only
and byte-identical whether stdout is a terminal or a pipe — no colour, no
spinner, no redraw, no cursor control. That is not decoration for a follow mode;
it is the point. tripl watch | tee incident.log has to produce the artifact you
actually saw, and a live-updating dashboard would produce neither. The preamble
holds one more invariant worth knowing: no preamble line starts with a
timestamp and every stream line does, so
tripl watch | grep -E '^[0-9]{4}-'
is the event stream on its own.
The first screen
A follow mode that only reported transitions it personally observed would tell an operator who started it thirty seconds late that nothing is happening. So the first successful read of every stream seeds the state — and then the preamble prints what that seed found, before a single event line:
- the cadence and the request budget, so the load this command adds to an already-unwell instance is stated rather than guessed at;
- every running and pending job, with its mode and, for a replay, its current chunk, percentage, phase and age. This is the "is it hung?" answer at attach time;
- the open signal count across all scopes, marked
(baseline), printed beside the project summary's own significance-filtered count. Two different populations, both labelled — never one silently standing in for the other; - the count of failed deliveries in the newest 20, also
(baseline).
Signals are counted, not listed. A 200-signal incident would bury the stream
before it began; tripl status is the command for the list.
Everything in the preamble is therefore already known and is never re-emitted as an event. Subsequent silence means "no new ones", not "nothing wrong".
What counts as new
Each tick builds a fresh snapshot and diffs it against the previous one. There is no growing "everything I have printed" set, so memory is flat over an eight-hour run.
| Stream | Identity | Reported when |
|---|---|---|
| Jobs | The job id, within the newest 10 jobs of each followed scan config | status, replay_progress_phase, replay_chunks_completed or replay_current_chunk_index changed |
| Signals | (scan_config_id, scope_type, scope_ref) — the backend's own key | The identity is new, or its bucket, direction or state changed |
| Deliveries | The delivery id, within the newest 20 failed deliveries | The row is failed and was not already reported as failed |
Three consequences are worth stating outright:
updated_atis deliberately not part of a job's identity of change. The worker's progress heartbeat bumps it even on the ticks where no counter moves. Including it would print a line every heartbeat and, far worse, would makejob.stalledunreachable — a heartbeating-but-wedged job would look alive, which is precisely backwards.updated_atstill ships in the JSONdata.- A signal's bucket is not part of its identity. If it were, a signal
advancing to a later bucket would produce a
signal.clearedfollowed by asignal.opened— a fabricated resolution in the middle of a live incident. - A job disappearing is not an event. The job list is a window ordered newest first, so a row leaving it is an artifact of the window, not a thing that happened. A signal disappearing is reported, because the signals endpoint returns the complete active set with no window.
A scan config created mid-run is discovered on the slow clock and then seeds
silently, exactly like the ones present at startup. A project created mid-run
is not picked up — restart watch for that.
Event tokens
event is a stability contract; the message beside it is prose and may be
reworded in any release.
| Token | Stream | Emitted when |
|---|---|---|
watch.started | meta | Once, right after the first screen. Carries the resolved options and the baseline counts. |
watch.stopped | meta | Once, last. data.reason is interrupted, duration_elapsed or authentication_failed. |
job.queued | event | A job is pending — newly seen, or moved back into it. |
job.started | event | A job is running. Also fires for a job first seen already running. |
job.progress | event | A running metrics_replay job's chunk counter or phase moved. The line the incident needed. |
job.stalled | event | A metrics_replay job's published progress has not moved for --stall-after seconds. |
job.unchanged | event | A job with no progress channel has not changed status for --stall-after seconds — a pending job no worker has picked up, or a long-running metrics_collection. |
job.finished | event | completed. For a replay, the message carries the final chunk count. |
job.failed | event | failed. Carries error_message, or says there was none. |
job.cancelled | event | cancelled. |
signal.opened | event | A signal identity that was not in the previous active set. |
signal.updated | event | An already-open signal whose bucket, direction or state moved. |
signal.cleared | event | A signal left the active set. |
delivery.failed | event | An alert delivery is in status failed. Nobody was paged. |
poll.degraded | diagnostic | A read did not return 200, or a full window may have hidden rows. |
poll.recovered | diagnostic | A stream that had been failing answered again. |
A scheduled collection job — result_summary.mode == "metrics_collection" — has
no chunk counters, so it produces job.queued / job.started / job.finished /
job.failed and never job.progress. That is not a gap in watch: the backend
publishes no progress for those jobs, and watch does not invent progress it was
not given. job.progress is a metrics_replay line.
signal.cleared says "cleared", not "resolved"A signal leaving the active set may only mean its freshness window closed. The line claims exactly what was observed and nothing more, and it prints how many signals are still open in that project so a disappearance never reads as an all-clear.
job.stalled is a statement about observation
It says watch has seen no progress since T — never this job is broken. A replay chunk over a large window legitimately takes minutes.
Which is also why there are two tokens rather than one. Only a
metrics_replay job publishes a progress counter; a scheduled
metrics_collection job and a job still sitting in pending publish nothing
that could move short of their status. Reporting those as job.stalled would
claim an observation watch was never in a position to make — and, because such a
job's state cannot change while it runs, it would fire on every long
collection, which is a false alarm with a schedule. So they get
job.unchanged, whose message says what was actually seen: still in status
pending after 4m of watching (no worker has picked it up). That sentence is
the one you want when the queue is not draining, which is the most common way a
scan appears to hang.
Both tokens carry the same unchanged_since and unchanged_seconds evidence
and share the re-report schedule described next.
The threshold is counted in seconds, not polls, so --interval 60 does not
silently turn a 120-second threshold into a two-hour one. A condition that
persists re-reports on a power-of-two schedule — at 1x, 2x, 4x, 8x the
threshold — which is roughly a dozen lines over four hours instead of one every
tick, while still telling you it is still going. Any observed progress restarts
the clock, so there is no "unstalled" line to keep track of.
The schedule is easiest to see at a threshold far below the default, which is what produced the three lines below — 30s, then 60s, then 2m, at 1x, 2x and 4x:
tripl watch --stall-after 30
2026-07-31T19:11:11Z job.stalled [prod] 'nightly replay' job job-91c2 unchanged for 30s at chunk 5 of 18 (27.8%) collecting; watch has seen no progress since 2026-07-31T19:10:41Z.
2026-07-31T19:11:41Z job.stalled [prod] 'nightly replay' job job-91c2 unchanged for 60s at chunk 5 of 18 (27.8%) collecting; watch has seen no progress since 2026-07-31T19:10:41Z.
2026-07-31T19:12:41Z job.stalled [prod] 'nightly replay' job job-91c2 unchanged for 2m at chunk 5 of 18 (27.8%) collecting; watch has seen no progress since 2026-07-31T19:10:41Z.
When a poll fails
The run continues. A follow tool that exits when the thing it follows goes
down is exactly backwards — an operator watching an instance through a rolling
restart wants watch still there when it comes back, and exiting would truncate
a | tee incident.log capture at the worst possible moment.
A failed read does not update its stream's snapshot. Diffing against an empty
list would print signal.cleared for every open signal and then reprint them all
as signal.opened on recovery — lying twice, during the incident. Holding the
last good snapshot turns an outage into a reporting delay: the next good poll
fires every transition that happened while watch was blind, late but complete.
2026-07-31T19:11:11Z poll.degraded [prod] signals read failed: HTTP 500 on /projects/{slug}/anomalies/signals. Signal lines are suspended until it recovers - no signal lines does NOT mean no signals.
2026-07-31T19:11:21Z poll.degraded [prod] signals read failed: HTTP 500 on /projects/{slug}/anomalies/signals. Signal lines are suspended until it recovers - no signal lines does NOT mean no signals.
2026-07-31T19:11:41Z poll.recovered [prod] signals read recovered after 30s (3 failed polls); 1 events reported from the gap.
Note the arithmetic: three polls failed, two lines were printed. poll.degraded
follows the same power-of-two schedule as job.stalled — the 1st, 2nd, 4th, 8th
consecutive failure of a stream gets a line and the rest are quiet.
poll.recovered is never throttled, and it carries the number that matters:
how many events came out of the gap, which is how you learn that a quiet
stretch was watch being blind rather than the instance being calm.
Two more shapes of degradation:
- A 403 is only ever a line. It is legitimately per-project and per-role, and the other projects are still reporting usefully. If every project 403s you get a wall of lines naming the 403, and you can stop the run.
- A full window in which every row is new also raises
poll.degraded, withwindow_full: true. It means older rows may have been pushed out between polls; lower--intervalor narrow the run. Merely hitting the limit is not reported — a healthy config with ten jobs of history does that every poll and it means nothing.
A 401 is the one failure that ends the run. The key is gone and waiting
cannot fix it, and continuing would hammer an auth path that is logged and rate
limited. watch prints the poll.degraded line, then watch.stopped with
reason: "authentication_failed", then the usual tripl: ... error, and exits
1.
The run can also be refused before it prints anything: watch needs the scan
listing to know what to follow, so if GET /projects/{slug}/scans fails during
startup the command fails there rather than half way through a feed.
Signals, deliveries and the scan listing are normally polled at most every 30
seconds. That clock only advances on a successful read, so while one of them
is failing it is retried on every tick — three times the usual rate against an
instance that is, by assumption, already unwell. Raise --interval if you are
watching an instance through a sustained outage.
Request cost
Per tick: one request per followed scan config, asking for the newest
10 jobs. That window is deliberately far smaller than doctor's 200 —
doctor asks how long has this been broken and needs history, watch asks
what appeared since the last poll and needs recency, and 20x the payload on a
loop that repeats every ten seconds is a load problem this repository has already
paid for once.
On a slow tick — at most once every 30 seconds per project — three more
requests are added per project: the scan listing, the open signals
(expanded=true, so event-scope anomalies are not dropped), and the newest 20
failed alert deliveries. The 30 seconds is not invented: the backend caches
the unfiltered active-signals query for exactly that long, so polling faster is
provably wasted work.
At most 6 requests are in flight at once, and only one tick is ever in flight: the interval is measured after the previous tick finished, never on a fixed wall clock and never with catch-up. An instance that answers slowly is therefore automatically polled less, and a 40-second stall can never be followed by a burst of queued ticks.
watch refuses to start above 24 selected scan configs rather than
truncating, and says so with the flags to narrow it. A one-shot command can
report what its budget could not reach; a command that repeats would have to
reprint that warning every tick, or print it once at the top where it scrolls
away and leaves you reading a feed silently missing the config the incident is
in.
It produces poll.degraded lines with a 404, on the power-of-two schedule, until
you restart. This is deliberate: silently dropping a target is the exact failure
mode watch exists to avoid.
Write safety
doctor, status and watch only ever read. Six verbs do not, and this
is the one table to read before you run any of them:
| Command | What it changes on the instance | Key | Backing role | Asks first |
|---|---|---|---|---|
tripl scans run | Queues a scan job, which executes the config's stored SQL against your warehouse. | tk_w_ | editor or owner | No |
tripl scans cancel | Stops a pending or running job. A running job is not killed: it stops at its next checkpoint — a metrics run at the next chunk boundary, keeping the metrics it already wrote; a catalog run or an event-group apply immediately before its write commits, so stopped there it writes nothing at all. Stopped after that commit, the work it had already made durable stays. Either way a stopped job stays cancelled. | tk_w_ | editor or owner | Yes |
tripl drifts dismiss | Moves one schema drift to false_positive or snoozed, which takes it out of doctor's untriaged count. | tk_w_ | editor or owner | Yes |
tripl drifts reopen | Moves one schema drift back to open, and discards its resolution note and resolver. | tk_w_ | editor or owner | Yes |
tripl annotate | Adds one chart annotation with source api. The same label posted again with source api within 24 hours is de-duplicated: nothing new is created. | tk_w_ | editor or owner | No |
tripl docs push | Imports a folder of .md files into one scope of the docs catalog: creates and updates notes, and with --mirror deletes every note of the scope the folder does not carry. On a terminal it posts a dry_run=true preview first, which changes nothing. | tk_w_ | editor or owner; organization needs a key not bound to one project, and --mirror on organization is refused to every key | Yes |
Everything else on this page — doctor, status, watch, scans list,
scans jobs, drifts list, docs ls, docs cat, docs pull — is GET-only
and needs nothing but tk_r_.
The CLI does not judge your key
There is no local tk_r_ / tk_w_ check. The prefix is derived server-side
from the scope's first letter and says nothing at all about the backing user's
role, so a client-side gate would be a fourth copy of a backend rule and still
wrong for a tk_w_ key held by a viewer. The request goes out and the API's own
403 is printed, naming all three possibilities at once:
tripl: Forbidden (403): the API key lacks the required scope (tk_r_ keys cannot write), is scoped to a different project, or the backing user role is insufficient. API detail: API key has read-only scope
Read API detail: — it is the server's own sentence and it says which of the
three actually applied.
--dry-run is on all six
It resolves everything a real invocation would resolve — including turning a
<scan> name into a config id, which is where a typo becomes exit 2 — prints
the exact request, and sends nothing:
tripl scans run 'prod events' --project prod --dry-run
tripl scans run - https://tripl.example.com (from $TRIPL_BASE_URL)
dry run: would send POST /projects/prod/scans/scan-1/run with body None
Nothing was sent.
The printed request is method, path, params and body, and nothing else — no
headers, no Authorization, no API key. A credential that can be printed
eventually is, so it is excluded at the one place the request is projected for
printing. --dry-run also short-circuits the confirmation prompt, because there
is nothing to confirm.
with body {'action': 'false_positive'} and with body None are the repr of
the body, so quotes are single and null is None. Do not paste that into
curl. The --json document carries the same body as real JSON — use
tripl drifts dismiss ... --dry-run --json | jq .request if you want something
a machine can consume.
The confirmation rule
scans cancel, drifts dismiss, drifts reopen and docs push prompt. scans run and
annotate do not, and neither has a --yes at all — passing one is exit 2,
because a flag that does nothing here is a flag a script author will assume does
something on the next command too. The reasoning is that run executes SQL an
owner already authored, on a schedule that already runs it, and annotate adds
a marker that is cheap to delete from a pipeline that has nobody to ask;
cancel throws away work in flight, dismiss hides a finding from doctor,
reopen destroys the note that says why the finding was hidden, and docs push
overwrites notes, or deletes them under --mirror.
tripl scans cancel 'prod events' job-91c2 --project prod
Cancel job job-91c2 of prod 'prod events'? It stops at the next chunk boundary; metrics already written are kept. [y/N] y
tripl scans cancel - https://tripl.example.com (from $TRIPL_BASE_URL)
prod 'prod events' (scan-1): job job-91c2 is now cancelled.
The question goes to stderr, not stdout — input() would have put prose
inside the one document --json promises. Anything but y or yes — including
an empty line and end-of-file — aborts with tripl: aborted. Nothing was sent.
and exit 1, never 0: a script must never be able to read "the operator said
no" as "the mutation happened".
--yes is mandatory, not optionalWhen stdin is not a terminal and --yes was not given, scans cancel,
drifts dismiss, drifts reopen and docs push refuse: they print the question, name
--yes, exit 2 and send nothing.
tripl: Mark drift drift-1 of prod as false_positive? It stops appearing in `tripl doctor`'s untriaged count. Refusing to prompt because stdin is not a terminal. Re-run with --yes to confirm non-interactively. Nothing was sent.
Both halves of that rule are deliberate. Prompting would hang the cron job that
invoked it, forever; proceeding silently would make a piped invocation more
dangerous than a typed one, and would make --yes meaningless.
A 201 is not proof the scan started
scans run reads the job it gets back. The backend catches a broker failure
inside the trigger and still answers 201, with the job already in status
failed and an error_message on it — so a script that checked only the status
code would report a scan that never started as started. The CLI prints the
message and exits 1:
tripl: the job was created but is already failed: broker unavailable
What is deliberately not here
Two operations exist in the REST API and will not be added to this CLI.
-
A bounded metrics replay.
POST /projects/{slug}/scans/{scan_id}/metrics/replayis guarded byget_owner_user, which rejects any request carrying an API key scope — read or write, editor or owner. It is owner-session-only, so a Bearer-token client cannot reach it at all and atripl scans replaywould403every time.scans cancelships in its place. Whether an owner-roletk_w_key should be let through is an open backend question. -
Accepting a schema drift. On a
missing_fielddrift, accepting deletes the field definition from the event type — the exact damage doctor'sschema_field_deleted_by_acceptfinding exists to report. The tool that reports that damage must not be the easiest way to cause it, so accepting stays in the tripl app.dismisssendsfalse_positiveorsnooze,reopensendsreopen, and no flag on either reachesaccept.The API refuses one slice of that damage on its own: accepting a
missing_fielddrift for a column a scan config's event name format builds event names from answers409, because the delete would fail every subsequent collection. It carries aforceoverride for the case where that guard over-fires, andforcehas no flag here either — it is reachable only from a request body you write yourself, which is the point. See Schema drift.
tripl scans
doctor tells you a scan config is failing and watch follows one while it
runs. tripl scans is what you type in between: which configs exist and
would the scheduler ever pick them up, what has this one been doing, start it
now, stop the one that is stuck.
usage: tripl scans [-h] [--url URL] [--api-key KEY] [--config PATH] <verb> ...
Four verbs: list, jobs, run, cancel. A bare tripl scans with no verb
prints this group's help on stderr and exits 2 — the same rule a bare
tripl follows, for the same reason: a script that invoked a group with no verb
has a bug, and exiting 0 would let it look successful.
scans list and not list-scansA command acting on the instance as a whole is one word (doctor, status,
watch); a command acting on a class of objects is <plural-noun> <verb>. The
flat spelling was rejected because it adds a top-level entry per verb and has no
answer for the sixth.
tripl scans list
usage: tripl scans list [-h] [--url URL] [--api-key KEY] [--config PATH]
[--project SLUG] [--include-demo] [--json]
[--timeout SECONDS]
| Flag | Meaning |
|---|---|
--project SLUG | List only this project. Repeatable. Required for a project-scoped key. |
--include-demo | Also list demo projects, which are excluded by default. |
--json | One JSON document on stdout, every human line on stderr. |
--timeout SECONDS | Per-request timeout, default 10.0, range 0.1–600. |
tripl scans list - https://tripl.example.com (from $TRIPL_BASE_URL)
prod (Prod)
scan-1 prod events 1h event_time scheduled
scan-2 checkout funnel 15m event_time scheduled
scan-3 ad-hoc backfill - event_time not scheduled (no interval)
scan-4 legacy pageviews 1d - not scheduled (no time column)
4 scan configs in 1 project.
The columns are id, name, collection interval, time column, and why the
dispatcher would or would not select this config. That last one is the reason
the command is worth having: the scheduler's query needs an interval and a
time column, both set, so not scheduled (no time column) names a config that
is not failing — it is invisible, and nothing will ever collect for it. That is
the same condition doctor reports as scan_config_not_dispatchable, evaluated
by the predicate doctor itself uses to decide whose job history is even worth
reading — and the same one the web UI badges as Needs a time column.
base_query and roughly twenty tuning knobs are omitted. Free-text SQL runs
to kilobytes and answers no operational question; read the full configuration in
the app.
A failed read is a printed unavailable line, an errors[] entry in the JSON,
and exit 1 — never a shorter table at exit 0.
mobile (Mobile)
/projects/mobile/scans: unavailable (Forbidden (403): the API key lacks the required scope (tk_r_ keys cannot write), is scoped to a different project, or the backing user role is insufficient. API detail: Not authorized for project)
4 scan configs in 2 projects.
1 read failed; the list above is incomplete.
Note what the footer says: 4 configs in 2 projects, because the project that
403'd is still one of the two selected. The count is of what arrived, the
denominator is of what was asked for, and the line underneath is the one that
tells you they are not the same number — and it counts the failures, so one
403 out of six projects reads differently from six. It is the same sentence
drifts list prints for the same condition, deliberately: one grep over an
incident log finds both.
Cost: one request for the project listing (or one per named --project
slug), plus one per project.