Skip to content
JollsPublic

About

Self-hosted daily triage gate for Miniflux and Nostr: archive, promote, or keep each item. Not a reader.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Siftstr

A self-hosted daily triage gate. Siftstr pulls new items from Miniflux (RSS, YouTube channels, Reddit, podcasts, GitHub releases, newsletters) and from Nostr. You sift each item on your phone or desktop, on its title. A Claude scheduled task writes summaries each morning, but only for digests and for the items you promote.

  • Delete: you're done with it. Its Karakeep bookmark, if it has one, is deleted. The Miniflux entry is only marked read. Once you have promoted an item it is never deleted: the same swipe archives it in Karakeep instead.
  • Promote: come back to it once, with a summary.
  • Keep: send it on to Karakeep, MeTube and so on.

It is not a reader. Source services stay the system of record, and Siftstr deletes its own workflow data after 6 months.

Siftstr makes no LLM calls itself. The summaries come from a Claude scheduled task that talks to Siftstr's JSON API once a day. See ARCHITECTURE.md for the design and ROADMAP.md for what is planned.

How it works

flowchart TD
    subgraph ingest ["Ingest (timers, and after a settings save)"]
        KR["Karakeep's own RSS import<br/>and bookmarks you save"] --> KQ["Every 30 minutes, and after a save that needs it:<br/>list unarchived bookmarks.<br/>Skip if too old, already in Siftstr,<br/>or tagged sifted"]
        MF[Miniflux entries] --> Q{"Per-source setting:<br/>summary_source"}
        NO[Nostr events] --> Q
    end

    Q -->|agent| T
    Q -->|digest feed| DG["Claude morning run:<br/>one summary per digest"]
    DG --> T
    Q -.->|karakeep| KB["Siftstr creates an archived Karakeep bookmark;<br/>Karakeep's AI summarizes it in the background"]
    KB -.-> T
    KQ --> T

    T["Triage card: title and excerpt<br/>(a digest card is its summary)<br/>swipe or key, 15-minute undo hold"]

    T -->|Delete| A["Mark read in Miniflux<br/>(and delete the Karakeep bookmark, if there is one)"]
    T -->|Promote| P["Mark read in Miniflux;<br/>agent path: save to Karakeep, archived, tagged promoted"]
    P -->|agent| PC["Claude morning run<br/>writes the summary and<br/>overwrites the bookmark's summary"]
    P -.->|karakeep| PK["Karakeep's summary<br/>is already there"]
    PC --> D["Card returns with the summary"]
    PK -.-> D
    D -->|Keep| K
    D -->|Archive| AR["Bookmark stays in Karakeep,<br/>archived and tagged promoted"]
    T -->|Keep| K["Mark read in Miniflux;<br/>Karakeep bookmark, MeTube download"]

    %% User actions in orange. linkStyle counts edges in the order they are written above.
    linkStyle 9,10,15,16,17 color:#e8590c,font-weight:bold
Loading
  • Every triage action marks the entry read in Miniflux (for feeds that come from Miniflux), so nothing you have sifted comes back as unread.
  • Triage is on the title and excerpt, for every feed. Digests are the exception: they have no single title to judge, so a digest card starts as Claude's summary.
  • Promote brings the card back with a summary. On agent feeds (the default) Claude writes it at the next morning run and it overwrites the bookmark's summary in Karakeep about 30 minutes after the bookmark is created. On karakeep feeds (dashed) Karakeep's own summary is shown, with no Claude run.
  • agent feeds stay out of Karakeep until you promote or keep. Digests must use this path, and so should Nostr, which has no page for Karakeep to crawl.
  • Older bookmarks that the age limit leaves out come back a few a day into the Backlog (Old Sifting), oldest first, while the Backlog has room.
  • Bookmarks Karakeep collects itself are polled every 30 minutes (and as soon as you save a working Karakeep connection), so Karakeep's own RSS import can feed the same triage. They already have Karakeep's summary, so promote shows it with no Claude run. The sifted tag keeps Siftstr from offering back what it already sent.
  • karakeep feeds are saved to Karakeep at ingest, so use it only on feeds you are happy to have Karakeep crawl.
  • Claude writes digest summaries and the summary for promoted items on agent feeds. It does nothing else.

The dashed karakeep path is an experiment (see ROADMAP.md): it is built, but has had only a first check against a real Karakeep. The design is in ARCHITECTURE.md (§5 to §7). Karakeep's own summary and the sources it collects are read back by a job that runs every 30 minutes, so a promoted card on the karakeep path can take that long to return.

What you need

  • A host that runs Docker (amd64 or arm64).
  • A Miniflux account, and/or Nostr npubs to follow.
  • A Claude account that can run scheduled tasks (see docs/scheduled-task.md).
  • Optional destinations for kept items: Karakeep, MeTube.

Deploy

The image is ghcr.io/jolls/siftstr, built for linux/amd64 and linux/arm64. It listens on plain HTTP on port 8080 and keeps everything under /data.

Docker Compose

Use compose.yaml from this repo. Copy .env.example to .env, fill it in, and start it:

cp .env.example .env     # set SIFTSTR_BASE_URL, SIFTSTR_ADMIN_USER, SIFTSTR_ADMIN_PASSWORD
docker compose up -d --build
docker compose logs -f

To run the published image instead of building, swap build: . for image: ghcr.io/jolls/siftstr:latest in compose.yaml.

Plain Docker

docker run -d --name siftstr --restart unless-stopped \
  -p 127.0.0.1:8080:8080 \
  -v siftstr-data:/data \
  -e SIFTSTR_BASE_URL=https://siftstr.example.com \
  -e SIFTSTR_ADMIN_USER=admin \
  -e SIFTSTR_ADMIN_PASSWORD='choose-a-long-password' \
  -e TZ=Europe/Berlin \
  ghcr.io/jolls/siftstr:latest

The admin variables only matter on the first start, when there are no users yet. Afterwards you can drop them. Check that it is up with GET /healthz (the image's HEALTHCHECK does this) and log in at /login.

Configuration

Variable Default Purpose
SIFTSTR_LISTEN :8080 Listen address (plain HTTP).
SIFTSTR_DATA_DIR /data Database, key, briefings and backups.
SIFTSTR_BASE_URL none The URL browsers and the Claude task use to reach Siftstr. Drives absolute links and the Secure cookie flag. Also turns on the Strict-Transport-Security header when it starts with https://.
SIFTSTR_TRUSTED_PROXIES none Comma-separated CIDRs (or IPs) of your reverse proxy. Login limits then read the client IP from X-Forwarded-For.
SIFTSTR_SECRET_KEY generated Key that encrypts saved upstream credentials. If unset, Siftstr creates /data/secret.key.
SIFTSTR_ADMIN_USER, SIFTSTR_ADMIN_PASSWORD none Creates the first admin on first start. The password must be at least 12 characters.
SIFTSTR_MINIFLUX_URL, SIFTSTR_KARAKEEP_URL, SIFTSTR_METUBE_URL none Pre-fill that connection for the admin user.
SIFTSTR_MINIFLUX_API_KEY, SIFTSTR_KARAKEEP_API_KEY (or ..._FILE) none Key for the pre-filled connection, or a path to a file holding it (such as a Docker secret). Not both.
TZ UTC Default timezone for new users. Each user can change theirs under Settings, Sources.

Everything else (timers, prompts, per-source settings) is set in the app. Connections are too; the connection variables only pre-fill them for the admin user: the URL is re-applied on every start, a key only when none is saved.

Add more users with docker compose exec or docker exec:

printf '%s' 'a-long-password' | docker compose exec -T siftstr /siftstr user add alice
printf '%s' 'new-password' | docker compose exec -T siftstr /siftstr user set-password alice

Passwords must be at least 12 characters.

Reverse proxy and TLS

The container never terminates TLS. Put Caddy, Traefik, nginx, Tailscale or similar in front of port 8080 and forward to it.

compose.yaml publishes port 8080 on 127.0.0.1 only. Ports that Docker publishes bypass host firewalls such as ufw and firewalld, so an all-interfaces port would expose plain HTTP (password, session cookie, API key) even with a proxy in front. To reach Siftstr from other machines without a proxy, change the bind address on purpose.

  • Set SIFTSTR_BASE_URL to the address people actually use. The session cookie gets the Secure flag only when that URL starts with https://, and it is never inferred from the request, so behind a TLS proxy set it to your https:// address. With an https:// URL the cookies are named __Host-siftstr_session and __Host-siftstr_login, so after switching between http:// and https:// everyone signs in again.
  • Set SIFTSTR_TRUSTED_PROXIES to the address or subnet your proxy connects from, for example the Docker network's subnet such as 172.18.0.0/16. Without it every login seems to come from the proxy, so 20 failed logins from anyone block logins for everyone for 15 minutes. List only addresses you control: a listed address can claim any client IP in X-Forwarded-For.
  • If you serve it over plain http:// on a LAN, the cookie is not Secure. That works, but anyone on that network can read the traffic. Siftstr logs a warning at startup in this case.

Exposing Siftstr publicly

Do not expose Siftstr to the internet without HTTPS. The container only speaks plain HTTP, so a TLS reverse proxy is required (see Reverse proxy and TLS). compose.yaml binds the port to 127.0.0.1 only; keep that bind and let the proxy be the only public listener.

Checklist:

  • SIFTSTR_BASE_URL is set to the https:// address people use.
  • SIFTSTR_TRUSTED_PROXIES is set to the proxy's address or subnet.
  • The port is still bound to 127.0.0.1, or is otherwise reachable only through the proxy.
  • The admin password is at least 12 characters and not used anywhere else.
  • Upstream connections on another host (Miniflux, Karakeep, MeTube) use https://. Settings → Destinations shows a hint for a plain http:// address that may be public.

At startup Siftstr logs a warning when SIFTSTR_BASE_URL is not https://. With an https:// value it sends Strict-Transport-Security: max-age=31536000.

Any signed-in user can point a connection or Nostr relay at an address on your internal network and see the connection error. That is fine with trusted users only; see ARCHITECTURE.md §12, Known limitations.

The same URL is what the Claude task calls, so it has to be reachable from where the task runs. See docs/scheduled-task.md.

Trusting a private CA

If Miniflux, Karakeep or MeTube sit behind a certificate from your own CA, mount the CA certificate (PEM) into a folder and point SSL_CERT_DIR at it. Public CAs stay trusted.

services:
  siftstr:
    environment:
      SSL_CERT_DIR: /certs
    volumes:
      - ./certs:/certs:ro

Put the CA certificate in ./certs as a .crt or .pem file.

Set up

  1. Log in, then open Settings, Destinations and add your Miniflux connection (and Karakeep and MeTube if you use them), skipping any that instance config already filled in.
  2. Open Settings, Sources. Your Miniflux feeds appear after the first ingest, which starts as soon as a working Miniflux connection is saved (and runs every 30 minutes, and once at start-up). Set granularity, max depth and carryover per source.
  3. For Nostr, add relays and followed npubs under Settings, Nostr.
  4. Create the Claude scheduled task: docs/scheduled-task.md.

Operator requirements for Karakeep and MeTube

  • Turn off Karakeep's native Miniflux integration. Otherwise every item reaches Karakeep whether or not you kept it. Siftstr is what decides what goes there.
  • Keep Karakeep's CRAWLER_VIDEO_DOWNLOAD off if MeTube handles downloads. It is an instance-wide Karakeep setting, and Siftstr never asks Karakeep to download anything. Siftstr never downloads media itself either.

Backups

Everything that must survive lives in /data:

Path Contents
siftstr.db The SQLite database (WAL mode).
secret.key The key that encrypts saved upstream credentials. Lose it and you re-enter them.
briefings/<username>/ The daily markdown briefings.
backups/ A consistent database snapshot written once a day, newest 7 kept.

Don't copy siftstr.db with a file tool while Siftstr is running: in WAL mode that can give you a broken copy. Back up /data/backups/ instead (restic, borg, a Nextcloud sync, or a volume snapshot), plus secret.key (or keep SIFTSTR_SECRET_KEY in your secrets store). The snapshots contain the credentials in encrypted form only.

To restore, stop Siftstr, put a snapshot at /data/siftstr.db (delete any siftstr.db-wal and siftstr.db-shm beside it), restore secret.key, and start it again.

The janitor also deletes workflow data (items, actions, outbox entries, runs) older than 6 months. Your sources, connections and settings are kept.

Markdown briefings

Each morning's run writes /data/briefings/<username>/YYYY-MM-DD.md, the same content as the /read page. To get it into Nextcloud or Obsidian, bind-mount a synced folder over /data/briefings/<username>. You can also open Briefings in the app to view or download any past day.

Development

go build ./...
go test ./...
go vet ./...
gofmt -l .                      # must print nothing
node --test web/test/*.test.js  # client queue and gesture tests

To try the app with sample data and no real upstreams, see docs/development.md.

Releases: pushing a tag like v1.0.0 runs .github/workflows/release.yml, which builds linux/amd64 and linux/arm64 with docker buildx and publishes to ghcr.io. Tag the version at the top of CHANGELOG.md; every PR adds its own version entry there. See AGENTS.md for the conventions and invariants.

License

AGPL-3.0. See LICENSE.

About

Self-hosted daily triage gate for Miniflux and Nostr: archive, promote, or keep each item. Not a reader.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages