# links.rip api

Personal link database with hybrid search. Save links, search later.


## authentication

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

## endpoints

### GET /api/entries

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:

### GET /api/entries/search?q=QUERY

Hybrid 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:

### GET /api/entries/:id

Get 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:

### POST /api/entries/:id/sources

Append 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:

### PUT /api/entries/:id/intent

Set 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:

### DELETE /api/entries/:id/intent

Remove the current intent, returning the entry to awareness only.

DELETE /api/entries/42/intent

Response: 204 on success, 404 if no intent exists.

### POST /api/entries

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:

A duplicate returns the existing entry's id, so clients can act on it: { "error": "Already exists", "id": 42 }.

### DELETE /api/entries/:id

Delete an entry. Response: 204, or 404.

### GET /api/entries/:id/article

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.

### POST /api/entries/:id/visits

Record that you opened the entry. Response (201): { "visited_at": "..." }

### GET, POST /api/entries/:id/notes

List an entry's notes newest first, or add one with { "body": "why this matters" }.

{ "notes": [{ "id": 3, "body": "why this matters", "created_at": "..." }] }

### DELETE /api/entries/:id/notes/:note_id

Response: 204, or 404.

### GET, POST /api/entries/:id/highlights

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": "..." }] }

### DELETE /api/entries/:id/highlights/:highlight_id

Response: 204, or 404.

### Collections

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.

### GET /api/counts

Totals for navigation: saved entries (the inbox), entries queued to read, and every entry including feed items.

{ "inbox": 120, "queue": 12, "everything": 2481 }

### GET /api/tags

{ "tags": [{ "id": 1, "name": "elixir", "entry_count": 12 }] }

### POST /api/searches

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.

### Saved searches

GET    /api/searches       -> { "searches": [{ "id", "query", "title", "created_at" }] }
GET    /api/searches/:id   -> same shape as the result event
DELETE /api/searches/:id   -> 204

### Sign in from an app

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.


## notes


links.rip