Personal link database with hybrid search. Save links, search later.
All endpoints require a Bearer token in the Authorization header.
Authorization: Bearer YOUR_API_TOKEN
Generate a token at links.rip/users/settings
and store it in the LINKS_RIP_TOKEN environment variable.
export LINKS_RIP_TOKEN="your-token-here" curl -H "Authorization: Bearer $LINKS_RIP_TOKEN" https://links.rip/api/entries/search?q=example
List entries, newest first. 25 per batch.
GET /api/entries GET /api/entries?cursor=OPAQUE_CURSOR GET /api/entries?days=7 GET /api/entries?tag_ids[]=1&tag_ids[]=2 GET /api/entries?stack_id=5 GET /api/entries?feed_id=3 GET /api/entries?intent_status=reading GET /api/entries?saved_only=true
saved_only=true leaves out items that arrived from feeds and
were never saved.
The cursor is opaque. Pass the next_cursor value from the
previous response back unchanged to fetch the next batch.
intent_status keeps only the entries whose current intent has
that status: want_to_read, reading, finished, abandoned. Entries with no intent are left out. It
combines with the other filters and with cursor.
Response (200):
{
"has_more": true,
"next_cursor": "eyJ2IjoxLCJzb3J0IjoiaW5zZXJ0ZWRfYXQiLCJ0cyI6IjIwMjYtMDMtMjhUMTI6MzQ6NTZaIiwiaWQiOjEyM30",
"entries": [
{
"id": 91,
"type": "note",
"content": "reread the section on backpressure",
"summary": null,
"tags": [],
"processing_state": "complete",
"url": null,
"created_at": "2026-03-28T09:00:00Z",
"intent": {
"id": 12,
"status": "reading",
"strength": "committed",
"target_at": null,
"reason": null,
"source_id": 7,
"created_at": "2026-03-27T12:00:00Z",
"updated_at": "2026-03-28T09:00:00Z"
},
"intent_source": {
"id": 7,
"source_type": "friend",
"source_name": "Molly",
"source_ref": null,
"context": "recommended over coffee",
"created_at": "2026-03-27T12:00:00Z"
}
}
]
}
Every listed entry carries intent (your current relationship
to it, null for awareness only) and intent_source
(the one provenance record named by intent.source_id,
null when the intent has none, and always null
when intent is). Both objects carry the same fields as under
GET /api/entries/:id below. For an entry's full provenance
history rather than the single source behind its intent, use that
endpoint.
Errors:
400 - unknown intent_status, or a malformed
cursorHybrid search across your saved entries. Combines full-text search with semantic vector similarity, ranked by reciprocal rank fusion. Falls back to full-text only if the embedding service is unavailable.
GET /api/entries/search?q=elixir+genserver
Response (200):
{
"query": "elixir genserver",
"count": 3,
"entries": [ ... ],
"collections": [{ "id": 5, "name": "Elixir", "entry_count": 9 }]
}
collections are collections whose names match the query or
that hold the matched entries. Each entry also has a snippet:
about 160 characters of its article, summary, or note around the most
specific query word it contains, or the start of its summary when it
matched on meaning alone.
Errors:
400 - missing/empty q or query exceeds 1000 bytesGet a single entry by ID.
GET /api/entries/42
Response (200):
{
"id": 42,
"type": "link",
"content": "https://example.com",
"summary": "...",
"tags": ["programming"],
"processing_state": "complete",
"url": {
"url": "https://example.com",
"title": "Example",
"authority": "example.com"
},
"created_at": "2026-03-27T12:00:00Z",
"sources": [
{
"id": 7,
"source_type": "friend",
"source_name": "Molly",
"source_ref": null,
"context": "recommended over coffee",
"created_at": "2026-03-27T12:00:00Z"
}
],
"intent": {
"id": 12,
"status": "reading",
"strength": "committed",
"target_at": null,
"reason": null,
"source_id": 7,
"created_at": "2026-03-27T12:00:00Z",
"updated_at": "2026-03-27T12:00:00Z"
}
}
The response also has appears_in, the saved searches
({ "id", "title", "query", "appeared_at" }) that cited this
entry.
sources is append-only provenance (how the entry entered the
library). intent is your current relationship to it;
null means awareness only - no reading commitment.
Errors:
404 - entry not found or belongs to another userAppend a provenance record: how/why this entry entered the library. Sources are append-only; adding one never modifies earlier ones.
POST /api/entries/42/sources
Content-Type: application/json
{
"source_type": "friend",
"source_name": "Molly",
"source_ref": "https://example.com/thread/abc123",
"context": "recommended over coffee"
}
source_type is required, one of: self,
friend, newsletter, agent,
import. The rest are optional.
Response (201): the created source object.
Errors:
404 - entry not found or belongs to another user422 - invalid source_type or field too longSet your current reading intent for an entry. Creates or fully replaces the single current intent (omitted optional fields are cleared).
PUT /api/entries/42/intent
Content-Type: application/json
{
"status": "reading",
"strength": "committed",
"target_at": "2026-08-01T00:00:00Z",
"reason": "core reading for the supervision-trees essay",
"source_id": 7
}
status (required): want_to_read,
reading, finished, abandoned.
strength (required): ambient,
interested, committed.
source_id optionally points at the provenance record that
created the intent; it must belong to the same entry.
Response (200): the current intent object.
Errors:
404 - entry not found or belongs to another user422 - invalid status/strength, or source_id not on this entryRemove the current intent, returning the entry to awareness only.
DELETE /api/entries/42/intent
Response: 204 on success, 404 if no intent exists.
Save a link or note.
POST /api/entries
Content-Type: application/json
{ "url": "https://example.com" }
-- or --
{ "content": "some note text" }
-- optionally with declared provenance --
{
"url": "https://example.com",
"source": { "source_type": "agent", "source_name": "hermes", "context": "research sweep" }
}
Every new entry records provenance. Without a source object
the save is recorded as self; agents saving on someone's
behalf should declare agent provenance.
Response (201):
{
"id": 42,
"content": "https://example.com",
"type": "link",
"created_at": "2026-03-27T12:00:00Z"
}
Errors:
422 - missing content, validation error, duplicate, or a non-object sourceA duplicate returns the existing entry's id, so clients can act on it:
{ "error": "Already exists", "id": 42 }.
Delete an entry. Response: 204, or 404.
The page text extracted for an entry, as Markdown, for reader views and
offline reading. Entries list has_article so clients know which
ones have it.
{
"entry_id": 42,
"title": "Local-first software",
"url": "https://example.com/essay",
"authority": "example.com",
"image": "https://example.com/cover.png",
"content": "# Local-first software
Cloud apps like...",
"word_count": 5120,
"fetched_at": "2026-03-27T12:00:00Z",
"updated_at": "2026-03-27T12:00:00Z"
}
Errors: 404 when the entry has no extracted text.
Record that you opened the entry. Response (201):
{ "visited_at": "..." }
List an entry's notes newest first, or add one with
{ "body": "why this matters" }.
{ "notes": [{ "id": 3, "body": "why this matters", "created_at": "..." }] }
Response: 204, or 404.
Passages highlighted while reading, oldest first, stored as plain text.
Add one with { "text": "the marginal user", "occurrence": 1 },
where occurrence (default 0) is which match of the text in the
article's plain text it is. Highlighting the same occurrence again returns
the existing highlight.
{ "highlights": [{ "id": 7, "text": "the marginal user", "occurrence": 1, "created_at": "..." }] }
Response: 204, or 404.
GET /api/collections
POST /api/entries/:id/collections { "name": "Reading" }
PUT /api/entries/:id/collections/:collection_id
DELETE /api/entries/:id/collections/:collection_id
POST adds the entry to the named collection, creating it if
needed, and returns { "id", "name" }. PUT and
DELETE add or remove membership by ID and return
204.
Totals for navigation: saved entries (the inbox), entries queued to read, and every entry including feed items.
{ "inbox": 120, "queue": 12, "everything": 2481 }
{ "tags": [{ "id": 1, "name": "elixir", "entry_count": 12 }] }
Ask: an assistant searches your library and answers in prose, citing
entries. The answer is saved as a search. The response is a
text/event-stream:
POST /api/searches { "query": "what have I saved about caching?" }
event: progress
data: { "id": "...", "status": "done", "label": "Searched your library", "meta": "8 results" }
event: result
data: { "id": 9, "query": "...", "title": "...", "answer": "...", "created_at": "...",
"entries": [ ... ], "collections": [ ... ], "activity": [ ... ] }
A failed run sends event: error with
{ "error": "..." } instead of result. Errors before
the stream starts: 400 for an empty or over-1000-byte query,
429 past 30 asks per minute.
To ask a follow-up, add "context_search_ids": [9] with up to
5 earlier searches; the assistant reads their questions and answers, oldest
first, as the conversation so far. The follow-up is saved as its own
search. IDs that aren't integers return 400; other users'
searches are ignored.
GET /api/searches -> { "searches": [{ "id", "query", "title", "created_at" }] }
GET /api/searches/:id -> same shape as the result event
DELETE /api/searches/:id -> 204
Native clients exchange an emailed code for a token, without a browser session. These two endpoints need no token.
POST /api/auth/link { "email": "you@example.com" }
-> 202 { "request_id": "..." }
POST /api/auth/verify { "email": "...", "request_id": "...", "code": "123456", "device_name": "Links for iPhone" }
-> 200 { "token": "...", "email": "..." }
Opening the emailed link shows the 6-digit code. Five wrong codes cancel
the request. DELETE /api/auth/token revokes the token that
made the request.
"link" (has a URL) or "note" (plain text).processing_state field tracks progress: new → processing_url → classifying → generating_embedding → complete (or error).url field is null for notes.links.rip