SaveThat CLI Guide
Use SaveThat API keys to access your bookmarks from a terminal.
1) Create an API key
In the app:
- Open profile menu in the sidebar.
- Click
API Keys. - Click
New API key. - Set a label (for example
MacBook CLI) and create. - Copy the key immediately (it is shown once).
Key format:
savethat_<id>_<secret>
2) Set environment variables
export SAVETHAT_BASE_URL="https://savethat.app"
export SAVETHAT_TOKEN="savethat_<id>_<secret>"
3) Install CLI
npm install -g @savethat/cli
# or run without installing globally:
npx @savethat/cli --help
4) Run commands
savethat --version
savethat doctor
savethat update --dry-run
savethat whoami --json
savethat account --json
savethat bookmarks list --limit 10 --json
savethat bookmarks search "design systems" --source twitter --limit 10 --json
savethat bookmarks get <bookmarkId> --include analysis,content --json
savethat bookmarks similar <bookmarkId> --limit 10 --threshold 0.7 --json
# Integration connectivity snapshot from account metadata
savethat account --json | jq '.data.account.sourcesConnected'
Full command reference
# Global
savethat [--base-url <url>] [--token <token>] [--json] <command>
savethat --version
savethat doctor [--json]
savethat update [--dry-run]
# Auth/account
savethat whoami
savethat account
# Bookmarks
savethat bookmarks list [--limit <n>] [--cursor <n>] [--collection "<name>"] [--collection-id <id>] [--tag <tag>] [--domain <domain>] [--status ready|processing|partial|failed] [--after YYYY-MM-DD] [--before YYYY-MM-DD] [--important] [--source raindrop|twitter] [--sort latest|oldest] [--format json|csv|md]
savethat bookmarks search "<query>" [--limit <n>] [--cursor <n>] [--collection "<name>"] [--collection-id <id>] [--tag <tag>] [--domain <domain>] [--status ready|processing|partial|failed] [--after YYYY-MM-DD] [--before YYYY-MM-DD] [--important] [--source raindrop|twitter] [--sort latest|oldest] [--format json|csv|md]
savethat bookmarks get <bookmarkId> [--include analysis,content,media,embedding] [--format json|csv|md]
savethat bookmarks similar <bookmarkId> [--limit <n>] [--threshold <0-1>]
savethat bookmarks collections [--search <query>] [--limit <n>] [--format json|csv|md]
savethat bookmarks schema [--format json|csv|md]
# Agent
savethat agent ask "<query>"
# Skill
savethat skill print
savethat skill install [--all] [--cursor] [--claude] [--hermes] [--openclaw] [--path <path>] [--force]
Behavior notes:
--collectionis partial collection-title matching.--collection-idis exact numeric matching.--cursoris offset pagination; usenextCursorfrom response.--tagcurrently accepts one exact tag per request.bookmarks searchsupports the same filters asbookmarks list.--includesupportsanalysis,content,media,embedding.doctoris read-only. It checks for newer CLI versions and suggestssavethat update.updaterunsnpm i -g @savethat/cli@latest; use--dry-runto print the command without running it.
Relay v1 response field contract
All Relay endpoints return the standard envelope:
{ "success": true, "schemaVersion": "1.0", "data": {}, "meta": { "requestId": "..." } }
The CLI strips the envelope for bookmarks list/search/get/similar/collections on success and
prints data directly. The shapes below describe data. Fields not listed below are not part of
the v1 contract — do not depend on them.
GET /api/relay/v1/bookmarks (list / search)
data: { items: Bookmark[], nextCursor?: number, totalCount: number }
nextCursor is offset-based and absent on the last page. totalCount is only counted on the
first page (cursor=0 or omitted) and reads 0 on subsequent pages.
Bookmark (list shape):
| field | type | notes |
|---|---|---|
id | string | bookmark id |
title | string | null | display title |
link | string | null | source URL |
excerpt | string | null | extracted excerpt |
note | string | null | user note |
collectionId | number | null | resolved collection id (-1 Unsorted, -99 Trash) |
collectionTitle | string | null | display title; Trash for -99, otherwise Unsorted when blank |
important | boolean | normalized from int 0/1 in DB |
source | "raindrop" | "twitter" | null | bookmark source |
domain | string | null | lowercased hostname extracted from link |
status | "ready" | "processing" | "partial" | "failed" | derived (see below) |
created | string (ISO 8601) | created_at |
updated | string (ISO 8601) | updated_at |
tags | string[] | attached tag strings |
summary | string | null | latest AI summary (final_summary) |
twitterDirectVideoUrl | string | null | Twitter only: inline MP4 playback URL (prefer R2 cached, else crawl). null if N/A. |
status derivation (list/search):
failed—summary_status = 'failed'ORcrawl_status = 'failed'processing—summary_status = 'pending'(or no analysis/embedding signals at all)ready—summary_status ∈ {'generated','validated'}AND has embeddingpartial— has summary OR has embedding (but not both)
Fields produced by the internal mapper but not exposed in v1 list/search:
cover, isCachedCover, cachedCoverWidth, cachedCoverHeight, collectionCover, sort,
source_id, summary_status, summary_generated_at, ai_model_used, hasAnalysis,
hasEmbedding, twitterDirectVideoByMediaIndex, highlights.
crawl_status exists only server-side for list status; it is not returned on the bookmark JSON.
GET /api/relay/v1/bookmarks/search (ranked search with a retrieval mode)
Use this when the client needs meaning-based results. GET /api/relay/v1/bookmarks?search= stays a
plain SQL text filter; this route runs the same hybrid, keyword or semantic retrieval that the MCP
search_bookmarks tool uses, so native clients, the CLI and chat apps rank the same way.
Query params: q (required, 1 to 2000 chars), mode (hybrid default, keyword, semantic),
plus the list filters limit, cursor, collectionId, collectionName, domain, status,
after, before, tag, important, source.
data: { items: SearchHit[], nextCursor?: number, totalCount: number, requestedMode, modeUsed, semanticStatus, indexCoverage: { eligible, indexed, compatible }, threshold, relaxedThreshold, message, ... }
SearchHit is the compact MCP shape, not the list Bookmark:
| field | type | notes |
|---|---|---|
id | string | bookmark id; call GET /bookmarks/:id for the full record |
title | string | null | display title |
link | string | null | source URL |
snippet | string | summary, excerpt or note, trimmed to 240 chars |
source | "raindrop" | "twitter" | null | bookmark source |
collectionId | number | null | resolved collection id |
collectionTitle | string | null | display title |
hasAnalysis | boolean | AI summary exists |
hasEmbedding | boolean | indexed for semantic search |
matchType | "keyword" | "semantic" | "hybrid" | how this hit was found |
similarity | number | cosine similarity, only on semantic or hybrid hits |
totalCount counts the retrieved candidate window (at most 100 per mode), not the whole library.
semanticStatus explains degraded runs (no_embeddings, model_mismatch, rate_limited,
temporarily_unavailable, candidate_limit); in hybrid mode modeUsed falls back to keyword
when semantic retrieval is unavailable. Semantic and hybrid requests spend query embeddings and are
rate limited per user (20 per minute, 500 per day); a rate_limited status is not an error.
GET /api/relay/v1/bookmarks/:id
data:
bookmark— listBookmarkshape (incl.twitterDirectVideoUrl) PLUShighlights: HighlightItem[](Raindrop only,[]when none).tags(string[]) — duplicate ofbookmark.tags, kept for back-compat. Preferbookmark.tags.analysis(wheninclude=analysis):summary(string \| null) —final_summaryfalling back tosummary.status(string \| null) — rawsummary_status(pending|generated|validated|failed).
embeddings(wheninclude=embeddingsorinclude=embedding):available(boolean)vector(number[] \| null) — 2000-dim normalized vector (Geminiembedding-001).
content(wheninclude=content):type,text,length(mapped fromcontent_type/content_raw/content_length).language— alwaysnullin v1 (reserved).videoDuration(number \| null) — seconds; populated for video sources.
media(wheninclude=media):cover(string \| null) — cached cover preferred when available.width,height(number \| null).
HighlightItem: { id: string, text: string, note?: string, color?: string, created?: string }.
bookmark.status derivation (getById uses hasAnalysis && hasEmbedding only — it does not
consult crawl_status):
ready— both presentpartial— exactly one presentprocessing— neither
Discrepancy to be aware of:
getById(and similar) do not emit"failed"because they do not considercrawl_status. List/search does. Treat list"failed"as a stricter signal than detail"processing", not a synonym for it.
Internal fields not exposed: cover (use include=media), isCachedCover, source_id,
summary_status, summary_generated_at, ai_model_used,
twitterDirectVideoByMediaIndex, collectionCover, sort, hasAnalysis, hasEmbedding.
GET /api/relay/v1/bookmarks/:id/similar
data:
bookmarkId(string) — input id, echoed.similar— array of listBookmarkshape (incl.twitterDirectVideoUrl) PLUSsimilarity(number, cosine similarity in[0, 1], rounded to 3 decimals). Nohighlights.count(number) —similar.lengthafter filtering.
status derivation matches getById. No "failed" emitted.
GET /api/relay/v1/bookmarks/collections
data: { items: Array<{ id: number, title: string|null, source: string|null, bookmarkCount: number }>, count: number }.
GET /api/relay/v1/whoami
data: { userId: string, scope: "read", tokenId: string }.
GET /api/relay/v1/account
data: { account: AccountStatusSnapshot }. The snapshot mirrors the in-app account view (tier,
quota, monthly cycle, sourcesConnected, etc.) and is intentionally not pinned at the field level
in v1 — explore via savethat --json account | jq .data.account.
Get bookmark details:
savethat bookmarks get <bookmarkId> --include analysis,content,embedding --json
Find similar bookmarks:
savethat bookmarks similar <bookmarkId> --limit 10 --threshold 0.7 --json
Ask the agent:
savethat agent ask "What have I saved about pricing strategy?" --json
agent ask scope:
- Runs across your account bookmark corpus.
- Does not currently support source/tag/collection filter flags.
5) Revoke keys
In API Keys, revoke any key you do not need anymore. Revocation is immediate.
6) Harness / Agent Skill Guide
If you are wiring SaveThat into an AI harness, use the detailed skill guide:
.agents/skills/savethat-cli/SKILL.md.claude/skills/savethat-cli/SKILL.md.hermes/skills/savethat-cli/SKILL.mdskills/savethat-cli/SKILL.mdpackages/bookmark-cli/SKILL.md(npm-package companion guide)
You can also install the bundled skill file into your current workspace directly from the CLI:
# installs to all supported workspace skill locations
savethat skill install
Useful variants:
# Print bundled skill markdown
savethat skill print
# Install only selected targets
savethat skill install --claude --cursor
savethat skill install --hermes
savethat skill install --openclaw
# Install to custom location
savethat skill install --path ".agents/skills/my-team-skill/SKILL.md"
Default install targets:
--cursor:.agents/skills/savethat-cli/SKILL.md--claude:.claude/skills/savethat-cli/SKILL.md--hermes:.hermes/skills/savethat-cli/SKILL.md--openclaw:skills/savethat-cli/SKILL.md
It includes:
- command selection patterns (
whoami->account->bookmarks->agent) - JSON-first parsing patterns with
jq - retry and backoff behavior by exit code
- security guidance for token handling and rotation
Exit codes
0: success2: unauthorized/forbidden (401/403)3: rate limited (429)4: validation/not found (400/404)1: other error