Skip to main content

Use the CLI

Install​

uv tool install tarnished-cli==0.3.13

See the CLI README for source installation and the command reference.

Authenticate​

Create or rotate an API key in the Tarnished web app first, then initialize the CLI locally:

tarnished auth init --api-key 'your-api-key'

Verify the local setup:

tarnished auth doctor
tarnished auth whoami

Connection and profiles​

Configuration is stored in config.json under --config-dir, then TARNISHED_CONFIG_DIR, then $XDG_CONFIG_HOME/tarnished (or ~/.config/tarnished). For example:

{
"default_profile": "work",
"profiles": {
"default": { "base_url": "http://127.0.0.1:5577", "output": "json" },
"work": { "base_url": "https://tarnished.example.com", "output": "json" }
}
}

--profile selects a named profile; when omitted, default_profile is used. For that selected profile, the server URL precedence is --base-url, then TARNISHED_BASE_URL, then the profile's base_url. Output precedence is --json, then TARNISHED_OUTPUT (json or text), then the profile's output. Flags and environment overrides apply only to the current invocation; they do not rewrite configuration or unrelated profiles. An unknown profile starts with built-in defaults, so auth init can initialize its credentials.

Credentials remain local to the selected profile/config directory. An explicit TARNISHED_API_KEY overrides stored credentials; server-side scopes still apply. Choose a trusted server when overriding its URL: the CLI uses the selected key at that server. URL overrides do not migrate or bind credentials to a new origin.

tarnished --profile work --base-url https://tarnished.example.com auth status

The CLI follows bounded same-origin HTTP redirects (same scheme, host and port), including API trailing-slash redirects. Cross-origin redirects, HTTP/HTTPS changes, URLs containing user credentials or the configured API key, redirect loops, and unresolved redirects are unsupported. Configure the final server URL instead. No request or API key is sent to a rejected redirect target; TLS verification remains enabled. Requests are not automatically retried after failure.

Connection, timeout, TLS, protocol, transfer and invalid JSON responses fail with exit code 1, rather than a traceback. With JSON output, errors go to stdout as JSON (error, plus status_code/details for HTTP errors); text errors go to stderr. auth doctor keeps its diagnostic report and exits 1 when unhealthy. Successful empty JSON responses remain null; binary downloads remain binary files. Downloads replace the destination only after the full transfer and local write succeed, so failure or interruption does not overwrite an existing file.

Waiting for imports​

import run --wait ends successfully on complete and fails promptly on failed or cancelled, retaining the server's failure explanation. Both --poll-interval and --timeout-seconds must be finite and greater than zero; invalid values are rejected before starting an import. The monotonic wait timer starts after submission, sleeps are capped to its remaining time, and polling requests use at most the remaining time as their HTTPX I/O timeout. This is not a hard wall-clock cancellation of an in-flight transfer. A local timeout or interruption does not cancel the server's import; inspect it with import status.

Capture job leads without AI​

Save a body file such as {"url":"https://example.com/job","text":"Optional posting text"}:

tarnished --json job-leads save --body-file capture.json
tarnished --json job-leads get "$LEAD_ID"
tarnished --json job-leads edit "$LEAD_ID" --body-file corrections.json
tarnished --json job-leads extract "$LEAD_ID" --expected-revision 1
tarnished --json job-leads retry "$LEAD_ID" --expected-revision 3
tarnished --json job-leads convert "$LEAD_ID"

save and its existing alias create only persist the URL and supplied source; no page fetch, AI configuration, or provider call is needed. pending means saved / not extracted, not queued work. Preserve the returned id before any later step. Duplicate saves return the owner's existing ID in details.detail.id.

Input limits are characters, not bytes: URL 2,048, text 100,000, optional legacy HTML 500,000. HTML preprocessing reads at most its first 100,000 characters. The retained, untrusted text snapshot is at most 50,000 characters including the truncation notice. Inspect source_text, source_truncated and content_warning; source previews in the web app are escaped text, not raw HTML.

For edits, corrections.json might contain {"expected_revision":0,"company":"Example","title":"Engineer","location":null,"skills":[]}. Use the current revision returned by GET/save/edit, not the illustrative numbers above. Omitted fields remain unchanged, null clears scalars, and [] clears lists. Only explicitly supplied business fields become protected manual corrections; future extraction will not overwrite them. URL, source snapshot, ownership and processing/conversion state cannot be edited. The server checks merged ranges. Company and title suffice for conversion without AI; repeated conversion returns the same application, retaining the initial evidence and time zone.

extract and retry are explicit provider actions and may cost money. They require --expected-revision; no hidden reads or automatic retries occur. A processing state can represent an interrupted request, not durable background execution. First inspect the lead. Only if intentionally replacing an uncertain attempt, add --restart-processing; its previous call may still finish or have been billed, and replacement can repeat paid work.

Extraction/edit/conversion errors retain the known lead id in JSON even on a transport timeout, with the existing error and optional HTTP status_code / details envelope and exit 1. Inspect the same lead before any retry. A failed external action does not erase saved source or identity. A 409 needs a reload and review of current values, never an automatic revision rebase or a new save.

Preferences and time zone settings​

The CLI can inspect or update the same dashboard feature and time zone preferences used by the web app.

Example body file:

{
"time_zone_mode": "manual",
"time_zone": "Europe/Belgrade",
"show_heatmap": true
}

Apply it with:

tarnished preferences update --body-file preferences.json
tarnished preferences get

Device mode behavior​

If the stored preference mode is device, the CLI uses the local machine time zone as request-time context for the day-sensitive commands that need local-day interpretation.

That means the CLI can mirror the web UI's local-day behavior for surfaces such as:

  • streak state
  • dashboard KPI windows
  • needs-attention buckets
  • analytics time windows

The CLI does not silently write the machine time zone back into Tarnished just because it can detect it.

Current command areas​

The CLI currently includes command groups for:

  • auth
  • admin
  • applications
  • job leads
  • profile
  • statuses
  • round types
  • rounds
  • user settings
  • preferences
  • export
  • import
  • dashboard
  • analytics
  • reports
  • transcriptions

Important behavior​

The CLI is intentionally API-key-first.

It does not manage remote API keys on your behalf. The web app remains the source of truth for API key creation, rotation, and revocation.

Recorded evidence and scoped analytics​

Status meaning is independent of its name. Meanings are unknown, applied, screening, interviewing, offer, accepted, rejected, withdrawn, no_reply. The first four non-unknown stages are active; the final four are closed. No Reply is user-marked closed-unanswered, not every waiting application. Changing a definition does not rewrite application or history snapshots.

Read an application's evidence_revision before corrections. Use its exact status ID, not a name match: global and personal definitions can share a label with different meanings. An omitted field stays unchanged; explicit response_evidence: null clears the entire response fact. {} records a substantive response of unknown occurrence date, with server recording time. A receipt, ordinary status change or round alone does not record a response.

Runnable body files are in cli/examples/evidence/ in the checkout. These are synthetic examples, not instructions to modify a real account. Replace the IDs and example revision 0 with the current owner's actual record/revision. A stale revision returns 409: reread and reconcile rather than blindly retrying.

# Read-only evidence and deterministic calculations (no model calls):
tarnished --json applications get "$APP_ID"
tarnished --json applications history list "$APP_ID"
tarnished --json analytics pipeline --period all --as-of 2026-01-07T09:00:00Z
tarnished --json analytics activity --period all --as-of 2026-01-07T09:00:00Z
tarnished --json analytics sankey --period all --as-of 2026-01-07T09:00:00Z

# Explicit owner-scoped corrections; run only with intended write authority:
tarnished applications update "$APP_ID" --body-file cli/examples/evidence/response-record.json
tarnished applications update "$APP_ID" --body-file cli/examples/evidence/response-clear.json
tarnished applications history correct "$APP_ID" "$EVENT_ID" --body-file cli/examples/evidence/history-correction.json
tarnished applications correct-meaning "$APP_ID" --body-file cli/examples/evidence/current-meaning.json

Add status_id to a response-record body to record an explicit employer outcome and its transition atomically. Notes and occurrence dates are optional. History correction changes only the supplied meaning or offset timestamp of one event; it cannot certify untouched legacy fields or move an event across neighbouring boundaries. Current-meaning correction does not reconstruct history. History delete supports --expected-revision N --yes and leaves a content-free gap.

analytics kpis, pipeline, activity, weekly, sankey, interview-rounds and dashboard kpis / needs-attention accept --period and --as-of. Results carry applied-date cohort, timezone, denominator and as-of. Current classification is separately labelled live/observed-now. Ever interview/offer counts use distinct applications with evidence, not current counts or round counts. Visits preserve repeats; unknown intervals and duration coverage remain explicit. Ten applications with four responses, two ever interviewed and one ever offered yield 40%, 20%, 10%; zero denominator means unavailable.

Advice is separate: analytics insights --body-file cli/examples/evidence/insights.json is an explicit provider-generation POST, requiring analytics:read and analytics:generate. Read-only presets do not gain generation authority. Configure the provider and review its cost before running it. In-session edits invalidate it without automatic generation; external edits are not continuously monitored.

Grounded reports (interview, application, pipeline)​

Three persisted report scopes are available. Reading a report never contacts a model provider; requesting one is an explicit generation job that requires the same authority as generation above (analytics:generate plus the read scopes) and an enabled openai/ text configuration. Without a configured text model the request fails with HTTP 503 and no paid call is made.

# Read the latest stored report per scope (no provider call):
tarnished --json reports interview get "$ROUND_ID"
tarnished --json reports application get "$APP_ID"
tarnished --json reports pipeline get --period 30d

# Request a report explicitly; replace IDs and the example revision with the
# current values from the read above:
tarnished --json reports interview request "$ROUND_ID" --body-file cli/examples/evidence/report-intent.json
tarnished --json reports application request "$APP_ID" --body-file cli/examples/evidence/report-intent.json
tarnished --json reports pipeline request --body-file cli/examples/evidence/report-intent-pipeline.json

A report is marked stale when its evidence or configuration changes; re-read it instead of assuming freshness. Deleting a cited source clears the retained report content. Pipeline reports use the effective time zone, so pass the same --as-of and time zone to compare them with analytics pipeline.

Document text and transcripts are readable and editable from the CLI:

tarnished --json applications cv text "$APP_ID"
tarnished --json applications cover-letter text "$APP_ID"
tarnished --json applications cv paste-text "$APP_ID" --body-file cli/examples/evidence/document-text.json
tarnished --json rounds transcript get "$ROUND_ID"
tarnished --json rounds transcript paste "$ROUND_ID" --expected-generation 0 --body-file cli/examples/evidence/transcript-paste.json
tarnished --json rounds transcript edit "$ROUND_ID" --expected-generation 1 --body-file cli/examples/evidence/transcript-edit.json

Document paste and transcript writes require the files:write scope in addition to the read scopes. Transcript corrections must retain segment IDs, order and source timestamps; supply the --expected-generation you read. A stale value returns 409: reread and reconcile rather than blindly retrying.

Speech transcription jobs​

Transcription runs on the installation's configured speech service. Starting a job sends the recording to that service, so it requires files:write; status reads require only files:read and rounds:read, and reads never dispatch work.

tarnished --json transcriptions capabilities
tarnished --json transcriptions list "$ROUND_ID"
tarnished --json transcriptions get "$JOB_ID"
tarnished --json transcriptions start "$ROUND_ID" "$MEDIA_ID" --speech-configuration-revision "$REV"
tarnished --json transcriptions retry "$JOB_ID"

Read configuration_revision from transcriptions capabilities and pass it when starting, so the job is bound to the configuration you reviewed. Every terminal state is reported honestly (complete, failed, interrupted, invalidated), including the sanitized error. A retry may repeat a request whose outcome is unknown, so reuse --intent-id when repeating an ambiguous attempt.

Report and transcription requests can wait for a bounded time instead of polling yourself:

tarnished reports pipeline request --body-file cli/examples/evidence/report-intent-pipeline.json --wait
tarnished transcriptions start "$ROUND_ID" "$MEDIA_ID" --speech-configuration-revision "$REV" --wait

--wait polls with a finite --timeout-seconds (default 300) and a --poll-interval (default 1), and exits non-zero on a terminal failure or timeout. It never loops forever.