A list of titles and addresses is what a browser's own bookmark bar does. The reason to keep pages here is that they have been fetched and archived, and a grid of covers is the cheapest way to show that. ── Which picture, and the trade it costs ── A crawled bookmark carries two: `content.imageUrl`, the page's own og:image, and a `screenshot` asset Karakeep took and stores itself. The screenshot is better - archived, cannot rot, never leaves the house - and it is unreachable from an `<img>`: Officer authenticates with a bearer token in a header and a browser cannot put a header on an image request, so the asset route answers 401 from markup. Fetching each as a blob would work and costs a request, a decode and an object URL per card. So the origin's image, with referrerPolicy="no-referrer" so those sites learn nothing about where the request came from, and loading="lazy" so a long grid does not fetch a hundred at once. The honest cost, written at the point of use: viewing your bookmarks makes requests to the sites they point at. Proxying the archived asset is the fix if that ever matters. ── The empty state is three states ── Not crawled yet, crawled with nothing to find, and a picture that failed to load. The first is temporary and the others are not, so it says "fetching…" and the header counts how many are still going. Measured on the live instance while writing this: 6 of 50 crawled, the rest pending - so a fresh import spends a while looking like a broken panel unless it explains itself. Grid is auto-fill on a minimum width rather than a column count, because this is one pane of a layout the owner can drag and the columns have to follow the width it is actually given.
Karakeep
Your bookmarks, behind an Officer session. One self-hosted Karakeep per Officer install, pointed at by the owner, with every Officer user mapped onto an account of their own.
Officer stores no bookmarks. Karakeep owns those. What this plugin owns is the credential and the door.
The model
It is the Vault model:
| One instance per install | karakeep_config, one row pinned by a CHECK, set in the panel. Not .env — a plugin cannot ask the owner to edit a file and restart a process. |
| A sidecar owns the upstream | officer-karakeep reads the URL from Postgres with a short TTL and reverse-proxies. Nothing else knows where Karakeep is. |
| Two doors | An authenticated one for Officer's own UI, which injects the mapped user's API key so the device never holds one. And /api/karakeep/kk, unauthenticated, for the browser extension — which carries its own key. |
| An account per user | karakeep_accounts, provisioned per ../USER_PROVISIONING.md. |
Why Karakeep, when this was first built against Linkwarden
Worth recording, because the plugin is otherwise the same shape.
Linkwarden authenticates its extension through NextAuth: host-scoped session cookies, CSRF checked
against the host, and absolute URLs baked from NEXTAUTH_URL. Proxying that means telling the upstream
the address the client uses, which is not the address the sidecar dials. Getting it wrong bounces the
extension out of the proxy mid-login. It cost a NEXTAUTH_URL change, a container recreate, a lost csrf
cookie, and it retired the upstream's own web login as the price of going through Officer.
Karakeep's extension holds an API key and sends it as a header. Nothing is host-pinned, so none of that exists — the same key works at any address and the upstream stays reachable on its own.
Three concrete differences, all from source:
| Linkwarden | Karakeep | |
|---|---|---|
| Extension auth | username/password → session, plus NextAuth cookie routes | apiKey in settings, sent as a header |
| Host pinning | NEXTAUTH_URL must name the client-facing address |
none |
| Custom headers | not supported | customHeaders: z.record(string, string) |
| Extension path | ${url}/api/v1/… across the namespace |
${address}/api/trpc |
| Provisioning | account creation yes; key minting needs a session | users.create + apiKeys.exchange, both public |
That last row is why nothing has to be pasted by hand: Officer creates the account, chooses the password, and exchanges it for a key itself.
A bare Officer origin
Not declared yet, and possible. The extension takes arbitrary customHeaders, so protocolHeaders
could route a bare https://<officer>/ to this plugin the way Bitwarden's client headers route to Vault.
Left until the path form is proven, because it asks the owner to configure a header whose purpose is
invisible from where they are typing it.
Two things carried over from the Linkwarden build
Both were bugs found by running it, and both are upstream-agnostic:
content-encodingmust be stripped from a proxied response.fetchdecompresses the body but the headers still claim gzip, so the client tries to gunzip plain JSON. It hides well — a 401 body is too small to compress, so every unauthenticated probe passes and the first real request is the first to fail.Set-Cookiemust be copied withappend, notset. It is the one header that legitimately repeats. Karakeep should never need this; it is here because a proxy that is correct only for the traffic its author happened to test is a trap for whoever changes the upstream later.
Verified against a live instance
2026-09-05, against Karakeep on alpha. Every shape written from source turned out to be right:
GET /api/v1/lists |
{ lists: [...] } |
GET /api/v1/bookmarks |
{ bookmarks: [...], nextCursor } |
GET /api/v1/bookmarks/search?q= |
the same, paginated |
| a bookmark | title, tags[], content: { type: 'link', url, title, description } |
And the door works from outside, over the public hostname, first attempt:
https://<officer>/api/karakeep/kk/api/v1/bookmarks
with an API key → 200, 20 bookmarks
without one → 401
No NEXTAUTH_URL, no cookies, no container recreate — which is the entire argument for this upstream
over the last one.
The address the sidecar dials is loopback, http://127.0.0.1:3001, and this bit again: alpha:3001
answers 200 from anywhere else and 000 from alpha itself, because the name resolves to 127.0.1.1
there and Karakeep binds 127.0.0.1 and 100.64.0.1. Officer runs on that host.
No provisioning yet — api/accounts.ts is the next piece.