Skip to content
API 14 min read

CLI (tc)

Hardened command-line client for ThreatCluster. Built for AI agents and humans. Install, authenticate, and query threat data from your terminal.

tc is the official ThreatCluster command-line client. It's designed primarily for AI agents to shell out to, and for humans who'd rather pipe JSON through jq than click around a UI.

The CLI is a thin wrapper over the public REST API: it doesn't add capabilities the API doesn't have, but it does add credential hygiene, bearer-token caching, scope downgrade for sub-processes, and structured error handling that you'd otherwise have to build yourself.

If you're integrating from a programming language other than Python (Go, JS, Rust), call the API directly. If you're writing shell scripts, AI agent tools, or working interactively, use tc.

Quick start

# 1. Install from PyPI
pipx install threatcluster-cli

# 2. Mint an agent key in the web UI
#    Settings → CLI → "Mint key" → copy the tc_agent_… value once

# 3. Log in (paste the key when prompted)
tc auth login

# 4. Use it
tc threats list --limit 5 | jq -r '.threats[].ai_title'
tc search "Volt Typhoon" --limit 10
tc darkweb ransomware victims --group lockbit

Package on PyPI: https://pypi.org/project/threatcluster-cli/

Install

Recommended: pipx

pipx install threatcluster-cli

pipx installs tc into an isolated Python environment and puts it on your PATH. If you don't have it: python3 -m pip install --user pipx && pipx ensurepath.

The package is at https://pypi.org/project/threatcluster-cli/ — pin a specific version with pipx install threatcluster-cli==<x.y.z> if you need reproducibility.

If you already have a Linux tc (the kernel traffic-control tool) at /usr/sbin/tc, make sure your PATH lists ~/.local/bin first:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
exec $SHELL
which tc                                     # → ~/.local/bin/tc

One-off: uvx

uvx tc threats list --limit 5

Useful in CI or for trying without committing to an install.

From source

If you're hacking on the CLI itself, install from a checkout:

git clone https://github.com/Jam0k/threatcluster-cli
pipx install ./threatcluster-cli
# or, for an editable install while iterating:
pipx install --editable ./threatcluster-cli

Shell completion

tc --install-completion bash    # or zsh, fish, powershell
exec $SHELL
tc thr<TAB>                     # → tc threats

Authentication

tc uses a two-tier credential model — see Agent keys & scopes for the full design rationale.

  • Refresh credential (tc_agent_… or legacy tc_live_…): long-lived, scoped, stored in your OS keyring. Never sent on a data request.
  • Bearer JWT: 15-minute token minted from the refresh credential. Sent on every API call. Cached in process memory only.

You only ever paste the refresh credential. The bearer is invisible.

tc auth login

tc auth login

Prompts you to paste a refresh credential (input hidden). Validates it by minting one bearer, then saves it.

Storage priority (CLI picks the first available):

  1. TC_REFRESH_TOKEN env var — for CI; bypasses storage.
  2. OS keyring — Keychain (macOS), SecretService (Linux desktop), Credential Manager (Windows). Encrypted at rest by the OS.
  3. ~/.config/tc-cli/credentials — 0600 file, current-uid only. Fallback when no keyring is available (headless Linux).

If the credential file's mode is anything other than 0600, or its owner isn't the current user, tc refuses to load it.

tc auth status

tc auth status            # short
tc auth status --verbose  # full diagnostics
{
  "api_url": "https://threatcluster.io",
  "authenticated": true,
  "bearer_expires_in": 900,
  "key_id": "tk_e5fdefc9",
  "org_id": null,
  "org_name": null,
  "scopes": ["darkweb:read", "entities:read", "feeds:read",
             "iocs:read", "threats:read", "vulns:read"],
  "storage": "keyring_or_file",
  "token_prefix": "tc_agent_fDr…"
}

--verbose adds bearer_jti, bearer_session_id, bearer_max_requests, and storage_detail (the storage backend class). Use it when something's misconfigured.

tc auth logout

tc auth logout                # hard kill: revokes server-side AND clears local
tc auth logout --keep-remote  # local-only clear (you're moving the key)

Default behavior calls POST /api/auth/agent/self-revoke so the credential dies everywhere — not just this machine.

Configuration

Environment variables (all optional):

Variable Default Purpose
TC_API_URL https://threatcluster.io Override API base. Plaintext http:// is rejected unless target is 127.0.0.1/localhost.
TC_REFRESH_TOKEN Refresh credential. Overrides keyring + file. Intended for CI.
TC_SCOPES (full set) Comma-separated subset to request when minting a bearer. Cannot exceed the refresh credential's scopes.
TC_SESSION_ID String identifier for per-session request budgeting.
TC_MAX_REQUESTS Cap requests per TC_SESSION_ID; subsequent requests get 429.
TC_PROPAGATE_AUTH 0 If 1, propagate TC_REFRESH_TOKEN to subprocesses. Off by default for safety.
TC_DEBUG 0 If 1, print redacted requests to stderr.

Output

All commands print JSON to stdout, errors to stderr.

tc iocs feed, tc iocs export, and tc threats stix may return plain text (IOC-per-line, STIX bundles), in which case the body is printed as-is so you can pipe it into grep, sort, or a SIEM ingest.

Commands

Top-level

Command Scope Purpose
tc auth login | status | logout Manage local credential
tc search <query> entities:read + threats:read Smart router across entities + threats
tc threats {list,get,iocs,stix} threats:read / iocs:read Threat clusters and their IOCs
tc iocs {feed,export} iocs:read Indicator feed and bulk export
tc entities {search,get,related,trending} entities:read Actors, malware, tools
tc vulns {list,get} vulns:read CVE list and detail
tc darkweb ransomware victims darkweb:read Ransomware leak-site victims
tc darkweb breaches darkweb:read Dark-web breach data
tc darkweb keyword-hits darkweb:read Dark-web keyword matches (Business tier)
tc feeds {list,get} feeds:read Your custom intel feeds
tc cluster open <id> Print + open a cluster's web URL

tc auth and tc cluster are local — they don't hit the API for data.

Stdin chaining (-)

Any command that takes a positional id/identifier accepts the literal - to read whitespace-separated ids from stdin. Useful for shell composition:

# IOCs for the top 5 trending threats
tc threats list --limit 5 \
  | jq -r '.threats[].cluster_id' \
  | tc threats iocs -

# STIX export of every "ransomware" cluster
tc threats list --query ransomware --limit 50 \
  | jq -r '.threats[].cluster_id' \
  | tc threats stix -

# CVE detail for everything in the past 24h
tc vulns list --since 24h \
  | jq -r '.cves[].cve_id' \
  | tc vulns get -

--watch (live feeds)

tc threats list, tc darkweb ransomware victims, tc darkweb breaches, and tc vulns list all support --watch. Each polls the endpoint on an interval, suppresses items it's already seen, and emits only new items as NDJSON (one JSON document per line). Ctrl+C to stop.

# Live ransomware feed into Slack
tc darkweb ransomware victims --watch --interval 60 \
  | while read line; do
      msg=$(echo "$line" | jq -r '"new victim: \(.group): \(.name)"')
      curl -s -X POST -d "{\"text\":\"$msg\"}" "$SLACK_WEBHOOK_URL"
    done

# Live CVE feed (slow-moving, 2-min default interval)
tc vulns list --watch --severity CRITICAL

# Watch threats matching a keyword
tc threats list --query lockbit --watch --interval 30

--interval is in seconds. Defaults: 60s for most commands, 120s for vulns.

The first poll seeds the dedupe set silently; subsequent polls emit only items not seen yet. So if you start --watch and there's nothing new, you see nothing.

tc search (smart router)

tc search <query> [--limit N] [--only entities|threats]

Calls both /entities/search and /threats?keyword=…, merges results into a common shape:

{
  "query": "Volt Typhoon",
  "count": 5,
  "results": [
    {"kind": "entity", "type": "apt_group", "value": "Volt Typhoon", "score": 4, "raw": {...}},
    {"kind": "threat", "id": "1796fc13-…", "title": "…", "score": 87.2, "summary": "…"}
  ]
}

Use --only entities if you want to disambiguate a name; --only threats if you want news/coverage only.

Other commands

Each subcommand has full --help:

tc threats list --help
tc darkweb ransomware --help
tc cluster open --help

Cookbook

Per-session containment for sub-agents

See Containment for agent loops below — the same TC_SCOPES + TC_SESSION_ID + TC_MAX_REQUESTS pattern is how you narrow a bearer for a subprocess.

Use in CI

GitHub Actions and GitLab CI can authenticate via OIDC instead of a long-lived secret. The CLI auto-detects CI; from your workflow:

- name: Query ThreatCluster
  run: |
    pipx install threatcluster-cli
    tc threats list --query "$KEYWORD" --limit 50 > threats.json
  env:
    TC_API_URL: https://threatcluster.io
    # OIDC handled automatically by the CLI; no secret in env

CI OIDC requires the server to be configured with allowlists for your repo — see Agent keys & scopes.

Pivoting between terminal and web

# Open a cluster in your browser
tc threats list --query "lockbit" --limit 1 \
  | jq -r '.threats[].cluster_id' \
  | tc cluster open -

# Or just print the URL
tc cluster open <cluster-id> --no-browser

tc cluster open accepts either a full cluster UUID or its 8-char short_id — full UUIDs are shortened automatically (last 8 hex chars, dashes stripped). The resulting URL points at https://threatcluster.io/cluster/<short_id>.

Bulk IOC export for SIEM

# Bulk JSON with confidence + reason metadata, last 7 days
tc iocs export --format json --hours 168 > tc-iocs.json

# Plain-text feeds, one IOC per line — drop into a blocklist
tc iocs feed --type ip   > ip-blocklist.txt
tc iocs feed --type hash > hash-feed.txt

# For a STIX 2.1 bundle, export per-cluster instead:
tc threats stix <cluster-id> > cluster.stix

tc iocs feed returns plaintext (one IOC per line) — drop straight into a firewall rule, SIEM blocklist, or grep. --type accepts all, ip, domain, hash, or email. --hours defaults to 720 (30 days). tc iocs export does not produce STIX; use tc threats stix per cluster.

Use with AI agents

tc is the supported way to give an AI agent access to ThreatCluster.

The CLI was designed for this case from the start: every command emits clean JSON, errors go to stderr with non-zero exit codes, scopes can be downgraded per task, and per-session request budgets cap blast radius if the agent goes sideways. There is no separate MCP server — the CLI is the agent surface.

Claude Code (and any shell-running agent)

The simplest path. Claude Code, Cursor, Aider, and any agent that can run a shell command can use tc directly. Authenticate once on the machine, then let the agent invoke commands as needed:

# One-time setup on the agent's host
pipx install threatcluster-cli
tc auth login          # paste a refresh credential

# The agent now runs commands like any other tool
tc threats list --query "lockbit" --limit 10
tc darkweb ransomware victims --days 1
tc iocs feed --type hash

If you want the agent to discover what's available, point it at this page or let it run tc --help, tc threats --help, etc. — the help text is short and machine-readable.

Mint a per-agent key

Don't share a personal credential with an agent. Mint a dedicated one:

  1. Settings → CLI → Mint key
  2. Tick only the scopes the agent needs (e.g. threats:read, iocs:read, leave darkweb:read off if not needed).
  3. Give the key a name that identifies the agent (claude-code-soc-bot).
  4. On the agent's host: TC_REFRESH_TOKEN=tc_agent_… tc auth login, or paste it into tc auth login manually.
  5. If the key leaks, revoke it from the same Settings page — the agent stops working within ~15 minutes (bearer TTL).

See Agent keys & scopes for the full scope reference.

Containment for agent loops

The two env vars below cap what a compromised or runaway agent can do, even if it has a valid credential. Use both in CI and in any long-lived agent:

# Per-task scope downgrade — child cannot escalate
TC_SCOPES=threats:read,iocs:read \
TC_SESSION_ID="agent-task-$(uuidgen)" \
TC_MAX_REQUESTS=100 \
  tc threats list --limit 5

What this gets you, in order:

  • Scope downgrade: bearer is minted with the subset of scopes you specify. The server rejects scopes the parent credential doesn't have, so the agent cannot upgrade itself to admin:write.
  • Session budget: the next 100 requests under this session id will succeed, the 101st gets HTTP 429. Caps the damage of a tight loop or prompt-injection scrape.
  • 15-minute bearer TTL: even without the budget, a stolen bearer dies fast.

Wrapping tc as a custom tool

If your agent framework expects "tools" with typed schemas (Claude API tool use, OpenAI function calling, LangChain), wrap CLI calls into one tool per intent — don't expose raw tc argv to the model. A small Python adapter:

import json, subprocess

def search_threats(query: str, limit: int = 10) -> dict:
    """Search ThreatCluster for clusters matching `query`."""
    out = subprocess.run(
        ["tc", "search", query, "--limit", str(limit)],
        capture_output=True, text=True, check=True,
        env={"TC_SCOPES": "threats:read,entities:read",
             "TC_MAX_REQUESTS": "20",
             "TC_SESSION_ID": f"search-{query[:20]}",
             **dict(__import__("os").environ)},
    )
    return json.loads(out.stdout)

Each tool sets its own TC_SCOPES minimum and a small TC_MAX_REQUESTS cap. The model never picks scopes — your code does.

MCP / Claude Desktop

ThreatCluster previously shipped a hosted MCP server at /mcp. It has been retired; agents now use this CLI instead. If you specifically need MCP for Claude Desktop, you can build a thin local MCP server that wraps tc calls (the mcp Python SDK takes ~50 lines for this). We don't ship one in-tree — if there's demand, open an issue.

Security model

Designed for the case where an AI agent shells out to tc. Threats considered:

  • Credential theft from disk: keyring is encrypted by the OS; the file fallback is 0600 + uid-checked.
  • Credential theft from process table: --api-key flag is rejected — argv leaks via /proc/<pid>/cmdline. Pass via env or storage only.
  • Credential leak via subprocess inheritance: TC_REFRESH_TOKEN is scrubbed from child env unless TC_PROPAGATE_AUTH=1 is set explicitly.
  • Credential leak via logs: the HTTP client strips Authorization and X-API-Key from any debug log output (verified by a unit test).
  • Plaintext interception: http:// URLs are refused unless the host is 127.0.0.1 or localhost.
  • Prompt-injection scraping: a compromised agent with a valid bearer can still scrape data within its scopes. Mitigations:
    • Mint scoped bearers per task: TC_SCOPES=threats:read tc ….
    • Cap per-session requests: TC_MAX_REQUESTS=50 TC_SESSION_ID=foo tc ….
    • 15-minute bearer ttl bounds damage even without a budget.

Troubleshooting

could not reach <url>: ConnectError: Name or service not known

TC_API_URL is unset or wrong. Default is https://threatcluster.io.

export TC_API_URL=http://localhost:8000   # local dev
export TC_API_URL=https://threatcluster.io   # prod (default)

refusing plaintext HTTP to non-loopback host

You set TC_API_URL=http://something.com. Plaintext is only allowed to loopback. Use https:// or set the URL to http://127.0.0.1:….

<file> has insecure mode 0o644; run chmod 0600 on it or delete and re-login

The credential file's permissions widened (often by another tool). Either:

chmod 0600 ~/.config/tc-cli/credentials
# or
tc auth logout && tc auth login

HTTP 403: insufficient_scope

Your key was minted without the scope this command needs. Mint a new key in Settings → CLI with the required scope ticked, or call tc auth login to replace your local credential.

HTTP 403: scope_elevation_rejected

You set TC_SCOPES=foo:read where foo:read isn't on your refresh credential. Either remove the env var or mint a new key with foo:read included.

HTTP 429: session_budget_exceeded

You hit the TC_MAX_REQUESTS cap for this TC_SESSION_ID. Mint a new bearer (any new tc invocation will do, with a fresh TC_SESSION_ID or none).

HTTP 401: bearer expired

Bearers live 15 minutes. The CLI refreshes them automatically; if you see this error, the clock skew between client and server is >30 seconds. Sync NTP.

tc runs the kernel tc (Linux traffic control)

which -a tc
# /usr/sbin/tc      ← kernel tool (wrong)
# /home/james/.local/bin/tc   ← ours

Reorder PATH so ~/.local/bin is first:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
exec $SHELL

Versioning

threatcluster-cli follows SemVer. Major-version bumps will be called out in cli/CHANGELOG.md and announced in release notes. Minor and patch versions are backward-compatible.

The CLI talks to a stable URL prefix (/api/public/v1/*); breaking server changes get a new prefix, not a quiet schema bump.

See also