---
title: "Search articles by keyword"
method: POST
path: "/search_articles_by_keyword"
---

# Search articles by keyword

`POST /search_articles_by_keyword`

## Request

- Base URL: `https://newsmind.app/api/v1`
- URL: `https://newsmind.app/api/v1/search_articles_by_keyword`
- Auth: OAuth 2 (authorization code, authorization URL `https://newsmind.app/oauth/authorize`, token URL `https://newsmind.app/oauth/token`, scopes: `mcp`)

Full-text keyword search. Ranked by relevance with a 14-day recency half-life; an AND search returning 0 auto-retries with OR (fallback='or'). The `retention` field carries the tier window + a cutoff_hit flag — surface when set. Paraphrased queries: use search_articles_by_meaning (Max).

## Request body

- object
  - `query` string, required: Search terms — natural language works (websearch syntax).
  - `unread_only` boolean: Exclude articles the user has marked read.
  - `since_days` integer: Only articles published in the last N days. Whole days only - the ranking function rejects a fractional value.
  - `limit` integer: Max results. Default 50; values outside 1-1000 are clamped.

## Response `200`

Success

- object
  - `articles` Article[], required
    - `id` integer
    - `feed_id` integer: ID of the feed this article belongs to. Useful for chaining into list_feeds or unsubscribe.
    - `feed_title` string, nullable
    - `title` string, nullable
    - `url` string, nullable
    - `image_url` string, uri, nullable: Thumbnail URL for the article, when available. HTTPS-only; tracking pixels filtered out. Sourced from <enclosure>, <media:thumbnail>, <media:content>, or first <img> in content. Forward-only — articles ingested before 0.3.0 have null.
    - `image_width` integer, nullable: Pixel width of image_url, when the source feed exposed it (MRSS attrs or HTML width=). Lets clients lay out before fetching the bytes. Null when unknown.
    - `image_height` integer, nullable: Pixel height of image_url, when known. Null otherwise.
    - `image_mime` string, nullable: MIME type of image_url (image/jpeg, image/png, image/webp, image/gif, image/avif, image/svg+xml). Sourced from <enclosure type=>, <media:content type=>, or inferred from URL extension. Null when neither available.
    - `summary` string, nullable
    - `published_at` string, date-time, nullable: When the source published the article (from the RSS feed).
    - `added_at` string, date-time, nullable: When Newsmind ingested the article into the user's feed. Use this when the user asks about 'when did this show up for me' or 'what's new since I last checked'; use published_at for 'how old is the story'.
    - `matched_watches` object[]: Saved watches this article matched. Present on unread/new-article responses when the user has active watches.
      - `watch_id` integer
      - `label` string, nullable
    - `likely_archival` object: Present when the title states a date a year or more before published_at — an aggregator re-indexed an old page under today's date. Don't present these as new.
      - `title_year` integer
      - `matched_text` string
      - `pattern` 'slash' | 'written' | 'paren'
    - `also_in` object[]: Other feeds that carried this same story. The duplicate was collapsed to one match (issue #59).
      - `feed_id` integer
      - `feed_title` string, nullable
    - `user_excluded` object: This row matched one of the watch's exclude_terms. Demoted, never dropped, and the term is named so an over-broad guess can be corrected (issue #23).
      - `term` string
    - `low_signal` object: Present when a Google News item's source is an encyclopedia, film database, statistics page or obituary listing rather than reporting. `rule: "obituary"` comes from a headline rule and may name a legitimate newsroom — describe the item, not the outlet.
      - `source` string
      - `rule` 'reference' | 'entertainment_db' | 'sports_stats' | 'obituary' | 'archive_page' | 'scraped_video'
  - `fallback` 'or', nullable: Set to 'or' when the AND search returned 0 articles and the tool retried with OR semantics. Indicates the results match ANY term, not all terms — narrate this to the user as reduced precision.
  - `hint` string, nullable: Human-readable note about empty results or fallback. Surfaces actionable suggestions (narrow the query, broaden time range, use semantic_search).
  - `search_scope` SearchScope: Present only when the caller's feeds hold more in-window articles than one search can cover (#303). Everything published since `since` was searched; older articles were not. Surface the note when present.
    - `since` string, date-time, required: ISO timestamp; articles published before it were not searched.
    - `bounded` boolean, required: Always true when the field is present.
    - `articles_searched` integer, nullable: Semantic search: how many of the caller's articles the scan read (the newest ones).
    - `matches_ranked` integer, nullable: Keyword search: how many caller-visible matches the rank was chosen from.
    - `note` string, required: One sentence for the user, ready to relay.
  - `retention` RetentionMeta, required: Tier-based retention window for the caller. Articles older than cutoff_at are invisible to discovery (search, unread, counts) regardless of query. cutoff_hit is set when retention likely truncated the result set — surface to the user when present.
    - `window_days` integer, required: Retention window in days. Pro = 30, Max = 90.
    - `cutoff_at` string, date-time, required: ISO timestamp; articles with published_at < cutoff_at are filtered out.
    - `cutoff_hit` boolean, nullable: Present (and set to true) only when retention likely bounded the result set: user asked for older data than retention covers, query came back empty for a multi-term ask, or the oldest returned article is right at the wall. Absent on queries where retention wasn't the limiter.

## Other responses

- `400`: Bad request
- `401`: Unauthorized
- `402`: Subscription required (trial expired or cancelled)
- `429`: Too many calls. Either the per-account rate limit ({error: "rate_limited", message}) or the account's in-flight work ({error: <sentence>, code: "busy"}). Wait Retry-After seconds.
- `500`: Server error on our side (error: internal_error). The team has been notified.
- `503`: Temporarily unable to answer. error: timeout (the request ran too long; narrow it), server_busy or tier_unavailable (retry after Retry-After seconds when sent).

---

[API](https://skmtc.dev/newsmind/apis/newsmind-api.md) · [All operations](https://skmtc.dev/newsmind/apis/newsmind-api/llms.txt) · [OpenAPI document](https://skmtc.dev/newsmind/apis/newsmind-api/revisions/f8af23fb5a10?raw)
