{
  "markdown": "<img src=\"icon.svg\" width=\"80\" style=\"border-radius:18px\">\n\n# Sidewise\n\n![version](https://img.shields.io/badge/version-v0.4.0-blue) ![license](https://img.shields.io/badge/license-MIT-green) [![GitHub](https://img.shields.io/badge/GitHub-nulljosh%2Fsidewise-black?logo=github)](https://github.com/nulljosh/sidewise)\n\nWhat is the other side reading?\n\nSidewise pulls 16 feeds from 14 newsrooms across the spectrum, tags each left, center or right,\nand flags the **blindspots**: stories only one side is covering. Free. No account, no key, no limit.\n\nTwo feeds from one newsroom count as one voice. A publisher can't run an opinion section next\nto its main feed and call that two sources.\n\nFive ways in: a [web reader](https://sidewise.heyitsmejosh.com), native [iPhone, iPad and Mac apps](https://sidewise.heyitsmejosh.com/app), a JSON API, an MCP server, and a terminal client (`swift build && ./.build/debug/sidewise-tui 5` — see [tui/](tui/)).\n\n## Screenshots\n\n<p>\n<img src=\"screenshots/raw/main.png\" alt=\"Sidewise\">\n</p>\n\n## Apps\n\nSwiftUI, one codebase for iOS and macOS, in `ios/`. It reads the same public API. No account,\nno tracking. Saved stories and the feed cache stay on the device.\n\n```\ncd ios && xcodegen generate\nxcodebuild -scheme Sidewise-iOS -destination 'generic/platform=iOS Simulator' build\n```\n\n## MCP\n\n```\nclaude mcp add --transport http sidewise https://sidewise.heyitsmejosh.com/mcp\n```\n\n| Tool | Params | Returns |\n|---|---|---|\n| `get_news` | `view`, `outlet`, `bias`, `developing`, `q`, `limit` | Current headlines, flat or clustered by story |\n| `get_blindspots` | `limit` | Only stories covered by a single political side |\n| `compare_coverage` | `q` (required), `limit` | One story as each side headlines it, plus the words unique to each |\n| `get_feed_health` |: | Which feeds answered, and whether the data served is complete or a stale fallback |\n\nCall `get_feed_health` before you trust an empty or one-sided result. An outage and a quiet\nnews day look the same otherwise.\n\nStateless streamable HTTP, no auth. Works with any MCP client: Claude Desktop, Claude Code, Cursor.\n\n## API\n\n`GET https://sidewise.heyitsmejosh.com/api/stories`. Every parameter is optional.\n\n| Param | Values | Default |\n|---|---|---|\n| `view` | `latest`, `stories`, `both` | `both` |\n| `outlet` | any outlet name, e.g. `Hacker News` | all |\n| `bias` | `left`, `center`, `right` | all |\n| `blindspot` | `true` | off |\n| `developing` | `true`: three or more newsrooms in the last 90 minutes | off |\n| `compare` | `true`: attach the side-by-side breakdown to each story | off |\n| `q` | substring match on headline and summary text | none |\n| `limit` | 1–200 | 60 clusters / 120 headlines |\n\nTwo more endpoints. `GET /api/health` reports every feed and answers **503** when more than\nhalf are down, so you can point a monitor at it as is. `GET /api/sources` lists each feed with\nits bias, its side and its parent newsroom.\n\n```bash\ncurl 'https://sidewise.heyitsmejosh.com/api/stories?view=stories&blindspot=true'\ncurl 'https://sidewise.heyitsmejosh.com/api/stories?view=latest&outlet=Hacker%20News&limit=10'\n```\n\n```json\n{\n  \"updated\": 1754700000000,\n  \"stories\": [\n    {\n      \"title\": \"Fed holds rates steady\",\n      \"blindspot\": false,\n      \"sources\": [\n        { \"title\": \"Fed holds rates steady\", \"link\": \"https://…\", \"outlet\": \"NPR\", \"bias\": -1 },\n        { \"title\": \"Fed refuses to cut rates\", \"link\": \"https://…\", \"outlet\": \"Fox News\", \"bias\": 2 }\n      ]\n    }\n  ],\n  \"latest\": [\n    { \"title\": \"Fed holds rates steady\", \"link\": \"https://…\", \"outlet\": \"NPR\", \"bias\": -1, \"ts\": 1754699000000 }\n  ]\n}\n```\n\nCORS is open. Feeds are re-pulled at most every 2 minutes. Responses are `no-store` (see below). `ts` is epoch ms, or `0` when the feed gave no date. Those sort to the bottom. Full spec: [`openapi.yaml`](https://sidewise.heyitsmejosh.com/openapi.yaml) · orientation for agents: [`llms.txt`](https://sidewise.heyitsmejosh.com/llms.txt).\n\n## How it works\n\nOne Cloudflare Worker (`worker.js`) polls every feed in `src/feeds.js` and serves the page,\nthe API and MCP from one pooled pull:\n\n- **`latest`**: every headline, newest first.\n- **`stories`**: headlines clustered by title-keyword overlap. Each source tagged left, center or right. One-sided clusters flagged `blindspot`.\n\nOnly the feed pull is cached, under one constant key. Filtering happens per request in `shape()`, after it, and responses go out `no-store`. The zone's CDN cache ignores query strings, so caching them would hand one caller's `?outlet=` to everyone. Nothing is refetched either way. Only the cheap filtering runs again.\n\nClusters are filtered *after* clustering. The flat feed is filtered *before* its 120-item cap. Both orders matter. Narrow the input first and you lose the cross-outlet comparison. Filter after the cap and small outlets vanish.\n\n![architecture](architecture.svg)\n\n## Bias scores\n\nEach source has a score from `-2` (left) to `+2` (right). `0` is center, or non-political for tech outlets like Hacker News and Daring Fireball. I assigned these by hand in `FEEDS`. They are a rough lean, not a rating.\n\nCBC · The Guardian · NPR · BBC · Global News · National Post · Fox News · NY Post · Daily Wire · Hacker News · Daring Fireball · NBC News · Wall Street Journal · NY Post Opinion · Vancouver Sun · The Province\n\nAdd one by appending `[outlet, bias, url]` to `FEEDS` at the top of `worker.js`. Any RSS 2.0 or Atom feed works.\n\n## Develop\n\n```\nnpm test         # 95 checks, no network\nnpm run feeds    # check every feed for freshness, not just a 200\nnpm run deploy   # wrangler deploy\n```\n\nRun `npm run feeds` after touching `FEEDS`. It checks **recency**, the only way to catch a\nzombie: a feed that still answers 200 with clean XML whose newest item is two years old. CNN\ndid that for three years. An item count never noticed.\n\nCloudflare Workers plus Static Assets. One deploy serves the page, `/api/stories` and `/mcp`.\n\n### Tests\n\n`test/` covers the four modules in `src/` with no network and no Worker runtime:\n\n| File | What it holds down |\n|---|---|\n| `parse.test.mjs` | Entity decoding, double-escaped summaries, CDATA and Atom shapes, and the rejection of `javascript:` and `data:` links |\n| `stories.test.mjs` | Clustering, the newsroom-not-feed counting rule behind blindspots, the developing window, and filter-after-cluster ordering |\n| `load.test.mjs` | Feed failure reporting, timeouts, the degraded/stale fallback, and the rule that a bad pull never overwrites the last good one |\n| `worker.test.mjs` | Query parsing and clamping, and that everything under `/api/` answers JSON with CORS, including 404s, 405s and 500s |\n| `mcp.test.mjs` | JSON-RPC framing, malformed input, and each tool's contract |\n\nSecurity: see [SECURITY.md](SECURITY.md).\n\n## License\n\nMIT 2026, Joshua Trommel. Headlines and links belong to their publishers. Sidewise stores nothing and links out to the original.\n\n## Whitepaper\n\n[Technical whitepaper](WHITEPAPER.md)\n\n## API and agent tools\n\nREST (`/api/*`), the `POST /mcp` server, and the reader's in-page WebMCP tools\n(`public/webmcp.js`) expose the same four operations. They are tested against each other so\nthey can't drift. See [`docs/API.md`](docs/API.md).\n",
  "bytes": 7218,
  "sha": "368ddd19ca41d7277f3aef65d75500277256278cf6dd0c4db391d336f7e2068d",
  "repo_slug": "nulljosh/sidewise",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_nulljosh_newsline_fadf8291/readme"
}