Back to skills
SkillHub ClubShip Full StackFull StackBackend

miniflux-news

Fetch and triage the latest unread RSS/news entries from a Miniflux instance via its REST API using an API token. Use when the user asks to get the latest Miniflux unread items, list recent entries with titles/links, or generate short summaries of specific Miniflux entries. Includes a bundled script to query Miniflux (/v1/entries and /v1/entries/{id}) using credentials from ~/.config/clawdbot/miniflux-news.json (or MINIFLUX_URL and MINIFLUX_TOKEN overrides).

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,070
Hot score
99
Updated
March 20, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
B78.7

Install command

npx @skill-hub/cli install openclaw-skills-miniflux-news

Repository

openclaw/skills

Skill path: skills/hartlco/miniflux-news

Fetch and triage the latest unread RSS/news entries from a Miniflux instance via its REST API using an API token. Use when the user asks to get the latest Miniflux unread items, list recent entries with titles/links, or generate short summaries of specific Miniflux entries. Includes a bundled script to query Miniflux (/v1/entries and /v1/entries/{id}) using credentials from ~/.config/clawdbot/miniflux-news.json (or MINIFLUX_URL and MINIFLUX_TOKEN overrides).

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Backend.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install miniflux-news into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding miniflux-news to shared team environments
  • Use miniflux-news for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: miniflux-news
description: Fetch and triage the latest unread RSS/news entries from a Miniflux instance via its REST API using an API token. Use when the user asks to get the latest Miniflux unread items, list recent entries with titles/links, or generate short summaries of specific Miniflux entries. Includes a bundled script to query Miniflux (/v1/entries and /v1/entries/{id}) using credentials from ~/.config/clawdbot/miniflux-news.json (or MINIFLUX_URL and MINIFLUX_TOKEN overrides).
---

# Miniflux News

Use the bundled script to fetch entries, then format a clean list and optionally write summaries.

## Setup (credentials)

This skill reads Miniflux credentials from a local config file by default.

### Config file (recommended)

Path:
- `~/.config/clawdbot/miniflux-news.json`

Format:
```json
{
  "url": "https://your-miniflux.example",
  "token": "<api-token>"
}
```

Create/update it using the script:

```bash
python3 skills/miniflux-news/scripts/miniflux.py configure \
  --url "https://your-miniflux.example" \
  --token "<api-token>"
```

### Environment variables (override)

You can override the config file (useful for CI):

```bash
export MINIFLUX_URL="https://your-miniflux.example"
export MINIFLUX_TOKEN="<api-token>"
```

Token scope: Miniflux API token with read access.

## Fetch latest entries

List latest unread items (default):

```bash
python3 skills/miniflux-news/scripts/miniflux.py entries --limit 20
```

Filter by category (by name):

```bash
python3 skills/miniflux-news/scripts/miniflux.py entries --category "News" --limit 20
```

If you need machine-readable output:

```bash
python3 skills/miniflux-news/scripts/miniflux.py entries --limit 50 --json
```

### Response formatting

- Return a tight bullet list: **[id] title — feed** + link.
- Ask how many the user wants summarized (e.g., “summarize 3” or “summarize ids 123,124”).

## View full content

Show the full article content stored in Miniflux (useful for reading or for better summaries):

```bash
python3 skills/miniflux-news/scripts/miniflux.py entry 123 --full --format text
```

If you want the raw HTML as stored by Miniflux:

```bash
python3 skills/miniflux-news/scripts/miniflux.py entry 123 --full --format html
```

## Categories

List categories:

```bash
python3 skills/miniflux-news/scripts/miniflux.py categories
```

## Mark entries as read (explicit only)

This skill **must never** mark anything as read implicitly. Only do it when the user explicitly asks to mark specific ids as read.

Mark specific ids as read:

```bash
python3 skills/miniflux-news/scripts/miniflux.py mark-read 123 124 --confirm
```

Mark all unread entries in a category as read (still explicit, requires `--confirm`; includes a safety `--limit`):

```bash
python3 skills/miniflux-news/scripts/miniflux.py mark-read-category "News" --confirm --limit 500
```

## Summarize entries

Fetch full content for a specific entry id (machine-readable):

```bash
python3 skills/miniflux-news/scripts/miniflux.py entry 123 --json
```

Summarization rules:
- Prefer 3–6 bullets max.
- Lead with the “so what” in 1 sentence.
- If content is empty or truncated, say so and summarize from title + available snippet.
- Don’t invent facts; quote key numbers/names if present.

## Troubleshooting

- If the script says missing credentials: set `MINIFLUX_URL`/`MINIFLUX_TOKEN` or create `~/.config/clawdbot/miniflux-news.json`.
- If you get HTTP 401: token is wrong/expired.
- If you get HTTP 404: base URL is wrong (should be the Miniflux web root).


---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "hartlco",
  "slug": "miniflux-news",
  "displayName": "Miniflux News",
  "latest": {
    "version": "0.1.0",
    "publishedAt": 1769158886872,
    "commit": "https://github.com/clawdbot/skills/commit/35c2606067dc99b4a721f910f61122a9b18a0216"
  },
  "history": []
}

```

### references/miniflux-api-notes.md

```markdown
# Miniflux API notes (quick)

This skill uses:
- `GET /v1/entries?status=unread&limit=20&order=published_at&direction=desc`
- `GET /v1/entries/{id}`

Auth:
- Header: `X-Auth-Token: <token>`

Common fields:
- `entries[].id` (integer)
- `entries[].title`
- `entries[].url`
- `entries[].content` (HTML)
- `entries[].published_at`
- `entries[].feed.title`

```

### scripts/miniflux.py

```python
#!/usr/bin/env python3
"""Miniflux API helper.

Reads credentials from (in order):
1) Environment variables (override):
   - MINIFLUX_URL   e.g. https://reader.example.com
   - MINIFLUX_TOKEN your Miniflux API token
2) Config file (recommended):
   - ~/.config/clawdbot/miniflux-news.json  (keys: url, token)

Usage examples:
  python3 miniflux.py entries --limit 20
  python3 miniflux.py entries --limit 20 --json
  python3 miniflux.py categories
  python3 miniflux.py entries --category "Tech" --limit 20
  python3 miniflux.py entry 123
  python3 miniflux.py entry 123 --full --format text
  python3 miniflux.py entry 123 --full --format html
  python3 miniflux.py entry 123 --json
  python3 miniflux.py mark-read 123 124 --confirm
  python3 miniflux.py mark-read-category "Tech" --confirm

Notes:
- Uses only the Python standard library.
- Prints human-readable text by default; pass --json for machine-readable output.
"""

from __future__ import annotations

import argparse
import json
import os
import stat
import sys
import textwrap
import urllib.parse
import urllib.request
from html.parser import HTMLParser


def _config_path() -> str:
    xdg = os.environ.get("XDG_CONFIG_HOME")
    base = xdg if xdg else os.path.join(os.path.expanduser("~"), ".config")
    return os.path.join(base, "clawdbot", "miniflux-news.json")


def _read_config() -> dict:
    path = _config_path()
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return {}
    except Exception as e:
        raise SystemExit(f"Failed to read config at {path}: {e}")


def _require(name: str, env: str, cfg_key: str) -> str:
    v = os.environ.get(env)
    if v:
        return v
    cfg = _read_config()
    v = cfg.get(cfg_key)
    if v:
        return str(v)
    raise SystemExit(
        f"Missing {env} (or config value '{cfg_key}').\n\n"
        f"Set env vars:\n  export {env}=...\n\n"
        f"Or create config:\n  python3 miniflux.py configure --url ... --token ...\n"
        f"Config path: {_config_path()}\n"
    )


def _request(path: str, query: dict | None = None, *, method: str = "GET", body: dict | None = None):
    base = _require("MINIFLUX_URL", "MINIFLUX_URL", "url").rstrip("/")
    token = _require("MINIFLUX_TOKEN", "MINIFLUX_TOKEN", "token").strip()

    url = base + path
    if query:
        url = url + "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})

    data_bytes = None
    if body is not None:
        data_bytes = json.dumps(body).encode("utf-8")

    req = urllib.request.Request(url, data=data_bytes, method=method)
    req.add_header("X-Auth-Token", token)
    req.add_header("Accept", "application/json")
    if data_bytes is not None:
        req.add_header("Content-Type", "application/json")

    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = resp.read()
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")
        raise SystemExit(f"HTTP {e.code} for {url}\n{err_body}")

    if not data:
        return {}

    try:
        return json.loads(data.decode("utf-8"))
    except Exception:
        raise SystemExit(f"Failed to parse JSON from {url}")


def _categories() -> list[dict]:
    data = _request("/v1/categories")
    if isinstance(data, list):
        return data
    return []


def _category_id_from_name(name: str) -> int:
    needle = name.strip().casefold()
    for c in _categories():
        title = (c.get("title") or "").strip().casefold()
        if title == needle:
            return int(c.get("id"))
    known = ", ".join((c.get("title") or "").strip() for c in _categories())
    raise SystemExit(f"Unknown category: {name}. Known: {known}")


def cmd_categories(args: argparse.Namespace) -> int:
    cats = _categories()
    if args.json:
        print(json.dumps(cats, ensure_ascii=False, indent=2))
        return 0
    if not cats:
        print("No categories.")
        return 0
    for c in cats:
        print(f"[{c.get('id')}] {(c.get('title') or '').strip()}")
    return 0


def cmd_entries(args: argparse.Namespace) -> int:
    # Miniflux supports /v1/entries with many filters.
    query = {
        "status": args.status,
        "limit": args.limit,
        "order": args.order,
        "direction": args.direction,
    }

    if args.category_id is not None:
        query["category_id"] = args.category_id
    elif args.category is not None:
        query["category_id"] = _category_id_from_name(args.category)

    data = _request("/v1/entries", query=query)

    if args.json:
        print(json.dumps(data, ensure_ascii=False, indent=2))
        return 0

    entries = data.get("entries", []) if isinstance(data, dict) else []
    if not entries:
        print("No entries.")
        return 0

    for e in entries:
        eid = e.get("id")
        title = (e.get("title") or "").strip() or "(untitled)"
        url = (e.get("url") or "").strip()
        feed = (e.get("feed", {}) or {}).get("title")
        published = e.get("published_at") or e.get("created_at")

        line = f"[{eid}] {title}"
        if feed:
            line += f" — {feed}"
        if published:
            line += f" ({published})"
        print(line)
        if url:
            print(f"  {url}")

    return 0


class _HTMLStripper(HTMLParser):
    def __init__(self):
        super().__init__()
        self._parts: list[str] = []

    def handle_data(self, data: str) -> None:
        if data:
            self._parts.append(data)

    def text(self) -> str:
        return "".join(self._parts)


def _html_to_text(html: str) -> str:
    s = _HTMLStripper()
    s.feed(html)
    return s.text()


def cmd_entry(args: argparse.Namespace) -> int:
    data = _request(f"/v1/entries/{args.id}")

    if args.json:
        print(json.dumps(data, ensure_ascii=False, indent=2))
        return 0

    title = (data.get("title") or "").strip() or "(untitled)"
    url = (data.get("url") or "").strip()
    raw = (data.get("content") or data.get("content_text") or "").strip()

    print(f"[{data.get('id')}] {title}")
    if url:
        print(url)

    if not raw:
        return 0

    # Miniflux usually stores HTML in `content`.
    if args.format == "html":
        body = raw
    else:
        body = _html_to_text(raw)

    if args.full:
        print("\n" + body.strip() + "\n")
        return 0

    # Default: short preview so humans don't get flooded.
    preview = textwrap.shorten(" ".join(body.split()), width=600, placeholder=" …")
    print("\n" + preview)
    return 0


def _mark_read(ids: list[int]) -> None:
    payload = {"entry_ids": ids, "status": "read"}
    _request("/v1/entries", method="PUT", body=payload)


def cmd_mark_read(args: argparse.Namespace) -> int:
    if not args.confirm:
        ids = " ".join(str(i) for i in args.ids)
        raise SystemExit(
            "Refusing to mark entries as read without --confirm.\n"
            f"Would mark as read: {ids}\n"
        )

    _mark_read(args.ids)

    for i in args.ids:
        print(f"✓ marked read: {i}")
    return 0


def _fetch_unread_ids_by_category(category_id: int, *, limit: int) -> list[int]:
    # Simple paging via offset. Stop when we hit limit or no more entries.
    ids: list[int] = []
    offset = 0
    page = 100
    while len(ids) < limit:
        data = _request(
            "/v1/entries",
            query={
                "status": "unread",
                "category_id": category_id,
                "limit": min(page, limit - len(ids)),
                "offset": offset,
                "order": "published_at",
                "direction": "desc",
            },
        )
        entries = data.get("entries", []) if isinstance(data, dict) else []
        if not entries:
            break
        for e in entries:
            if e.get("id") is not None:
                ids.append(int(e["id"]))
        offset += len(entries)
        if len(entries) == 0:
            break
    return ids


def cmd_mark_read_category(args: argparse.Namespace) -> int:
    if not args.confirm:
        raise SystemExit("Refusing to mark a whole category read without --confirm.")

    cid = args.category_id if args.category_id is not None else _category_id_from_name(args.category)
    ids = _fetch_unread_ids_by_category(cid, limit=args.limit)
    if not ids:
        print("No unread entries in category.")
        return 0

    _mark_read(ids)
    print(f"✓ marked read: {len(ids)} entries (category_id={cid})")
    return 0


def cmd_configure(args: argparse.Namespace) -> int:
    path = _config_path()
    os.makedirs(os.path.dirname(path), exist_ok=True)

    cfg = _read_config() if os.path.exists(path) else {}
    if args.url:
        cfg["url"] = args.url
    if args.token:
        cfg["token"] = args.token

    if not cfg.get("url") or not cfg.get("token"):
        raise SystemExit("Both --url and --token are required (at least once).")

    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(cfg, f, ensure_ascii=False, indent=2)
        f.write("\n")

    # Best-effort restrictive perms.
    try:
        os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
    except Exception:
        pass

    os.replace(tmp, path)
    print(f"Wrote config: {path}")
    return 0


def main(argv: list[str]) -> int:
    p = argparse.ArgumentParser(prog="miniflux.py", add_help=True)
    sub = p.add_subparsers(dest="cmd", required=True)

    p_cfg = sub.add_parser("configure", help="Write/update config file")
    p_cfg.add_argument("--url", help="Miniflux base URL, e.g. https://reader.example.com")
    p_cfg.add_argument("--token", help="Miniflux API token")
    p_cfg.set_defaults(func=cmd_configure)

    p_cat = sub.add_parser("categories", help="List categories")
    p_cat.add_argument("--json", action="store_true", help="Output raw JSON")
    p_cat.set_defaults(func=cmd_categories)

    p_entries = sub.add_parser("entries", help="List entries")
    p_entries.add_argument("--status", default="unread", choices=["unread", "read", "removed"], help="Entry status")
    p_entries.add_argument("--limit", type=int, default=20, help="Max entries")
    p_entries.add_argument("--order", default="published_at", help="Order field")
    p_entries.add_argument("--direction", default="desc", choices=["asc", "desc"], help="Sort direction")
    p_entries.add_argument("--category", help="Category title (exact match)")
    p_entries.add_argument("--category-id", type=int, help="Category id")
    p_entries.add_argument("--json", action="store_true", help="Output raw JSON")
    p_entries.set_defaults(func=cmd_entries)

    p_entry = sub.add_parser("entry", help="Fetch one entry")
    p_entry.add_argument("id", type=int, help="Entry id")
    p_entry.add_argument("--json", action="store_true", help="Output raw JSON")
    p_entry.add_argument("--full", action="store_true", help="Print full content (instead of preview)")
    p_entry.add_argument("--format", default="text", choices=["text", "html"], help="Content format when printing")
    p_entry.set_defaults(func=cmd_entry)

    p_mr = sub.add_parser("mark-read", help="Mark entries as read (explicit only)")
    p_mr.add_argument("ids", nargs="+", type=int, help="Entry id(s) to mark as read")
    p_mr.add_argument("--confirm", action="store_true", help="Required safety flag")
    p_mr.set_defaults(func=cmd_mark_read)

    p_mrc = sub.add_parser("mark-read-category", help="Mark ALL unread entries in a category as read (explicit only)")
    p_mrc.add_argument("category", nargs="?", help="Category title (exact match)")
    p_mrc.add_argument("--category-id", type=int, help="Category id")
    p_mrc.add_argument("--limit", type=int, default=500, help="Safety limit: max entries to mark read")
    p_mrc.add_argument("--confirm", action="store_true", help="Required safety flag")
    p_mrc.set_defaults(func=cmd_mark_read_category)

    args = p.parse_args(argv)
    return int(args.func(args))


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))

```