# feedql: how to use it (for people and LLM agents)

feedql is a personal feed and knowledge base that builds itself. It collects from sources a person chooses
(Hacker News, Reddit, GitHub, X, Telegram, YouTube, forums, app-store reviews, registries, any website), removes
duplicates, groups reports of the same event, scores each item against the person's **topics**, and always links to
the **original source**. People read it on the web; programs and AI agents read and manage it through **MCP** or a
**REST API**. Everything here is one account's own data: a key sees only its owner's topics and items.

This page is generated from the server's own tool registry and route table, so the tool and route sections cannot
drift from the code. `tools/list` over MCP returns the same tools with their complete JSON schemas.

- Web app: https://app.feedql.com (sign in, Settings, API keys)
- MCP (Claude Desktop, claude.ai, Claude Code, any MCP client): https://mcp.feedql.com (also https://api.feedql.com/mcp)
- REST API: https://api.feedql.com/api/v1
- This page: https://feedql.com/llm.md (also https://api.feedql.com/llm.md); index: https://feedql.com/llms.txt

Nothing on this page is a secret. Never paste a real key into a prompt, a URL, a log or a commit: keep it in an
environment variable (`FEEDQL_KEY` below).

## 1. Quick start

1. Get access (section 2): an OAuth connector for Claude Desktop / claude.ai, or an `fq_` API key for scripts and Claude Code.
2. Find the topics: MCP `list_topics`, or `GET https://api.feedql.com/api/v1/topics`.
3. Read: MCP `search_items` with an FQL query such as `topic:golang since:1d score>=60`, or
   `GET https://api.feedql.com/api/v1/items?q=topic%3Agolang%20since%3A1d%20score%3E%3D60`.
4. Optional: act on what you read (vote, mark read, save, note, create or change topics). This needs a **write** key, and
   marking items works over REST and MCP.

```bash
export FEEDQL_KEY=fq_...            # made in app.feedql.com > Settings > API keys
curl -s -H "Authorization: Bearer $FEEDQL_KEY" "https://api.feedql.com/api/v1/topics"
```

## 2. Connect and authenticate

| You are | Use | How |
|---|---|---|
| Claude Desktop or claude.ai | OAuth connector | Settings > Connectors > Add custom connector, URL `https://mcp.feedql.com`, Connect, sign in to feedql, choose **Read only** or **Read and manage**, Allow. No key is copied around. |
| Claude Code | OAuth, or an API key | `claude mcp add --transport http feedql https://mcp.feedql.com` then `/mcp` to sign in; or with a key: `claude mcp add --transport http feedql https://api.feedql.com/mcp --header "Authorization: Bearer $FEEDQL_KEY"` |
| A script, cron job, another agent runtime | API key `fq_...` | `Authorization: Bearer fq_...` on every request, REST and MCP alike |
| A data pipeline reading one stream | Channel key `fqc_...` | same header; works only on `/c/<id>/...` and on `/mcp` (where it sees just that channel's tools) |
| A generic MCP client with OAuth support | OAuth 2.1 | discovery below |

**Scopes.** An API key or OAuth grant is **read** or **write** ("Read only" / "Read and manage"). Read lists and searches.
Write also creates, changes and deletes topics, votes, marks read, saves, writes notes, manages webhooks, exports and
channels. A read key simply does not see write tools in `tools/list`; a REST write route answers `403 forbidden`.
Prefer a read key unless the task needs to change something.

**Channel keys** (`fqc_...`, read or write, optional expiry) belong to one channel (a separately configured stream for a
program); any other route answers `403`. **Account keys** (`fq_...`) read all of the owner's channels.
The person makes and revokes keys in the app (Settings > API keys; channel keys on the channel page). A write connection can also
make a key for a script with `create_api_key` (never above its own scope, 1 to 365 days, default 30, the secret is in the answer
**once**; at most 10 live keys and 5 MCP creations an hour) and end one with `revoke_api_key` (not the key making the call). Read
connections and channel keys cannot. Such keys show as made by MCP in Settings.
Connected OAuth apps are listed next to the keys and can be disconnected there.

**OAuth discovery** (MCP authorization spec 2025-06-18, PKCE S256 only, public clients):
`GET /.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` on `mcp.feedql.com` or
`api.feedql.com`; dynamic registration `POST /oauth/register`; `GET /oauth/authorize`; `POST /oauth/token`
(`fqo_` access tokens, rotating `fqr_` refresh tokens). A call without a valid token gets `401` with
`WWW-Authenticate: Bearer realm="feedql", resource_metadata="https://api.feedql.com/.well-known/oauth-protected-resource"`.
A revoked or expired key is `401` too.

## 3. The data model in five lines

- A **topic** says what to follow: a title, an optional **About** (`about`, up to 600 characters, in the person's own words;
  the scoring model reads it), a keyword pack, places and languages, and the **sources** it follows.
  `status` is `expanding` while the model builds the pack, then `ready`; `following: true` once followed.
- An **item** is one report from a source. It has a `score` (0-100, how well it fits the topic, `null` until scored or in
  raw topics), a `rationale`, the original `url`, and may belong to an **event** (other reports of the same story).
  The same item id is used everywhere (MCP, REST, webhooks).
- The **feed** is newest first and applies each topic's score threshold, the person's votes and their don't-show / must-see rules.
- **Marks** are the person's own state on an item: `mine` = `{vote: up|down, read, saved, not_this, note}`. A down vote or
  "not this" teaches the topic by similarity of meaning. Only vote when the person decided.
- **Channels**: the person's reading feed is the `main` channel. Extra channels are separate data streams for programs.
  Most agents only ever touch `main`. `list_topics`, `search_items`, `topic_feed` and `new_items` work on `main`; for another
  channel use `list_channels`, then `channel_items` / `channel_new_items` / `channel_stats` with its `channel` id or slug
  (the FQL key `channel:` is something else: an alias of `place:`).

Items are kept for `retention_days` (365 by default) from collection (`kept_until` on each item); saved or noted items stay.

### The item payload

The same object in MCP results, REST lists and webhooks:

```json
{
  "id": "6720f1c2a5b7d9e8f0a1b2c3", "url": "https://example.com/original-article", "title": "Go 1.27 released",
  "summary": "feedql's own summary, in the account's summary language", "source": "hackernews", "kind": "post",
  "place": "r/golang", "lang": "en", "author_hash": "9f2c...", "published": "2026-10-11T07:30:00Z",
  "topic": {"id": "6720e0...", "title": "golang"},
  "score": 82, "rationale": "why it matters",
  "metrics": {"points": 321, "upvotes": 321, "comments": 87},
  "entities": [{"type": "domain", "value": "go.dev"}],
  "event": {"id": "...", "reports": [{"id": "...", "title": "...", "url": "...", "source": "...", "published": "..."}]},
  "mine": {"read": true}, "kept_until": "2027-10-11", "seq": 1207
}
```

- `url` is always the original address: **link to it when you show an item**. `text`, `excerpt`, `image(s)`, `transcript_*`,
  `key_points`, `visibility`, `feedback`, `filtered_by`, `similarity`, `fields` appear when they apply (`get_item` has the most).
- `summary_status` (`ready`, `pending`, `failed`): summaries are written when an item is first asked for. `pending` means one
  was just queued: call `get_item` again in a few seconds.
- `metrics` always has `points`; `rating`, `stars`, `score`, `upvotes`, `comments`, `views`, `version` only when the source gave them.
- `score` already includes the person's votes. `mine` is absent when the person did nothing with the item.
- `kind`: post, review, app, release, issue, repo, video, article, notice, tender, grant, job. `place`: `r/<subreddit>`,
  `owner/repo`, `<app id>@<country>`, `<forum host>/<category>`, `@handle` or a website host.

## 4. MCP

**Endpoint**: `https://mcp.feedql.com` (or `https://api.feedql.com/mcp`). Streamable HTTP, stateless, JSON responses,
bearer authentication. A client library or `claude mcp add` handles the protocol. By hand, each call is one POST:

```bash
curl -s https://api.feedql.com/mcp \
  -H "Authorization: Bearer $FEEDQL_KEY" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_items","arguments":{"query":"topic:golang since:1d"}}}'
```

No `initialize` handshake is needed: the server is stateless, every POST stands alone. The `Accept` header is required by the
protocol; the reply is always plain JSON, never an event stream.
The answer is `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"<the result as JSON text>"}],"structuredContent":{...}}}`;
`structuredContent` and the text are the same value. A key that cannot use a tool does not see it, and calling it is an error.
`tools/list` returns every tool the key may use with full input and output schemas; `prompts/list` the prompts.

**Failures** are tool errors (`result.isError: true`) whose text is JSON:
`{"error":{"code":"bad_request","message":"...","field":"item_ids","retry_after_seconds":3}}` (`field` and `retry_after_seconds` only when they apply).
Codes: `bad_request` (with `field`), `unauthorized`, `forbidden`, `not_found`, `conflict`, `limit_reached`, `rate_limited`
(wait `retry_after_seconds`), `unavailable` (retry later), `query_syntax` (the FQL is wrong; the message has the position),
`query_unsupported`. Arguments that break a tool's schema come back as `bad_request` too.

**Rate limit**: a bucket of 120 calls refilling over a minute per key; a write call costs 4. Over it: `rate_limited`. This bucket is
separate from the REST one (section 5): mixing REST reads with MCP writes spends each from its own.

**Long work never blocks a call.** Building a keyword pack (`create_topic`), a test run (`start_test_run`), an export
(`start_export`) and the first use of a meaning search return at once; poll the status tool (`get_topic`, `get_test_run`,
`get_export`, or repeat the same `search_items`) every 2 to 5 seconds.

**Paging.** A list holds up to 30 items. List tools return `next`; pass it back as `cursor` (with the same query) for older items.
It is absent on the last page. A page can be short, even empty, while `next` is present (the search scans in bounded rounds):
stop only when `next` is absent. Pass the cursor as it is (URL-encode it in a REST query string), and deduplicate by item `id`:
an item scored while you page can show up twice.
`new_items` is different: call it once without a cursor to get "now", then with the `cursor` it returned; items come oldest
first, `more` says call again at once.

**Language.** Tools that return items take `lang` (`en`, `uk`) for summaries and translated titles; default is the account's.

### Tools

88 tools are offered to a write key; a read key sees the ones marked "read key". "Call" is a ready
`tools/call` params object; "Result" shows the shape of `structuredContent` (types, not values; `{...}` is collapsed;
the item payload is described above).

### Domain: feed

#### `feed_sources`

*Sources in the feed* (read key)

List the sources present in the feed window (the recent entries a topic's feed ranks; all followed topics without topic_id) with how many entries each reported, most first: source is the code to use as source: in search_items or as source in topic_feed (ext:x and ext:linkedin are posts the user's own browser extension read), label is its name.

Arguments:

- `topic_id` (string): only this topic's feed window (optional; default all followed topics)

Call: `{"name":"feed_sources","arguments":{"topic_id":"6720e0a1b2c3d4e5f6a7b8c9"}}`

Result: `{"sources": [{…}]}`

#### `get_item`

*One item in full* (read key)

Fetch one item with its full text, entities, score and rationale, and the other reports of its event. summary_status is ready, pending, failed or none (a short post: its own text is the whole story, no summary is written) for the summary in the call's language: summaries are written when an item is asked for, so pending means one was just queued, call get_item again in a few seconds.

Arguments:

- `id` (string, required): the item id
- `lang` (string): language of summaries and translated titles (en, uk); default is the user's summary language

Call: `{"name":"get_item","arguments":{"id":"6720f1c2a5b7d9e8f0a1b2c3"}}`

Result: `{"id": "string", "url": "string", "title": "string", "original_title": "string", "text": "string", "body": "string", "body_truncated_from": 0, "summary": "string", "summary_status": "string", "excerpt": "string", "transcript_status": "string", "transcript_language": "string", "transcript_source": "string", "key_points": [{…}], "image": {"url": "string", "width": 0, "height": 0, "thumb_url": "string", …`

#### `new_items`

*Items since a cursor* (read key)

Fetch what matched or was scored since a cursor, oldest first. Call without a cursor first to get the cursor of now, then pass the returned cursor each time; while more is true call again. Reading never changes what the user sees as read. include=filtered also returns the items a topic in hide exclusion mode kept out (each has filtered_by: exclusion:<word> or filter:<description>, and no score).

Arguments:

- `topic_id` (string): only this topic (optional)
- `include` (string): filtered: also return the items a topic in hide exclusion mode kept out (filtered_by says why: exclusion:<word> or …
- `cursor` (string): the cursor of the previous answer; omit it the first time to get the cursor of now
- `lang` (string): language of summaries and translated titles (en, uk); default is the user's summary language

Call: `{"name":"new_items","arguments":{}}`

Result: `{"items": [{…}], "cursor": "string", "more": false, "retention_days": 0}`

#### `search_items`

*Search the feed (FQL)* (read key)

Search the user's feed with a query in the feedql query language (FQL). (FQL syntax: section 6 of this page.) include=filtered also returns the items a topic in hide exclusion mode kept out (each has filtered_by: exclusion:<word> or filter:<description>, and no score).

Arguments:

- `query` (string): an FQL query (see the tool description); empty lists the whole feed
- `include` (string): filtered: also return the items a topic in hide exclusion mode kept out (filtered_by says why: exclusion:<word> or …
- `cursor` (string): the next value of the previous answer, to get older items (or the next part of a sorted ranking)
- `lang` (string): language of summaries and translated titles (en, uk); default is the user's summary language
- `limit` (integer): rows in the page, 1 to 200; default 30
- `sort` (string): points (the source's own score: HN points, Reddit score), comments, engagement (points + comments) or relevance (the score the …

Call: `{"name":"search_items","arguments":{"query":"topic:golang since:1d score>=60"}}`

Result: `{"items": [{…}], "next": "string", "status": "string", "retry_after_seconds": 0, "message": "string", "retention_days": 0, "truncated": false}`

#### `similar_items`

*Items like this one* (read key)

Find items similar in meaning to one item among the user's own history (semantic search, Story 4.7): up to 10 rows over the last 1-90 days, each with the AI score or the free signal, source, topic, publication time and the cosine similarity. Only items the user already has; never another user's data.

Arguments:

- `item_id` (string, required): the item to find neighbours of
- `days` (integer): how far back to look, 1 to 90; default 30
- `limit` (integer): how many items, 1 to 10; default 5

Call: `{"name":"similar_items","arguments":{"item_id":"<item id>"}}`

Result: `{"items": [{…}], "note": "string"}`

#### `source_info`

*What a source is to me* (read key)

A source as this user experiences it, from their own last 30 days: how many items it gave in 24 hours and 7 days (rate per day), the time of its last item, its health (ok, quiet, silent), the user's own votes on its items (up, down, hidden, read, saved) and the topics that received items from it. The source id is a code from feed_sources.

Arguments:

- `source_id` (string, required): one source code from feed_sources (hackernews, reddit, github, site:dou.ua)

Call: `{"name":"source_info","arguments":{"source_id":"<source id>"}}`

Result: `{"source": "string", "label": "string", "posts_7d": 0, "posts_24h": 0, "per_day": 0.0, "last_post": "string", "health": "string", "my_votes": {"up": 0, "down": 0, "hidden": 0, "read": 0, "saved": 0}, "my_topics": ["string"]}`

#### `summarize_event`

*What all reports of the event say* (read key)

Read every report of the event an item belongs to: feedql's neutral summary of the event (empty until written; summary_written says), and each report's title, link, source and time. Use it to summarise or compare how sources reported the same thing.

Arguments:

- `item_id` (string, required): any report of the event

Call: `{"name":"summarize_event","arguments":{"item_id":"<item id>"}}`

Result: `{"item_id": "string", "event_id": "string", "summary": "string", "summary_written": false, "reports": [{…}], "sources": ["string"]}`

#### `topic_feed`

*Read a topic's feed* (read key)

Read one topic's feed, newest first, as the app shows it (score threshold applied). Pass the returned next as cursor for older items. sort=fresh orders newest first, sort=score highest score first (a raw topic's score is only informational); leave it out for the app's ranked order, and pass the same sort with every cursor.

Arguments:

- `topic_id` (string, required): the topic id from list_topics
- `cursor` (string): the next value of the previous answer, to get older items
- `lang` (string): language of summaries and translated titles (en, uk); default is the user's summary language
- `source` (string): only items reported by these sources, comma separated codes from feed_sources (hackernews,reddit,ext:linkedin); default all
- `sort` (string): fresh (newest first) or score (highest score first); default is the app's ranked order. Pass the same sort with every cursor

Call: `{"name":"topic_feed","arguments":{"topic_id":"6720e0a1b2c3d4e5f6a7b8c9"}}`

Result: `{"items": [{…}], "next": "string", "retention_days": 0, "truncated": false}`

#### `why_shown`

*Why is this item in my feed* (read key)

Explain from stored data, not from memory, why one item is shown to the user: the topic and its mode (raw: no AI score, a free signal; scored: an AI score against the topic's lowest score; must: a must-see entry), the score or the signal and its parts (reports, freshness, vote adjustment), the lowest score and whether the item passed it, the topic words and phrases found in the item, the exclusion words that did not fire, the ranker mode and rating, the source, when it was added and what is not known (listed in …

Arguments:

- `item_id` (string, required): the item id
- `topic_id` (string): explain the match in this topic (default: the topic that shows the item best)
- `lang` (string): language of the labels (en, uk); default is the user's summary language

Call: `{"name":"why_shown","arguments":{"item_id":"<item id>"}}`

Result: `{"item_id": "string", "title": "string", "topic_id": "string", "topic_title": "string", "mode": "string", "match_mode": "string", "score": 0, "reason": "string", "signal": 0, "signal_parts": [{…}], "threshold": 0, "passed": false, "matched_words": ["string"], "exclusion_words_not_fired": ["string"], "must_see": "string", "ranker": "string", "ranker_rating": 0.0, "vote_adjust": 0, "vote_like": "string", …`

### Domain: marks

#### `list_notes`

*Items with a note* (read key)

The items the user wrote a note on, most recent note first (at most 200), with their note in mine.note.

Arguments:

- `lang` (string): language of summaries and translated titles (en, uk); default is the user's summary language

Call: `{"name":"list_notes","arguments":{}}`

Result: `{"items": [{…}]}`

#### `list_saved`

*Saved items* (read key)

The user's saved (bookmarked) items, most recently saved first (at most 200), as item payloads with mine.

Arguments:

- `lang` (string): language of summaries and translated titles (en, uk); default is the user's summary language

Call: `{"name":"list_saved","arguments":{}}`

Result: `{"items": [{…}]}`

#### `mark_item`

*Vote, read, save or hide an item* (write key)

Do what the app's buttons do with an item of the user's feed: vote up (More like this) or down (Less like this), mark read, save (bookmark), or "not this" (hide). A down vote or hide teaches the topic: items similar in meaning to it score lower (up: higher), existing rows of the last 7 days update shortly after, and after three negatives from one place get_topic offers it in source_suggestions to turn off.

Arguments:

- `item_id` (string, required): the item id
- `topic_id` (string): the topic the item was seen in (its scoring learns from your votes there); default: the topic where the item scored best
- `vote` (string): up, down, or none to take the vote back; voting changes the shown score of the item itself (+10 / -30) and the feed learns by …
- `read` (boolean): mark read (true) or unread (false)
- `saved` (boolean): save (true) or unsave (false) the item; saved items are listed by list_saved
- `not_this` (boolean): true hides the item from the feed and removes any vote; false brings it back

Call: `{"name":"mark_item","arguments":{"item_id":"6720f1c2a5b7d9e8f0a1b2c3","saved":true,"vote":"up"}}`

Result: `{"item_id": "string", "before": {"vote": "string", "read": false, "saved": false, "not_this": false}, "after": {"vote": "string", "read": false, "saved": false, "not_this": false}}`

#### `mark_items`

*Mark many items read at once* (write key)

Mark up to 200 items of the user's feed read (or unread with read=false) in one call, like the app's multi-select. Items that are not in the user's feed are listed in not_found and skipped; the answer says which ids changed and which already had the state.

Arguments:

- `item_ids` (string[], required): the item ids to mark, at most 200
- `read` (boolean): true marks them read (default), false marks them unread

Call: `{"name":"mark_items","arguments":{"item_ids":["6720f1c2a5b7d9e8f0a1b2c3","6720f1c2a5b7d9e8f0a1b2c4"],"read":true}}`

Result: `{"changed": ["string"], "unchanged": ["string"], "not_found": ["string"]}`

#### `save_note`

*Write a note on an item* (write key)

Set the user's note on an item (at most 2000 characters); an empty text removes the note. Notes are listed by list_notes and shown as mine.note on the item.

Arguments:

- `item_id` (string, required): the item id
- `text` (string, required): the note (at most 2000 characters); empty removes the note

Call: `{"name":"save_note","arguments":{"item_id":"6720f1c2a5b7d9e8f0a1b2c3","text":"check the release notes before upgrading"}}`

Result: `{"item_id": "string", "note": "string"}`

#### `set_hide_read`

*Hide read items* (write key)

Set the "Hide read" preference: when on, items marked read leave the user's feed.

Arguments:

- `on` (boolean, required): true hides read items from the feed

Call: `{"name":"set_hide_read","arguments":{"on":true}}`

Result: `{"hide_read": false}`

### Domain: topics

#### `add_topic_links`

*Add sites and feeds to a topic* (write key)

Add sites, feeds, repositories or subreddits where the topic should look, one address per entry. An app store app can be pinned to a country: <App Store id>@<cc> (1232780281@gb) or <Google Play package>@<cc> (org.isoron.uhabits@de) read its reviews in that storefront (default us; an app link works too), and versions:<App Store id>@<cc> follows its release notes (items of kind release, place <id>@<cc>, metrics.version). A followed topic reads them at once; a website the service has not seen is learned first.

Arguments:

- `topic_id` (string, required)
- `links` (string[], required): addresses of sites, feeds, repositories or subreddits, one per entry

Call: `{"name":"add_topic_links","arguments":{"links":["https://go.dev/blog","r/golang","golang/go"],"topic_id":"6720e0a1b2c3d4e5f6a7b8c9"}}`

Result: `{"topic": {"id": "string", "title": "string", "channel_id": "string", "status": "string", "following": false, "paused": false, "raw": false, "funnel": {…}, "spend": {…}, "sites_only": false, "threshold": 0, "geo": ["string"], "languages": ["string"], "pack_version": 0, "about": "string", "ranker": "string", "github_issues": false, "github_releases_only": false, "teacher_votes": 0, "rubric": "string", …`

#### `create_topic`

*Create a topic* (write key)

Create a topic. With links and no title (or sites_only true) it is made of those sites alone: named after their hosts, no keywords, the feed shows everything the sites publish, AI scoring off (turn it on with update_topic raw false); poll nothing, it is ready at once. Otherwise without keyword_pack the model builds one (poll get_topic until status is ready, uses the daily AI budget); with a pack it is ready at once.

Arguments:

- `channel` (string): the channel to create the topic in (id or slug from list_channels); empty: the main channel, the reading feed
- `title` (string): what to follow, 1-120 characters; may be left out when links are given
- `keyword_pack` (object): words, phrases and excluded words; omit to let the model build one
- `links` (string[]): links to follow (repositories, feeds, subreddits, pages)
- `raw` (boolean): turn AI scoring off for this topic
- `sites_only` (boolean): make the topic from links alone: no keywords, everything the sites publish is the feed
- `geo` (string[]): limit to places: ISO country codes (UA, DE), EU, EUROPE, NAMER, LATAM, APAC, MENA, REMOTE
- `languages` (string[]): limit to items written in these languages (ISO 639-1: en, uk, de)
- `excluded` (string[]): exclusion words: items whose title or text has one never reach the feed; whole words or phrases, case-insensitive, a trailing * …
- `dont_show` (string[]): what the topic must not show, as short descriptions in any language ("road accidents", "crime", "obituaries"): items about it are …
- `must_see` (string[]): words or names whose items ALWAYS show ("Ivan Petrov"): they beat exclusion words, dont_show, turned-off places, vote-similarity …
- `markers` (string[]): raw phrases (case-insensitive substrings of an item title or text, at most 50 of 120 characters): the topic matches ONLY items …
- `about` (string): what you want from this topic, in your own words, at most 600 characters ("senior Go: runtime, performance, concurrency; no …
- `match` (string): how keyword_pack words, phrases and markers pre-select items before any model call: words (default; the item contains one as a …
- `exclude_mode` (string): drop (default): items kept out by excluded / dont_show are forgotten; hide: they are kept as filtered rows (never scored by the …

Call: `{"name":"create_topic","arguments":{"about":"Senior Go: runtime, performance, concurrency, releases. Not beginner tutorials.","languages":["en"],"title":"golang"}}`

Result: `{"topic": {"id": "string", "title": "string", "channel_id": "string", "status": "string", "following": false, "paused": false, "raw": false, "funnel": {…}, "spend": {…}, "sites_only": false, "threshold": 0, "geo": ["string"], "languages": ["string"], "pack_version": 0, "about": "string", "ranker": "string", "github_issues": false, "github_releases_only": false, "teacher_votes": 0, "rubric": "string", …`

#### `delete_topic`

*Delete a topic* (write key)

Delete a topic with its matches, runs and marks. This cannot be undone.

Arguments:

- `topic_id` (string, required): the topic id from list_topics

Call: `{"name":"delete_topic","arguments":{"topic_id":"<topic id>"}}`

Result: `{"deleted": false}`

#### `follow_topic`

*Follow a topic* (write key)

Follow a topic on the sources of a finished test run: pass sources (types) or all. After this its feed fills continuously.

Arguments:

- `topic_id` (string, required)
- `run` (integer): the test run number; defaults to the latest
- `sources` (string[]): source types to follow (see list_source_types)
- `all` (boolean): follow every source type of the run

Call: `{"name":"follow_topic","arguments":{"sources":["reddit","hackernews"],"topic_id":"6720e0a1b2c3d4e5f6a7b8c9"}}`

Result: `{"id": "string", "title": "string", "channel_id": "string", "status": "string", "following": false, "paused": false, "raw": false, "funnel": {"day": {…}, "week": {…}}, "spend": {"month_usd": 0.0, "last_30_days_usd": 0.0, "all_time_usd": 0.0, "by_kind": [{…}], "estimated": false, "attributed_since": "string"}, "sites_only": false, "threshold": 0, "geo": ["string"], "languages": ["string"], "pack_version": 0, …`

#### `get_test_run`

*Poll a test run* (read key)

Poll a test run: status is running until every source is done, then done. Pass run_id, or topic_id and n.

Arguments:

- `topic_id` (string): with n, instead of run_id
- `n` (integer): the run number; the id returned by start_test_run is topic_id:n
- `run_id` (string): the run id returned by start_test_run (topic_id:n)

Call: `{"name":"get_test_run","arguments":{"n":1,"topic_id":"6720e0a1b2c3d4e5f6a7b8c9"}}`

Result: `{"id": "string", "topic_id": "string", "n": 0, "status": "string", "pack_version": 0, "started_at": "string", "finished_at": "string", "total_items": 0, "sources": [{…}]}`

#### `get_topic`

*One topic* (read key)

One topic with its keyword pack, excluded words (excluded; excluded_suggestions are proposals not yet accepted, among them words that keep showing up in items the user marked Less like this; source_suggestions are places (a subreddit, account, site, repository) at least three of those items came from, muted_sources the places already turned off), dont_show (what is kept out by meaning) and must_see (what always shows), pasted links and (when followed) its sources and its funnel {day, week}: collected, filtered out …

Arguments:

- `topic_id` (string, required): the topic id from list_topics

Call: `{"name":"get_topic","arguments":{"topic_id":"6720e0a1b2c3d4e5f6a7b8c9"}}`

Result: `{"id": "string", "title": "string", "channel_id": "string", "status": "string", "following": false, "paused": false, "raw": false, "funnel": {"day": {…}, "week": {…}}, "spend": {"month_usd": 0.0, "last_30_days_usd": 0.0, "all_time_usd": 0.0, "by_kind": [{…}], "estimated": false, "attributed_since": "string"}, "sites_only": false, "threshold": 0, "geo": ["string"], "languages": ["string"], "pack_version": 0, …`

#### `list_source_types`

*Source types* (read key)

The source types a topic can follow (the names used by follow_topic and update_topic). jobs_only types carry vacancies and are offered only to topics that follow vacancies.

Arguments:

- none

Call: `{"name":"list_source_types","arguments":{}}`

Result: `{"sources": [{…}]}`

#### `list_topics`

*List topics* (read key)

List the user's topics (id, channel_id, title, status, whether it is followed) of one channel (channel_id is that channel's id, `main` for the reading feed): the main channel (the reading feed) by default, or channel=<id or slug> from list_channels. A followed topic carries funnel {day, week}: for the last 24 h and 7 days how many items were collected, filtered out and why (lang, excluded_words, topic_words, muted_sources, vote_similar, dont_show, far_by_meaning, below_threshold), shown, must_see, and read, …

Arguments:

- `channel` (string): the channel id or slug from list_channels; empty: the main channel (your reading feed)

Call: `{"name":"list_topics","arguments":{}}`

Result: `{"topics": [{…}]}`

#### `pin_topic`

*Pin a topic* (write key)

Pin or unpin a topic in the topic switcher (at most 8 pinned).

Arguments:

- `topic_id` (string, required)
- `pinned` (boolean, required): true pins the topic in the topic switcher (at most 8), false unpins

Call: `{"name":"pin_topic","arguments":{"pinned":true,"topic_id":"<topic id>"}}`

Result: `{"topic_id": "string", "pinned": false}`

#### `remove_topic_link`

*Remove a site or feed from a topic* (write key)

Remove one link (the key from get_topic links[].key) from a topic and stop reading it. A followed topic keeps at least one source. Returns the topic with topic.links[] and their state.

Arguments:

- `topic_id` (string, required)
- `key` (string, required): the link key from get_topic links[].key

Call: `{"name":"remove_topic_link","arguments":{"key":"<key>","topic_id":"<topic id>"}}`

Result: `{"topic": {"id": "string", "title": "string", "channel_id": "string", "status": "string", "following": false, "paused": false, "raw": false, "funnel": {…}, "spend": {…}, "sites_only": false, "threshold": 0, "geo": ["string"], "languages": ["string"], "pack_version": 0, "about": "string", "ranker": "string", "github_issues": false, "github_releases_only": false, "teacher_votes": 0, "rubric": "string", …`

#### `retry_topic`

*Build the keyword pack again* (write key)

Ask the model for the keyword pack again after it failed (status failed). A topic in any other state is returned unchanged. Poll get_topic until status is ready.

Arguments:

- `topic_id` (string, required): the topic id from list_topics

Call: `{"name":"retry_topic","arguments":{"topic_id":"<topic id>"}}`

Result: `{"id": "string", "title": "string", "channel_id": "string", "status": "string", "following": false, "paused": false, "raw": false, "funnel": {"day": {…}, "week": {…}}, "spend": {"month_usd": 0.0, "last_30_days_usd": 0.0, "all_time_usd": 0.0, "by_kind": [{…}], "estimated": false, "attributed_since": "string"}, "sites_only": false, "threshold": 0, "geo": ["string"], "languages": ["string"], "pack_version": 0, …`

#### `start_test_run`

*Start a test run* (write key)

Start a test run of a ready topic across the sources (scores found items with the model within the daily AI budget). Pass sources (types from list_source_types) to test only those; a type the topic is not offered is refused. Poll get_test_run with the returned id.

Arguments:

- `topic_id` (string, required)
- `sources` (string[]): limit the run to these source types (see list_source_types); default all offered to the topic

Call: `{"name":"start_test_run","arguments":{"topic_id":"6720e0a1b2c3d4e5f6a7b8c9"}}`

Result: `{"id": "string", "topic_id": "string", "n": 0, "status": "string", "pack_version": 0, "started_at": "string", "finished_at": "string", "total_items": 0, "sources": [{…}]}`

#### `unfollow_topic`

*Unfollow a topic* (write key)

Stop following a topic (it stays as a draft with its keyword pack).

Arguments:

- `topic_id` (string, required): the topic id from list_topics

Call: `{"name":"unfollow_topic","arguments":{"topic_id":"<topic id>"}}`

Result: `{"id": "string", "title": "string", "channel_id": "string", "status": "string", "following": false, "paused": false, "raw": false, "funnel": {"day": {…}, "week": {…}}, "spend": {"month_usd": 0.0, "last_30_days_usd": 0.0, "all_time_usd": 0.0, "by_kind": [{…}], "estimated": false, "attributed_since": "string"}, "sites_only": false, "threshold": 0, "geo": ["string"], "languages": ["string"], "pack_version": 0, …`

#### `update_topic`

*Edit a topic* (write key)

Change a topic: title, keyword_pack (with pack_version from get_topic; a stale one is a conflict), excluded (replaces the exclusion words, [] clears them; whole words or phrases, case-insensitive, a trailing * is a prefix; on a followed topic the last 7 days are re-judged and matching items leave the feed), mute_sources / unmute_sources (turn a place from get_topic source_suggestions[].key off for this topic, or back on: its items stop matching and the ones in the feed are removed), match (words, phrase, parts or …

Arguments:

- `topic_id` (string, required)
- `changes` (object, required): the fields to change; only present ones apply

Call: `{"name":"update_topic","arguments":{"changes":{"excluded":["hiring","crypto*"],"threshold":60},"topic_id":"6720e0a1b2c3d4e5f6a7b8c9"}}`

Result: `{"topic": {"id": "string", "title": "string", "channel_id": "string", "status": "string", "following": false, "paused": false, "raw": false, "funnel": {…}, "spend": {…}, "sites_only": false, "threshold": 0, "geo": ["string"], "languages": ["string"], "pack_version": 0, "about": "string", "ranker": "string", "github_issues": false, "github_releases_only": false, "teacher_votes": 0, "rubric": "string", …`

### Domain: data

#### `get_export`

*Export status and download* (read key)

One export or stats job: status (queued, running, ready, failed), rows, size and, when ready, a fresh signed download_url (24 hours) or, for a stats job, its result. Without an id it lists the recent exports.

Arguments:

- `id` (string): the export id from start_export or item_stats; empty lists the recent exports

Call: `{"name":"get_export","arguments":{"id":"6720f1c2a5b7d9e8f0a1b2c5"}}`

Result: `…`

#### `item_stats`

*Count items by day, topic, source…* (read key)

Count the items an FQL query finds, grouped by day, week, topic, source, lang, score_bucket or author_count, with the average score and the distinct authors (authors) per bucket; author_count groups by topic and ranks by distinct authors. Counts what the search shows the reader (one per item). A typical range answers at once; a range too big for that answers {status: queued, job_id}: read the result with get_export.

Arguments:

- `query` (string): an FQL query; empty counts the whole feed
- `by` (string, required): day, week, topic, source, lang, score_bucket or author_count
- `include` (string): filtered: also return the items a topic in hide exclusion mode kept out (filtered_by says why: exclusion:<word> or …

Call: `{"name":"item_stats","arguments":{"by":"day","query":"topic:golang since:30d"}}`

Result: `{"by": "string", "buckets": [{…}], "total": 0, "scanned": 0, "capped": false, "status": "string", "job_id": "string"}`

#### `start_export`

*Export your data* (write key)

Start an export of every item matching an FQL query (empty: the whole feed) as a gzip file of JSON lines or CSV: title, url, source, author hash, published, score, rationale, topic, language, event and its reports, feedql's summary in the user's language, the excerpt and the user's vote, save and note; never the full article text. The answer is the export with status queued: poll get_export until ready, then fetch its download_url (signed, valid 24 hours; files are deleted after 7 days).

Arguments:

- `query` (string): an FQL query: every matching item is exported; empty exports the whole feed (no ~meaning terms)
- `format` (string): jsonl (default, one JSON item per line) or csv
- `include` (string): filtered: also return the items a topic in hide exclusion mode kept out (filtered_by says why: exclusion:<word> or …

Call: `{"name":"start_export","arguments":{"format":"jsonl","query":"topic:golang since:30d"}}`

Result: `{"id": "string", "channel": "string", "kind": "string", "status": "string", "q": "string", "format": "string", "include": "string", "by": "string", "rows": 0, "bytes": 0, "truncated": false, "error": "string", "created_at": "string", "finished_at": "string", "expires_at": "string", "filename": "string", "download_url": "string", "result": {"by": "string", "buckets": [{…}], "total": 0, "scanned": 0, …`

### Domain: webhooks

#### `create_inbound_webhook`

*Create an inbound webhook* (write key)

Make an address that accepts pushed items into one of the user's topics. POST /in/<uuid> takes one item or {"items":[...]} (up to 100) with title, url, text, published, author, source, tags, fields{}, metrics{}, id; authenticate with Authorization: Bearer <key> or an HMAC signature (see llm.md). The answer carries the secret key once; it cannot be read again. At most 20 per account. Items become source in:<name> and are private to the user.

Arguments:

- `name` (string, required): 1 to 40 characters; items get the source in:<name as a slug>
- `topic_id` (string): the topic that receives the items (main feed or a channel). Omit it to get a new raw, already followed topic named Inbox: <name>, …
- `mapping` (object): optional: item field (title, url, text, published, author, source, tags, id) to a dotted path in the pushed object, for example …

Call: `{"name":"create_inbound_webhook","arguments":{"name":"<name>"}}`

Result: `{"webhook": {"id": "string", "name": "string", "address": "string", "source": "string", "topic_id": "string", "channel_id": "string", "mapping": {"<key>": "string"}, "enabled": false, "key_hint": "string", "created_at": "string", "key_rotated_at": "string", "last_request_at": "string", "requests": 0, "failed_requests": 0, "items_created": 0, "items_duplicate": 0, "items_rejected": 0}, "key": "string", …`

#### `create_webhook`

*Create a webhook* (write key)

Push a topic's new items (or, with channel, every new item of a channel) to a public https URL, signed with X-Feedql-Signature (HMAC-SHA256 of the body). The answer carries the signing secret once; it cannot be read again. At most 3 per topic or channel and 10 per account. Delivery starts from now.

Arguments:

- `topic_id` (string): the topic whose new items are pushed (or channel instead)
- `channel` (string): the channel (id or slug) whose new items are all pushed, instead of one topic
- `url` (string, required): a public https URL that accepts POST

Call: `{"name":"create_webhook","arguments":{"topic_id":"6720e0a1b2c3d4e5f6a7b8c9","url":"https://example.com/feedql-hook"}}`

Result: `{"webhook": {"id": "string", "topic_id": "string", "channel_id": "string", "items_today": 0, "url": "string", "status": "string", "created_at": "string", "delivered_items": 0, "last_delivered_at": "string", "failing_since": "string", "attempts": 0, "last_error": "string", "disabled_at": "string", "disabled_reason": "string"}, "secret": "string"}`

#### `delete_inbound_webhook`

*Delete an inbound webhook* (write key)

Delete the webhook with its key and delivery log; its address answers 401 from then on. Items it already brought stay in the feed.

Arguments:

- `id` (string, required): the inbound webhook id (the uuid inside its address)

Call: `{"name":"delete_inbound_webhook","arguments":{"id":"<id>"}}`

Result: `{"deleted": false}`

#### `delete_webhook`

*Delete a webhook* (write key)

Delete a webhook; nothing more is sent to its URL.

Arguments:

- `id` (string, required): the webhook id

Call: `{"name":"delete_webhook","arguments":{"id":"<id>"}}`

Result: `{"deleted": false}`

#### `enable_webhook`

*Re-enable a webhook* (write key)

Turn a disabled webhook back on (after 24 hours of failures it is disabled): clears the failure state and delivers what was missed.

Arguments:

- `id` (string, required): the webhook id

Call: `{"name":"enable_webhook","arguments":{"id":"<id>"}}`

Result: `{"id": "string", "topic_id": "string", "channel_id": "string", "items_today": 0, "url": "string", "status": "string", "created_at": "string", "delivered_items": 0, "last_delivered_at": "string", "failing_since": "string", "attempts": 0, "last_error": "string", "disabled_at": "string", "disabled_reason": "string"}`

#### `get_webhook`

*One webhook* (read key)

One webhook with its delivery state: items delivered, failing since, attempts, last error, why it was disabled.

Arguments:

- `id` (string, required): the webhook id

Call: `{"name":"get_webhook","arguments":{"id":"<id>"}}`

Result: `{"id": "string", "topic_id": "string", "channel_id": "string", "items_today": 0, "url": "string", "status": "string", "created_at": "string", "delivered_items": 0, "last_delivered_at": "string", "failing_since": "string", "attempts": 0, "last_error": "string", "disabled_at": "string", "disabled_reason": "string"}`

#### `inbound_webhook_deliveries`

*Inbound webhook deliveries* (read key)

The last requests the webhook received (up to 50, newest first): time, status, items, how many were created, duplicate or rejected, the error and the first 200 characters of the first item. Never more of the payload.

Arguments:

- `id` (string, required)
- `limit` (integer): 1 to 50, default 50

Call: `{"name":"inbound_webhook_deliveries","arguments":{"id":"<id>"}}`

Result: `{"deliveries": [{…}]}`

#### `list_inbound_webhooks`

*List inbound webhooks* (read key)

The user's inbound webhooks: addresses (https://api.feedql.com/in/<uuid>) that accept JSON pushed from scripts, Zapier, n8n, CI or monitoring and write it as items into one of the user's topics. Each with its target topic, on/off, key hint (never the key) and counters.

Arguments:

- none

Call: `{"name":"list_inbound_webhooks","arguments":{}}`

Result: `{"webhooks": [{…}]}`

#### `list_webhooks`

*List webhooks* (read key)

The user's webhooks (push of new items to a URL), all or one topic's, with delivery state and the last error.

Arguments:

- `topic_id` (string): only this topic's webhooks
- `channel` (string): only the webhooks attached to this channel (id or slug)

Call: `{"name":"list_webhooks","arguments":{}}`

Result: `{"webhooks": [{…}]}`

#### `rotate_inbound_webhook_key`

*Rotate an inbound webhook's key* (write key)

Give the webhook a new secret key; the old one stops working at once. The new key is in the answer once.

Arguments:

- `id` (string, required): the inbound webhook id (the uuid inside its address)

Call: `{"name":"rotate_inbound_webhook_key","arguments":{"id":"<id>"}}`

Result: `{"webhook": {"id": "string", "name": "string", "address": "string", "source": "string", "topic_id": "string", "channel_id": "string", "mapping": {"<key>": "string"}, "enabled": false, "key_hint": "string", "created_at": "string", "key_rotated_at": "string", "last_request_at": "string", "requests": 0, "failed_requests": 0, "items_created": 0, "items_duplicate": 0, "items_rejected": 0}, "key": "string"}`

#### `update_inbound_webhook`

*Change an inbound webhook* (write key)

Rename an inbound webhook, move it to another topic, change its field mapping or switch it on or off. The address and the key stay.

Arguments:

- `id` (string, required)
- `name` (string)
- `topic_id` (string): move the webhook to another topic
- `mapping` (object): replaces the mapping; {} clears it
- `enabled` (boolean): false: the address answers 404 to the right key until it is switched on again

Call: `{"name":"update_inbound_webhook","arguments":{"id":"<id>"}}`

Result: `{"id": "string", "name": "string", "address": "string", "source": "string", "topic_id": "string", "channel_id": "string", "mapping": {"<key>": "string"}, "enabled": false, "key_hint": "string", "created_at": "string", "key_rotated_at": "string", "last_request_at": "string", "requests": 0, "failed_requests": 0, "items_created": 0, "items_duplicate": 0, "items_rejected": 0}`

### Domain: account

#### `create_api_key`

*Create an API key* (write key)

Make an API key for the user (fq_…, for scripts and other agents). Needs name; scope is read (default) or write and never above the access of the connection making the call; expires_in_days is 1 to 365 (default 30) - a key that never expires cannot be made here. The answer carries the full secret ONCE (key): it cannot be read again, so give it to the user or store it right away. At most 10 live keys per user and 5 keys created through MCP an hour. The key is marked as made by MCP in Settings.

Arguments:

- `name` (string, required): what the key is for, 1 to 60 characters
- `scope` (string): read (default) or write; never above the access of the connection making the call
- `expires_in_days` (integer): days until the key stops working: 1 to 365, default 30; a key that never expires cannot be made here

Call: `{"name":"create_api_key","arguments":{"name":"<name>"}}`

Result: `{"id": "string", "name": "string", "scope": "string", "key": "string", "display": "string", "expires_at": "string", "origin": "string"}`

#### `ext_site_set`

*Switch a browser-extension site on or off* (write key)

Tell feedql whether the browser extension should read a site for the user (the extension popup's toggle; the extension itself still needs the browser permission, which only the user can grant). Sites are the keys of ext_status.

Arguments:

- `site` (string, required): the site tag from ext_status, for example linkedin
- `enabled` (boolean, required): true: the extension reads this site for the user; false: it stops

Call: `{"name":"ext_site_set","arguments":{"enabled":true,"site":"<site>"}}`

Result: `{"site": "string", "enabled": false, "note": "string"}`

#### `ext_status`

*Browser extension status* (read key)

What the feedql browser extension did for the user: per offered site (x, linkedin, and any site added as a recipe) whether it is switched on, last_synced_at (the last successful update, the time the web app shows as "last update"), the last attempt, whether it succeeded, how many posts it found, any back-off, the recipe version the extension reads the site with, and totals of posts pushed, accepted and already known, plus today's allowance.

Arguments:

- none

Call: `{"name":"ext_status","arguments":{}}`

Result: `{"sites": {"<key>": {…}}, "daily": {"used": 0, "cap": 0}}`

#### `get_account`

*My account, limits and spend* (read key)

The user's email, interface and summary languages, limits (topics, sources per topic, daily AI budget) and what is used today.

Arguments:

- none

Call: `{"name":"get_account","arguments":{}}`

Result: `{"email": "string", "ui_language": "string", "summary_language": "string", "show_images": false, "limits": {"topics": 0, "extra_channels": 0, "sources_per_topic": 0, "ai_daily_usd": 0.0}, "usage": {"topics": 0, "followed_topics": 0, "ai_spent_today_usd": 0.0, "ai_remaining_today_usd": 0.0}, "supported_languages": ["string"], "created_at": "string"}`

#### `list_api_keys`

*My API keys* (read key)

The user's API keys: name, scope (read or write), the start of the key, last use. Also whether it expires (expires_at), has expired, and where it was made (origin mcp: by an agent). Secrets are never shown. Make a key with create_api_key, revoke one with revoke_api_key, or use the app (Settings, API keys); at most 10 live keys.

Arguments:

- none

Call: `{"name":"list_api_keys","arguments":{}}`

Result: `{"api_keys": [{…}]}`

#### `revoke_api_key`

*Revoke an API key* (write key)

Revoke one of the user's API keys by id (from list_api_keys); it stops working at once. The key making this call cannot be revoked (conflict): use another key or the app. Revoking a revoked key is fine. Connections (Connect to Claude) are not keys; those are disconnected in the app.

Arguments:

- `id` (string, required): the key's id from list_api_keys

Call: `{"name":"revoke_api_key","arguments":{"id":"<id>"}}`

Result: `{"id": "string", "revoked": false}`

#### `set_languages`

*Interface and summary languages* (write key)

Change the interface language and/or the language summaries and translated titles are written in (en, uk). Omitted ones stay.

Arguments:

- `ui_language` (string): interface language (en, uk); omit to keep
- `summary_language` (string): language of summaries and translated titles (en, uk); omit to keep

Call: `{"name":"set_languages","arguments":{}}`

Result: `{"ui_language": "string", "summary_language": "string"}`

#### `set_show_images`

*Show images in the app* (write key)

Turn the thumbnails in the feed and the article pictures in the app on or off for this user (the Settings checkbox "Show images"). It changes only what the app shows: items still carry image and images in the API and MCP.

Arguments:

- `show` (boolean, required): true shows thumbnails and article pictures in the app, false hides them (saves data)

Call: `{"name":"set_show_images","arguments":{"show":true}}`

Result: `{"show_images": false}`

### Domain: profile

#### `get_memory`

*What feedql knows about me* (read key)

The short note feedql keeps of what the user likes, learned from their votes, saves, opens and hides. Lines the user wrote or changed are marked mine. Private to the user.

Arguments:

- none

Call: `{"name":"get_memory","arguments":{}}`

Result: `{"lines": [{…}], "updated_at": "string", "updating": false}`

#### `get_soul`

*My About me* (read key)

The user's own words about who they are and what matters to them (role, city, interests, what to ignore), and their per-topic notes. feedql rates items against it. Private to the user.

Arguments:

- none

Call: `{"name":"get_soul","arguments":{}}`

Result: `{"about": "string", "updating": false, "topic_notes": {"<key>": "string"}}`

#### `set_memory`

*Edit or wipe my memory* (write key)

Replace the memory with the given lines (a line added or changed is kept as written; a learned line left out is not said again), or wipe it all with wipe=true.

Arguments:

- `lines` (string[]): the memory as it should now be, one preference per line (at most 12 lines of 4-200 characters, no links); a line you add or …
- `wipe` (boolean): true forgets everything feedql learned (lines are ignored)

Call: `{"name":"set_memory","arguments":{}}`

Result: `{"lines": [{…}], "updated_at": "string", "updating": false}`

#### `set_soul`

*Write my About me* (write key)

Replace the user's About me text (free text, markdown allowed, at most 2000 characters; empty clears it). feedql reads it in the background and uses it to rate items for the user.

Arguments:

- `about` (string, required): free text, markdown allowed, at most 2000 characters; empty clears it

Call: `{"name":"set_soul","arguments":{"about":"<about>"}}`

Result: `{"about": "string", "updating": false, "topic_notes": {"<key>": "string"}}`

#### `set_topic_matters`

*What matters in a topic* (write key)

Set or clear (empty text) the note on what matters to the user in one topic (at most 400 characters). It counts for relevance in that topic, most of all in a topic made of sites alone.

Arguments:

- `topic_id` (string, required)
- `text` (string, required): what matters in this topic, at most 400 characters; empty removes it

Call: `{"name":"set_topic_matters","arguments":{"text":"<text>","topic_id":"<topic id>"}}`

Result: `{"topic_id": "string", "text": "string"}`

### Domain: channels

#### `add_channel_sources`

*Attach raw sources to a channel* (write key)

Attach collection sources straight to a channel: what they publish is the channel's items, unscored, with no keywords, no model and no AI budget. Hundreds at a time: App Store apps <id>@<cc>, Google Play apps <package>@<cc>[:<lang>], versions:<id>@<cc> release notes, stored searches and lists (hackernews:/reddit:search:/github:search:/discourse:<host>/search/…/stackexchange:<site>/…/appstore:search:/googleplay:search:|chart:/listings:/federalregister:icr/n8n:all, forms in the sources field), or any supported …

Arguments:

- `channel` (string, required): the channel id or slug from list_channels
- `sources` (string[], required): up to 500 sources: App Store <id>@<cc> or appstore:<id>@<cc>, Google Play <package>@<cc>[:<lang>] or googleplay:<package>@<cc>, …
- `where` (string): a filter every item of these sources must pass, e.g. rating<=3 or rating<=3,app_genre=Games (keys: metrics such as rating, fields …
- `mode` (string): what an item failing where becomes: hide (default; kept, read with include filtered) or drop (not stored for the channel)
- `interval_min` (integer): read each source every this many minutes (1 to 43200); 0 or omitted: the automatic schedule
- `inactive` (boolean): add them switched off (listed, not collected until update_channel_source active=true)
- `spread` (boolean): spread the first read of each over its own interval instead of reading all at once (use for hundreds)

Call: `{"name":"add_channel_sources","arguments":{"channel":"<channel>","sources":["<source>"]}}`

Result: `{"added": [{…}], "existing": [{…}], "problems": [{…}]}`

#### `channel_entity`

*One entity of a channel's graph* (read key; also offered to a channel key)

The entity view of the web app for one entity=type:value (e.g. tool:stripe, repo:golang/go): how many items name it in the period (items) and all time (total), first_seen and last_seen, its mentions over time (series: buckets points over the period, oldest first, each with the bucket start and the number of items), its neighbours (items naming both, weight, up to three sample item ids) and the most recent item ids that name it (fetch them with get_item). found false: the channel never named it.

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none
- `entity` (string, required): type:value, e.g. tool:stripe or repo:owner/name
- `since` (string): the period: a date (2026-10-01), an RFC 3339 time or a span like 30d; default all time
- `limit` (integer): how many neighbours and recent item ids (default 20, at most 100)
- `buckets` (integer): points of the mentions-over-time series (default 13, at most 120)

Call: `{"name":"channel_entity","arguments":{"entity":"<entity>"}}`

Result: `{"enabled": false, "found": false, "entity": {"type": "string", "value": "string", "items": 0}, "first_seen": "string", "last_seen": "string", "total": 0, "series": [{…}], "neighbours": [{…}], "recent_items": ["string"]}`

#### `channel_follow_status`

*Where an import's test runs and follows stand* (read key)

The summary of import_channel with follow true: state running or done, and per topic pending, running (pack or test run going), followed or failed with found (items the test run listed; 0 is reported plainly), scanned, the source types followed, a note and the reason a topic was not followed. Poll it every few seconds until state is done; a topic with a pack to build can take a minute, a test run up to three.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels

Call: `{"name":"channel_follow_status","arguments":{"channel":"<channel>"}}`

Result: `{"channel": "string", "state": "string", "topics": [{…}], "followed": 0, "failed": 0, "waiting": 0, "started_at": "string", "finished_at": "string"}`

#### `channel_graph`

*Entity graph of a channel* (read key; also offered to a channel key)

What a channel's items talk about and what is named together. Without entity: the top entities by the number of items that named them (types tool, repo, app, company, domain), optionally one type and since a date or span. With entity=type:value (e.g. tool:stripe): that entity's item count and its neighbours, each with the weight (items naming both) and up to three sample item ids to fetch with get_item. Needs the channel option graph (update_channel payload.graph); with it off the answer has enabled false.

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none
- `entity` (string): type:value, e.g. tool:stripe or repo:owner/name; returns its neighbours instead of the top entities
- `type` (string): only entities of this type: tool, repo, app, company, domain
- `since` (string): only entities seen since: a date (2026-10-01), an RFC 3339 time or a span like 7d or 36h
- `limit` (integer): how many to return (default 20, at most 100)

Call: `{"name":"channel_graph","arguments":{}}`

Result: `{"enabled": false, "entities": [{…}], "entity": {"type": "string", "value": "string", "items": 0}, "neighbours": [{…}]}`

#### `channel_items`

*Search a channel (FQL)* (read key; also offered to a channel key)

Search the items a channel received, newest first, with a query in the feedql query language (empty: everything). Pass the returned next as cursor for older items. (FQL syntax: section 6 of this page.) include=filtered also returns the items a topic in hide exclusion mode kept out (each has filtered_by: exclusion:<word> or filter:<description>, and no score). view=filtered leaves out the raw stream of the channel's own sources (the app's Filtered view); the default view=all returns everything.

Arguments:

- `channel` (string): the channel id or slug; a session made with a channel key is bound to its channel and needs none
- `query` (string): an FQL query; empty lists everything the channel received
- `include` (string): filtered: also return the items a topic in hide exclusion mode kept out (filtered_by says why: exclusion:<word> or …
- `cursor` (string): the next value of the previous answer, to get older items
- `lang` (string): language of summaries and translated titles (en, uk); default is the user's summary language
- `group` (string): item: list each item once with a topics array (topic id, title, score, rationale, seq) of the topics it matched; default: one row …
- `limit` (integer): rows in the page, 1 to 200; default 30
- `sort` (string): points, comments, engagement or relevance: the most first, each item once; needs since: in the query; truncated: true says the …
- `legacy_id` (string): the id the item had in news-cli; returns just that item (imported history); query and cursor are ignored
- `view` (string): filtered: only the channel's own topics, without the raw stream of its sources; all (default): everything the channel received

Call: `{"name":"channel_items","arguments":{}}`

Result: `{"items": [{…}], "next": "string", "status": "string", "retry_after_seconds": 0, "message": "string", "retention_days": 0, "truncated": false}`

#### `channel_keep`

*Pin items of a channel* (write key; also offered to a channel key)

Pin items (by ids or legacy_ids, up to 5000 a call) so the channel's retention never removes them, like a Save: while pinned they stay however old they are. Answers requested, changed (rows pinned) and unknown (ids that no item has).

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none
- `ids` (string[]): item ids to pin or unpin
- `legacy_ids` (string[]): ids the items had in news-cli (imported history)

Call: `{"name":"channel_keep","arguments":{}}`

Result: `{"requested": 0, "changed": 0, "unknown": ["string"]}`

#### `channel_markers`

*Marker hit statistics of a channel* (read key; also offered to a channel key)

How precise each topic marker is (raw phrases set with markers on create_topic / update_topic): per topic and marker the items it hit, how many of those were good (scored at or above the topic threshold, or voted up) and the rate good/hits, over the last 7 and 30 days. Markers without a hit are listed with zeros; a marker that no longer belongs to the topic is not. Counters are written by the pipeline in batches, so a hit appears within a minute of the item arriving.

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none

Call: `{"name":"channel_markers","arguments":{}}`

Result: `{"topics": [{…}]}`

#### `channel_network`

*Network of a channel's top entities* (read key; also offered to a channel key)

The network view of the web app: the top entities of a period (nodes, each with the number of items that named it) and the co-mentions among them (edges: a and b are indices into nodes, weight is the items naming both), heaviest first. type narrows to one kind, nodes is how many (default 60, at most 300), since a date or span like 30d. total is how many entities the period holds. Needs the channel option graph.

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none
- `type` (string): only entities of this type: tool, repo, app, company, domain
- `since` (string): the period: a date (2026-10-01), an RFC 3339 time or a span like 30d; default all time
- `nodes` (integer): how many top entities (default 60, at most 300)

Call: `{"name":"channel_network","arguments":{}}`

Result: `{"enabled": false, "total": 0, "nodes": [{…}], "edges": [{…}]}`

#### `channel_new_items`

*A channel's items since a cursor* (read key; also offered to a channel key)

What reached a channel since a cursor, oldest first, for polling programs. Call without a cursor first to get the cursor of now, then pass the returned cursor each time; while more is true call again. Reading changes nothing. include=filtered also returns the items a topic in hide exclusion mode kept out (each has filtered_by: exclusion:<word> or filter:<description>, and no score). view=filtered leaves out the raw stream of the channel's own sources; default all.

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none
- `topic_id` (string): only this topic of the channel
- `include` (string): filtered: also return the items a topic in hide exclusion mode kept out (filtered_by says why: exclusion:<word> or …
- `cursor` (string): the cursor of the previous answer; omit it the first time to get the cursor of now
- `lang` (string): language of summaries and translated titles (en, uk)
- `view` (string): filtered: only the channel's own topics, without the raw stream of its sources; all (default): everything the channel received

Call: `{"name":"channel_new_items","arguments":{}}`

Result: `{"items": [{…}], "cursor": "string", "more": false, "retention_days": 0}`

#### `channel_sources`

*A channel's raw sources* (read key)

The raw collection sources attached to a channel (no keywords, no model, no AI spend): id, key, type, label, active, interval_min (0: automatic), where and mode (the item filter), when last collected, the last error and when it runs next. Newest first, a page at a time (offset, limit; total says how many match); query, type and status narrow the list. Add with add_channel_sources; the number you may have is the sources-per-topic limit in get_account.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels
- `offset` (integer): skip this many sources (newest first)
- `limit` (integer): page size, 1 to 500 (default 200)
- `query` (string): only sources whose title or key (handle, link) contains this, case-insensitive
- `type` (string): only sources of this type, e.g. reddit, appstore
- `status` (string): paused (switched off) or failing (the last read failed)

Call: `{"name":"channel_sources","arguments":{"channel":"<channel>"}}`

Result: `{"sources": [{…}], "total": 0, "offset": 0}`

#### `channel_sparql`

*SPARQL over a channel's graph* (read key; also offered to a channel key)

Ask the channel's entity graph a SPARQL 1.1 question: SELECT (rows of variable to value), ASK (boolean) or CONSTRUCT (Turtle). Read-only and scoped to this channel's own graph; at most 1000 rows, 5 seconds. Namespace https://feedql.com/ns# (fq): fq:Item (fq:published xsd:dateTime, fq:fromSource, fq:inTopic, fq:inChannel, fq:mentions) and fq:Entity with the kinds fq:Tool, fq:Repo, fq:App, fq:Company, fq:Domain (fq:entityType 'tool', rdfs:label the name); property paths work (?e a/rdfs:subClassOf* fq:Entity).

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none
- `query` (string, required): a SPARQL 1.1 SELECT, ASK or CONSTRUCT over the channel's graph (default graph = this channel; no UPDATE, DESCRIBE, FROM, SERVICE)

Call: `{"name":"channel_sparql","arguments":{"query":"<query>"}}`

Result: `{"type": "string", "vars": ["string"], "rows": [{"<key>": "string"}], "boolean": false, "turtle": "string", "truncated": false}`

#### `channel_stats`

*Count a channel's items* (read key; also offered to a channel key)

Count the items of a channel an FQL query finds, grouped by day, week, topic, source, lang, score_bucket or author_count, with the average score and the distinct authors (authors) per bucket; author_count groups by topic and ranks by distinct authors. A typical range answers at once; a range too big answers {status: queued, job_id}: read it with get_export. include=filtered also returns the items a topic in hide exclusion mode kept out (each has filtered_by: exclusion:<word> or filter:<description>, and no score).

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none
- `query` (string): an FQL query; empty counts everything
- `include` (string): filtered: also return the items a topic in hide exclusion mode kept out (filtered_by says why: exclusion:<word> or …
- `by` (string, required): day, week, topic, source, lang, score_bucket or author_count

Call: `{"name":"channel_stats","arguments":{"by":"<by>"}}`

Result: `{"by": "string", "buckets": [{…}], "total": 0, "scanned": 0, "capped": false, "status": "string", "job_id": "string"}`

#### `channel_unkeep`

*Unpin items of a channel* (write key; also offered to a channel key)

Take the pin off items (same arguments as channel_keep); they expire with the channel's retention again.

Arguments:

- `channel` (string): the channel id or slug; a channel-key session needs none
- `ids` (string[]): item ids to pin or unpin
- `legacy_ids` (string[]): ids the items had in news-cli (imported history)

Call: `{"name":"channel_unkeep","arguments":{}}`

Result: `{"requested": 0, "changed": 0, "unknown": ["string"]}`

#### `channel_usage`

*A channel's volume and spend* (read key)

How a channel did in the last 7 days, sizes only: items per day, matched / scored / waiting / without an AI score, dropped by don't-show filters, the top sources, the sources that fail, spend today and in 7 days against the channel's own daily AI cap, how many items have custom field values, and llm_spend: what the channel and its topics cost in model calls this month, in the last 30 days and all time, by kind of work with tokens.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels

Call: `{"name":"channel_usage","arguments":{"channel":"<channel>"}}`

Result: `{"days": [{…}], "matched": 0, "scored": 0, "waiting": 0, "unscored": 0, "dropped_by_filters": 0, "top_sources": [{…}], "failing_sources": [{…}], "spend_today_micro": 0, "spend_7d_micro": 0, "cap_micro": 0, "fields_filled": 0, "spend_today_usd": 0.0, "spend_7d_usd": 0.0, "cap_usd": 0.0, "llm_spend": {"month_usd": 0.0, "last_30_days_usd": 0.0, "all_time_usd": 0.0, "by_kind": [{…}], "estimated": false, …`

#### `collect_channel_source`

*Collect a channel source now* (write key)

Make one source due at once; the collector starts it within a minute (rate limits of the upstream still apply, a blocked host stays blocked). Read the result in channel_sources: last_collected_at and last_error. A switched-off source is refused.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels
- `source` (string, required): the source id (src_…) from channel_sources

Call: `{"name":"collect_channel_source","arguments":{"channel":"<channel>","source":"<source>"}}`

Result: `{"id": "string", "key": "string", "type": "string", "label": "string", "url": "string", "active": false, "interval_min": 0, "interval_fixed": false, "where": "string", "mode": "string", "added_at": "string", "last_collected_at": "string", "last_error": "string", "next_run_at": "string"}`

#### `create_channel`

*Create a channel* (write key)

Create an extra channel (2 by default per user, raised by the admin). Then add its topics with create_topic channel=<id>, follow them, and read the data with channel_items or at https://api.feedql.com/c/<id>/items with a channel key.

Arguments:

- `name` (string, required): 1-60 characters
- `slug` (string): lowercase letters, digits and dashes, 1-40 characters; made from the name when left out
- `description` (string): what the channel is for, at most 300 characters

Call: `{"name":"create_channel","arguments":{"name":"<name>"}}`

Result: `{"id": "string", "slug": "string", "name": "string", "description": "string", "main": false, "paused": false, "learn_votes": false, "payload": {"summary": false, "summary_lang": "string", "excerpt": false, "text": false, "body": false, "marks": false, "images": false, "entities": false, "graph": false, "explain": false}, "fields": [{…}], "fields_version": 0, "topics": 0, "topic_list": [{…}], "max_topics": 0, …`

#### `delete_channel`

*Delete a channel* (write key)

Delete an extra channel with its topics, keys, webhooks and exports. This cannot be undone. The main channel cannot be deleted.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels

Call: `{"name":"delete_channel","arguments":{"channel":"<channel>"}}`

Result: `{"deleted": false}`

#### `export_channel_template`

*Export a channel as a template* (read key)

A channel's setup as one JSON document: payload options, custom fields, topics (keyword packs, exclusion words, don't-show, must-see, places, languages, sites, threshold) and webhook addresses. It holds no secret, no key and no item. Import it with import_channel to make the same channel again.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels

Call: `{"name":"export_channel_template","arguments":{"channel":"<channel>"}}`

Result: `{"feedql_channel_template": 0, "name": "string", "description": "string", "payload": {"summary": false, "summary_lang": "string", "excerpt": false, "text": false, "body": false, "marks": false, "images": false, "entities": false, "graph": false, "explain": false}, "learn_votes": false, "fields": [{…}], "topics": [{…}], "webhooks": ["string"]}`

#### `get_channel`

*One channel* (read key; also offered to a channel key)

One channel by id or slug with its topic count and limit.

Arguments:

- `channel` (string): the channel id or slug from list_channels; empty: the main channel (your reading feed)

Call: `{"name":"get_channel","arguments":{}}`

Result: `{"id": "string", "slug": "string", "name": "string", "description": "string", "main": false, "paused": false, "learn_votes": false, "payload": {"summary": false, "summary_lang": "string", "excerpt": false, "text": false, "body": false, "marks": false, "images": false, "entities": false, "graph": false, "explain": false}, "fields": [{…}], "fields_version": 0, "topics": 0, "topic_list": [{…}], "max_topics": 0, …`

#### `import_channel`

*Make a channel from a template* (write key)

Make a new channel from a template (see export_channel_template): it counts against the channel limit and the topic limit, is validated first and removed again if anything fails. Topics are made but not followed unless follow is true: then the worker test-runs and follows each topic (always followed once its run completes, even with 0 found; stopped only by a plan limit, a pack that could not be built or the daily AI budget) and the answer carries the pending summary (follow: per topic state pending, running, …

Arguments:

- `template` (object, required): the template from export_channel_template
- `name` (string): overrides the template's name
- `slug` (string)
- `webhooks` (boolean): also create the template's webhooks
- `follow` (boolean): test-run and follow every topic in the background; the answer carries the pending summary, read it with channel_follow_status

Call: `{"name":"import_channel","arguments":{"template":{"feedql_channel_template":1,"fields":[],"name":"<name>","payload":{},"topics":[]}}}`

Result: `{"channel": {"id": "string", "slug": "string", "name": "string", "description": "string", "main": false, "paused": false, "learn_votes": false, "payload": {…}, "fields": [{…}], "fields_version": 0, "topics": 0, "topic_list": [{…}], "max_topics": 0, "url": "string", "daily_cap_usd": 0.0, "daily_cap_custom": false, "retention_days": 0, "created_at": "string"}, "topics": [{…}], "webhooks": [{…}], …`

#### `list_channel_keys`

*A channel's keys* (read key)

The channel keys (fqc_…) of one channel: name, scope, the start of the key, expiry, last use, revoked / expired. Secrets are never shown; channel keys are made and revoked in the app (the channel page), because a key must not mint keys.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels

Call: `{"name":"list_channel_keys","arguments":{"channel":"<channel>"}}`

Result: `{"keys": [{…}]}`

#### `list_channels`

*List channels* (read key; also offered to a channel key)

The user's channels: the main one (their reading feed, always there) and the extra ones, each with its id, slug, topic count, topic_list (id, title, following, paused of every topic in it), topic limit and its data URL (https://api.feedql.com/c/<id>). A channel is a separately configured stream of data for a program; its topics never appear in the reading feed.

Arguments:

- none

Call: `{"name":"list_channels","arguments":{}}`

Result: `{"channels": [{…}], "max_channels": 0, "extra": 0}`

#### `remove_channel_source`

*Detach a channel source* (write key)

Detach a source from the channel by its id. What it already collected stays in the channel.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels
- `source` (string, required): the source id (src_…) from channel_sources

Call: `{"name":"remove_channel_source","arguments":{"channel":"<channel>","source":"<source>"}}`

Result: `{"sources": [{…}], "total": 0, "offset": 0}`

#### `update_channel`

*Edit a channel* (write key)

Change a channel's name, slug, description or pause it (paused: its topics collect and score nothing; resume with paused false). learn_votes true lets your reading votes (Less / More like this, Hide in the main feed) move the ranking of this channel's topics by similarity; it is off by default and off means no vote ever changes a channel row (turning it off also clears the adjustments already made).

Arguments:

- `channel` (string, required): the channel id or slug
- `changes` (object, required): the fields to change: name, slug, description, paused, learn_votes, payload, fields, daily_cap_usd; only present ones apply

Call: `{"name":"update_channel","arguments":{"changes":{},"channel":"<channel>"}}`

Result: `{"id": "string", "slug": "string", "name": "string", "description": "string", "main": false, "paused": false, "learn_votes": false, "payload": {"summary": false, "summary_lang": "string", "excerpt": false, "text": false, "body": false, "marks": false, "images": false, "entities": false, "graph": false, "explain": false}, "fields": [{…}], "fields_version": 0, "topics": 0, "topic_list": [{…}], "max_topics": 0, …`

#### `update_channel_source`

*Change a channel source* (write key)

Change one channel source by its stable id: active (false stops collecting it, true resumes), interval_min (minutes between reads; 0 back to automatic), where and mode (the item filter). The id never changes.

Arguments:

- `channel` (string, required): the channel id or slug from list_channels
- `source` (string, required): the source id (src_…) from channel_sources
- `active` (boolean): false stops collecting the source without removing it; true resumes
- `interval_min` (integer): minutes between reads, 1 to 43200; 0 returns to the automatic schedule
- `where` (string): the item filter (see add_channel_sources); empty string clears it
- `mode` (string): hide or drop

Call: `{"name":"update_channel_source","arguments":{"channel":"<channel>","source":"<source>"}}`

Result: `{"id": "string", "key": "string", "type": "string", "label": "string", "url": "string", "active": false, "interval_min": 0, "interval_fixed": false, "where": "string", "mode": "string", "added_at": "string", "last_collected_at": "string", "last_error": "string", "next_run_at": "string"}`

### Domain: digests

#### `create_digest`

*Create a digest* (write key; also offered to a channel key)

Create a scheduled digest in a channel: name, prompt (free text, up to 4000 characters, say which sections you want), optional topics (ids of the channel's topics; empty: the whole channel). Schedule: schedule daily, weekly (with weekday monday..sunday) or monthly (with day 1 to 28), hour 0 to 23 in the timezone tz (an IANA name like Europe/Kyiv); period_days is 1, 7 or 30; model claude-sonnet-5-5 (default), claude-opus-5-5 or claude-haiku-5-5. Defaults: weekly on Monday 09:00 UTC, 7 days, on.

Arguments:

- `channel` (string): the channel id or slug; empty: the main channel; a channel-key session needs none
- `name` (string)
- `prompt` (string)
- `topics` (string[])
- `all_topics` (boolean)
- `period_days` (integer)
- `schedule` (string)
- `weekday` (string)
- `day` (integer)
- `hour` (integer)
- `tz` (string)
- `model` (string)
- `enabled` (boolean)
- `notify_push` (boolean)
- `notify_email` (boolean)

Call: `{"name":"create_digest","arguments":{}}`

Result: `{"id": "string", "channel": "string", "topics": ["string"], "name": "string", "prompt": "string", "period_days": 0, "schedule": {"kind": "string", "weekday": "string", "day": 0, "hour": 0, "tz": "string"}, "model": "string", "enabled": false, "notify": {"push": false, "email": false}, "next_run_at": "string", "created_at": "string", "updated_at": "string", "latest_run": "string", "latest_at": "string", "new": false}`

#### `delete_digest`

*Delete a digest* (write key; also offered to a channel key)

Delete a digest and all its runs.

Arguments:

- `id` (string, required): the digest id from list_digests

Call: `{"name":"delete_digest","arguments":{"id":"<id>"}}`

Result: `{"deleted": false}`

#### `get_digest_run`

*Read a digest run* (read key; also offered to a channel key)

One run with its Markdown text and the items it used. Citations in the text are [n]; items lists each n with the item id, title, url and source, and cited: true for the ones the text uses (items without a url are never cited). Reading a finished run marks it as seen.

Arguments:

- `id` (string, required): the digest run id from list_digest_runs or run_digest

Call: `{"name":"get_digest_run","arguments":{"id":"<id>"}}`

Result: `{"id": "string", "digest_id": "string", "channel": "string", "slot": "string", "name": "string", "manual": false, "period_from": "string", "period_to": "string", "status": "string", "error": "string", "items": [{…}], "entities": [{…}], "model": "string", "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "cost_micro": 0, "markdown": "string", "created_at": "string", …`

#### `list_digest_runs`

*List digest runs* (read key; also offered to a channel key)

The archive, newest first: id, digest, period, status (queued, running, ready, empty, capped, failed), model, tokens and cost, without the text. Of one digest (digest) or of a channel.

Arguments:

- `channel` (string): the channel id or slug; used when no digest id is given; a channel-key session needs none
- `digest` (string): only the runs of this digest id; empty: every run of the channel
- `limit` (integer): runs to return, 1 to 50; default 20

Call: `{"name":"list_digest_runs","arguments":{}}`

Result: `{"runs": [{…}]}`

#### `list_digests`

*List digests* (read key; also offered to a channel key)

The digests of a channel: a digest is a prompt the user wrote, run on a schedule over the channel's items (or some of its topics) for the last 1, 7 or 30 days, answered as Markdown where every claim cites an item. Each has its schedule, model, on/off, next_run_at and the latest run (new: true until the user opens it).

Arguments:

- `channel` (string): the channel id or slug; empty: the main channel; a channel-key session needs none

Call: `{"name":"list_digests","arguments":{}}`

Result: `{"digests": [{…}]}`

#### `run_digest`

*Run a digest now* (write key; also offered to a channel key)

Write the digest now. Returns the run with status queued; read it with get_digest_run until its status is ready (a minute or two). A run with no items in the period is stored as empty without a model call; a run that would exceed the channel's daily AI cap is capped. One run at a time per digest.

Arguments:

- `id` (string, required): the digest id from list_digests

Call: `{"name":"run_digest","arguments":{"id":"<id>"}}`

Result: `{"id": "string", "digest_id": "string", "channel": "string", "slot": "string", "name": "string", "manual": false, "period_from": "string", "period_to": "string", "status": "string", "error": "string", "items": [{…}], "entities": [{…}], "model": "string", "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "cost_micro": 0, "markdown": "string", "created_at": "string", …`

#### `update_digest`

*Change a digest* (write key; also offered to a channel key)

Change a digest: only the fields you give change (name, prompt, topics, all_topics true for the whole channel, period_days, schedule, weekday, day, hour, tz, model, enabled false to switch it off, notify_push, notify_email). Schedule: schedule daily, weekly (with weekday monday..sunday) or monthly (with day 1 to 28), hour 0 to 23 in the timezone tz (an IANA name like Europe/Kyiv); period_days is 1, 7 or 30; model claude-sonnet-5-5 (default), claude-opus-5-5 or claude-haiku-5-5.

Arguments:

- `id` (string, required): the digest id from list_digests
- `name` (string)
- `prompt` (string)
- `topics` (string[])
- `all_topics` (boolean)
- `period_days` (integer)
- `schedule` (string)
- `weekday` (string)
- `day` (integer)
- `hour` (integer)
- `tz` (string)
- `model` (string)
- `enabled` (boolean)
- `notify_push` (boolean)
- `notify_email` (boolean)

Call: `{"name":"update_digest","arguments":{"id":"<id>"}}`

Result: `{"id": "string", "channel": "string", "topics": ["string"], "name": "string", "prompt": "string", "period_days": 0, "schedule": {"kind": "string", "weekday": "string", "day": 0, "hour": 0, "tz": "string"}, "model": "string", "enabled": false, "notify": {"push": false, "email": false}, "next_run_at": "string", "created_at": "string", "updated_at": "string", "latest_run": "string", "latest_at": "string", "new": false}`

### Prompts

Ready-made flows a client can offer (`prompts/get`):

- `follow_a_topic` (about, places?, languages?): Set up a new topic: words, sources, a test run, then follow.
- `daily_digest` (since?): Summarise what arrived since the last look.
- `triage_feed` (topic?): Vote, save and clear the feed together.

## 5. REST API

Base URL `https://api.feedql.com`. Every call: `Authorization: Bearer fq_...` (or `fqc_...` on `/c/...`). Responses are JSON.
`:name` in a path is a path parameter.

**Errors** always have the shape `{"error":{"code":"...","message":"...","field":"...","retry_after_seconds":3}}`
(`field` and `retry_after_seconds` only when they apply). Status and code: `400 bad_request` / `query_syntax` /
`query_unsupported`, `401 unauthorized`, `403 forbidden` (a read key on a write route, a channel key off its channel),
`404 not_found`, `409 conflict` / `limit_reached`, `422` with `field` (invalid value), `429 rate_limited`,
`5xx unavailable` (retry later). Write bodies are strict JSON: an unknown field is `400`.

**Rate limit**: 10 requests a second per key with a burst of 20. Every answer carries `X-RateLimit-Limit`,
`X-RateLimit-Burst` and `X-RateLimit-Remaining`. Over the limit: `429` with `Retry-After: <seconds>`: wait that long, go on.

**Pagination**: lists answer `{"items":[...],"next":"<cursor>","retention_days":365}`. Pass `next` back as `cursor`; no `next`
means the last page. Cursors are opaque and per list kind (a `next` of `/items` goes to `/items`; the `cursor` of `/items/new`
goes back to `/items/new`).

**Polling for new items**: `GET /api/v1/items/new` with no cursor returns no items and the cursor of "now"; then call it with
the cursor you got each time. While `more` is true, call again at once. Reading through the API never changes what the app
shows as new or read. Prefer webhooks (section 7) to tight polling.

**Meaning search** (`~"phrase"` in `q`): the first use of a phrase answers `202 {"status":"preparing","retry_after_seconds":2}`;
repeat the same request after the wait.

### Feed and items

| Route | Key | What |
|---|---|---|
| `GET /api/v1/feed/sources` | read | sources present in the feed window with entry counts, most first. Query: `topic` |
| `GET /api/v1/items` | read | search the feed with an FQL query. Query: `q`, `cursor`, `include=filtered`, `limit` (1-200), `sort=points|comments|engagement|relevance` (needs `since:` in `q`; answers `truncated` when the window was only partly ranked). Empty `q` lists everything |
| `GET /api/v1/items/:id` | read | one item with full text, event reports, images and summary status |
| `POST /api/v1/items/:id/mark` | write | vote / read / save / hide / note one item: `{vote?: up|down|none, read?, saved?, not_this?, note?, topic_id?}` -> `{item_id, before, after, note?}`. REST twin of `mark_item` + `save_note` |
| `POST /api/v1/items/marks` | write | set one field on up to 200 items: `{ids:[...], field: read|saved|not_this|vote, value: true|false (vote: up|down|none)}` -> `{changed, unchanged, not_found}`. REST twin of `mark_items` |
| `GET /api/v1/items/new` | read | changes since a cursor, oldest first; call without `cursor` first to get the cursor of now. Query: `cursor`, `topic`, `include=filtered` |
| `GET /api/v1/sources` | read | source types (`type`, `label`, `searchable`) a topic can use |
| `GET /api/v1/topics` | read | your topics |
| `GET /api/v1/topics/:id` | read | one topic with its keyword pack, status, threshold, funnel and spend (what its model calls cost, by kind) |
| `GET /api/v1/topics/:id/items` | read | the topic's feed, newest first. Query: `cursor`, `source=a,b` (codes from /feed/sources) |

### Topics (manage)

| Route | Key | What |
|---|---|---|
| `GET /api/v1/runs/:id` | read | the same by run id `<topic id>:<n>` |
| `POST /api/v1/topics` | write | create a topic: `{title, keyword_pack?, links?, about?, geo?, languages?, excluded?, dont_show?, must_see?, markers?, match?, exclude_mode?, raw?}` -> 201. Without a pack the model builds one (poll `GET /topics/:id` until `status` is `ready`) |
| `DELETE /api/v1/topics/:id` | write | delete the topic with its matches and marks -> 204 |
| `PATCH /api/v1/topics/:id` | write | change any of title, about, keyword_pack (+pack_version), excluded, match, exclude_mode, dont_show, must_see, markers, threshold (1-100), geo, languages, raw, paused, sources, add_links, remove_links. Strict JSON: an unknown field is 400 |
| `POST /api/v1/topics/:id/follow` | write | follow on the sources of a finished run: `{run?, sources?:[types], all?}` |
| `POST /api/v1/topics/:id/runs` | write | start a test run of a `ready` topic -> 202 (`Location` is the poll URL). Optional body `{"sources":["reddit"]}` |
| `GET /api/v1/topics/:id/runs/:n` | read | poll a test run: `status` is `running` until every source is `done`; scored items per source |
| `POST /api/v1/topics/:id/unfollow` | write | stop following (the topic stays as a draft) |

### Exports and counts

| Route | Key | What |
|---|---|---|
| `GET /api/v1/exports` | read | your recent exports |
| `POST /api/v1/exports` | write | start an export of every item an FQL query finds: `{q, format: jsonl|csv, include?}` -> 202 |
| `GET /api/v1/exports/:id` | read | one export: `status` queued/running/ready/failed, `rows`, and when ready a signed `download_url` (24 h) |
| `GET /api/v1/exports/:id/download` | signed | the file; the signed link from `download_url` is the credential, send no key |
| `GET /api/v1/stats` | read | counts of the items a query finds. Query: `q`, `by=day|week|topic|source|lang|score_bucket|author_count`, `include`. A big range answers 202 with a job (read it at `/exports/:id`) |

### Webhooks

| Route | Key | What |
|---|---|---|
| `GET /api/v1/topics/:id/webhooks` | read | a topic's webhooks |
| `POST /api/v1/topics/:id/webhooks` | write | `{url}` -> 201 `{webhook, secret}`; the `whsec_` signing secret is shown once |
| `GET /api/v1/webhooks` | read | your webhooks with delivery state |
| `DELETE /api/v1/webhooks/:id` | write | remove it |
| `GET /api/v1/webhooks/:id` | read | one webhook |
| `POST /api/v1/webhooks/:id/enable` | write | turn a disabled webhook back on; what it missed is delivered |

### Channels

| Route | Key | What |
|---|---|---|
| `GET /api/v1/channels` | read | your channels (the main one first) with limits |
| `POST /api/v1/channels` | write | `{name, slug?, description?}` -> 201 |
| `DELETE /api/v1/channels/:id` | write | remove the channel with its topics, keys, webhooks and exports -> 204 |
| `GET /api/v1/channels/:id` | read | one channel by id (`ch_...`) or slug (`main` is the reading feed) |
| `PATCH /api/v1/channels/:id` | write | change name, slug, description, paused, payload, fields, learn_votes, daily_cap_usd |
| `GET /api/v1/channels/:id/follow` | read | progress of an import with `follow: true`; poll until `state` is `done` |
| `GET /api/v1/channels/:id/graph` | read | entity graph (needs channel option `payload.graph`). Query: `type`, `since`, `limit`, `entity=type:value` |
| `GET /api/v1/channels/:id/graph.nt` | read | the whole channel graph as N-Triples (streamed) |
| `GET /api/v1/channels/:id/graph.ttl` | read | the whole channel graph as Turtle |
| `GET /api/v1/channels/:id/keys` | read | the channel's keys, never a secret |
| `GET /api/v1/channels/:id/markers` | read | marker hit statistics per topic |
| `POST /api/v1/channels/:id/sparql` | read | read-only SPARQL 1.1 over the channel graph (SELECT/ASK/CONSTRUCT); body `{"query":"..."}` or `application/sparql-query` |
| `GET /api/v1/channels/:id/template` | read | the channel's setup as one JSON template (no secret, no ids) |
| `GET /api/v1/channels/:id/topics` | read | the channel's topics |
| `POST /api/v1/channels/:id/topics` | write | create a topic in the channel (body of `POST /api/v1/topics`) |
| `GET /api/v1/channels/:id/usage` | read | last 7 days of items, scoring and spend against the channel's cap |
| `GET /api/v1/channels/:id/webhooks` | read | the channel's webhooks |
| `POST /api/v1/channels/:id/webhooks` | write | `{url}` -> 201 with the signing secret once |
| `POST /api/v1/channels/import` | write | make a channel from a template: `{template, name?, slug?, webhooks?, follow?}` -> 201 |

### Channel data (/c/:cid, account or channel key)

| Route | Key | What |
|---|---|---|
| `GET /c/:cid` | read | the channel |
| `GET /c/:cid/digest-runs` | read | the archive, newest first, without the text (`digest`, `limit`) |
| `GET /c/:cid/digest-runs/:id` | read | one run: `status` queued/running/ready/empty/capped/failed, `markdown` with `[n]` citations, and `items` (n, id, title, url, source, cited) |
| `GET /c/:cid/digests` | read | the channel's digests (scheduled written reports); `/c/main/digests` for the main channel |
| `POST /c/:cid/digests` | write | create a digest: `{name, prompt, topics?, period_days?: 1|7|30, schedule: daily|weekly|monthly, weekday?, day?, hour, tz, model?, enabled?, notify_push?, notify_email?}` -> 201 |
| `DELETE /c/:cid/digests/:id` | write | delete a digest and its runs -> 204 |
| `GET /c/:cid/digests/:id` | read | one digest |
| `PATCH /c/:cid/digests/:id` | write | change a digest; only the fields given change |
| `POST /c/:cid/digests/:id/runs` | write | write the digest now -> 202 with the run (`status` queued); read it at `/digest-runs/:id` |
| `GET /c/:cid/exports` | read | the channel's exports |
| `POST /c/:cid/exports` | write | start an export of the channel |
| `GET /c/:cid/exports/:id` | read | one export |
| `GET /c/:cid/graph` | read | entity graph |
| `GET /c/:cid/graph.nt` | read | channel graph as N-Triples |
| `GET /c/:cid/graph.ttl` | read | channel graph as Turtle |
| `GET /c/:cid/items` | read | search the channel (`q`, `cursor`, `include`, `limit`, `sort`), newest first unless sorted |
| `GET /c/:cid/items/:id` | read | one item |
| `GET /c/:cid/items/new` | read | changes since a cursor (`cursor`, `topic`, `include`) |
| `GET /c/:cid/markers` | read | marker hit statistics |
| `POST /c/:cid/sparql` | read | SPARQL over the channel graph |
| `GET /c/:cid/stats` | read | counts (`q`, `by`, `include`) |
| `GET /c/:cid/topics` | read | the channel's topics |
| `GET /c/:cid/webhooks` | read | the channel's webhooks |
| `POST /c/:cid/webhooks` | write | create a channel webhook |
| `DELETE /c/:cid/webhooks/:wid` | write | remove a channel webhook |

### OAuth and MCP

| Route | Key | What |
|---|---|---|
| `GET /.well-known/oauth-authorization-server` | none | RFC 8414 metadata: authorize, token and registration endpoints, PKCE S256 only |
| `GET /.well-known/oauth-authorization-server/*` | none | the same, path-suffixed form |
| `GET /.well-known/oauth-protected-resource` | none | RFC 9728 metadata: names the authorization server |
| `GET /.well-known/oauth-protected-resource/mcp` | none | the same for the MCP resource |
| `ANY /mcp` | read | the user MCP (streamable HTTP, stateless, JSON responses); also at `https://mcp.feedql.com/` and `/mcp` |
| `GET /oauth/authorize` | none | authorization code + PKCE (S256); sends the person to the consent page |
| `POST /oauth/register` | none | RFC 7591 dynamic client registration: `{client_name, redirect_uris}` |
| `POST /oauth/token` | none | exchange a code or refresh token for `fqo_` access / `fqr_` refresh tokens |

### Inbound webhooks (push items in, section 7.1)

| Route | Key | What |
|---|---|---|
| `GET /api/v1/inbound-webhooks` | read | your inbound webhooks (address, target topic, on/off, key hint, counters; never the key) |
| `POST /api/v1/inbound-webhooks` | write | `{name, topic_id?, mapping?}` -> 201 `{webhook, key, curl}`; the `fqi_` key is shown once. Without `topic_id` a raw, followed topic `Inbox: <name>` is made |
| `DELETE /api/v1/inbound-webhooks/:id` | write | delete it with its key and log -> 204 |
| `GET /api/v1/inbound-webhooks/:id` | read | one inbound webhook |
| `PATCH /api/v1/inbound-webhooks/:id` | write | change `name`, `topic_id`, `mapping` (`{}` clears) or `enabled` |
| `GET /api/v1/inbound-webhooks/:id/deliveries` | read | the last requests (up to 50): status, counts, error, first 200 characters of the first item. Query: `limit` |
| `POST /api/v1/inbound-webhooks/:id/rotate` | write | a new key `{webhook, key}`, shown once; the old key stops at once |
| `POST /in/:id` | none | push one item or `{items:[...]}` (up to 100) into the webhook's topic. Auth is the webhook's own `fqi_` key, not an account key: `Authorization: Bearer fqi_...` or `X-Feedql-Timestamp` + `X-Feedql-Signature` (section 7.1). `401` wrong/missing key or unknown address, `404` switched off, `413`, `429` with `retry_after_seconds` |

Marks work over REST with a write key: `POST /api/v1/items/:id/mark` (one item: vote, read, saved, not_this, note) and
`POST /api/v1/items/marks` (many: `{"ids":[...],"field":"read","value":true}`, up to 200). Account settings, profile
("About me") and memory are MCP only. Routes under `/api/v1/ext/` belong to the
browser extension and are not part of the public API.

## 6. FQL, the query language

One filter language for `search_items` (`query`), `GET /api/v1/items?q=`, exports and stats. The same query returns the
same items everywhere. At most 500 characters and 20 terms. Terms separated by a space must **all** match.
Unknown keys, bad values, unclosed quotes and a dangling `OR` or `-` are errors (`query_syntax`, with the 1-based position), never ignored.

Keys: `topic`, `source`, `lang`, `since`, `until`, `score`, `event`, `rating`, `stars`, `comments`, `upvotes`, `views`, `place`, `channel`, `kind`.

| Term | Meaning |
|---|---|
| `fusion`, `"exact phrase"` | the word or phrase is in the title, text or context (case-insensitive) |
| `-term` | a minus in front of any term excludes it: `-"hiring"`, `-source:reddit` |
| `a OR b` | either; `OR` (upper case) binds tighter than the space: `a OR b c` = (a or b) and c |
| `topic:golang`, `topic:a,"b c"` | the topic's name contains the value (case-insensitive: `topic:go` also matches "golang" and "Google"), or the id equals it, so `topic:6720e0a1b2c3d4e5f6a7b8c9` is exact; a comma list is OR. A name that matches no topic gives an empty list, not an error |
| `source:hackernews,github` | source code, a comma list is OR. Codes come from `feed_sources` / `list_source_types`: `hackernews`, `reddit`, `github`, `x`, `telegram`, `youtube`, `discourse`, `stackexchange`, `appstore`, `googleplay`, `appversions`, `medium`, `websearch`, `site`, `federalregister`, `ted`, `grants`, `prozorro`, `linkedin-jobs` |
| `source:site` | any website source; `source:site:dou.ua` one website by domain (`www.` dropped); `source:site:other` the less common sites |
| `source:in:deploys` | items pushed to the person's inbound webhook of that name (`in:<name as a slug>`, section 7.1) |
| `source:ext:x`, `source:ext:linkedin` | posts the person's own browser extension read in their logged-in browser (`ext:<site>`) |
| `lang:uk,en` | the item's language (ISO 639-1, detected locally); items whose language is unknown never match, so `-lang:ru` keeps them |
| `since:7d`, `until:2026-10-01` | published at or after (`since`) / before (`until`). Relative `30m`, `12h`, `7d`, `2w`, or `YYYY-MM-DD`, or `YYYY-MM-DDTHH:MM[:SS]Z` (UTC) |
| `score>70` | also `>=`, `<`, `<=`, `=`. Unscored items never match a score term. Using `score` also lets you ask for items below the topic's threshold |
| `rating<=3`, `stars>100`, `comments>10`, `upvotes>=50`, `views>1000` | the source's own numbers: `rating` is a review's 1-5 stars (App Store, Google Play), `stars` a GitHub repository's, `comments` replies/comments, `upvotes` score/likes/reactions, `views` YouTube/forum/Telegram views. An item without that number never matches |
| `place:r/golang`, `channel:owner/repo`, `place:1232780281@gb` | where inside a source: subreddit `r/<name>`, GitHub `owner/repo`, app `<id or package>@<country>`, forum `<host>/<category>`, `@handle`, a website host. Case-insensitive, exact; `channel:` is an alias. An item without a place never matches |
| `kind:release`, `kind:review,issue` | what the item is: post, review, app, release, issue, repo, video, article, notice, tender, grant, job |
| `event:yes`, `event:no` | the item is (not) one report of a bigger event |
| `~"cheaper batteries"` | by meaning (vector search), best match first, results carry `similarity`. One per query, standing alone: not negated, not inside `OR`. First use of a phrase answers `preparing` (see above) |

Time and score: `since:` uses **published** time, not collection time. `since:1d` is the last 24 hours, rolling; for "since midnight"
use a date, `since:2026-10-11` (00:00 UTC). The account has no time zone: say which you used. A topic's score threshold applies
unless the query has a `score` term; a topic without AI scoring (`raw`) has no scores, so `score` terms match nothing there.

Order and page size: a search lists newest first. `sort=points|comments|engagement|relevance` (REST `sort`, `search_items` and `channel_items` `sort`) ranks a
window instead: `points` is the source's own number (Hacker News points, Reddit score, X likes, YouTube views, GitHub stars), `comments` its replies, `engagement` the
comparable number (below), `relevance` the score the reader sees. The query must carry `since:` (the window, e.g. `since:48h`); each item appears once, the most first; `next` continues the ranking.
`metrics.engagement` (and `sort=engagement`) is the one number that compares across sources: Hacker News points, Reddit score, X likes, forum likes, Stack Exchange score and
Telegram reactions are the item's points; a GitHub repository is its stars and an issue its comments; a YouTube video is its likes (`metrics.likes`, read for the most viewed videos of a search; 0 when not read: views are on another scale).
`metrics.points` and the rest stay as the source gave them. Reddit scores are refreshed on every pass of a subreddit (its hot listing), Hacker News front-page points on every pass of the front page.
`source:x` is posts the server read with its pooled X account; `source:ext:x` is posts the person's own browser extension read in their logged-in browser: the same upstream, a different collector, so an
item can come from either, and `source:x,ext:x` asks for both. `stats` with `by=source` answers `max_engagement` and `max_points` per source, and a query with a `since:` of 30 days or less is answered in the request.
Only the newest rows of a very big window are ranked (`truncated: true` says so). `limit` (1 to 200, default 30) sets the page size. Search covers the
items matched to **your** topics and nothing else: a story is found only if one of your sources brought it and a topic kept it. To include items a
topic scored under its threshold, add a `score` term (`score>=0`); raw topics have no score, so `score` terms skip them.

Examples:

```
topic:golang since:1d score>=60                       today's relevant Go items
source:github kind:release since:7d topic:"home lab"  new releases in a topic
source:appstore place:1232780281@gb rating<=2 since:30d   recent bad reviews of one app in the UK store
source:hackernews comments>100 since:3d               busy Hacker News threads
topic:golang release OR changelog -"beta"      (release or changelog), in the topic, without beta
~"crashes after an update" source:github since:30d score>60   by meaning, with filters
source:site:dou.ua lang:uk since:2d                   one website, one language
```

Visibility: search covers the person's own feed only (followed topics; items marked "Not this" stay hidden; each topic's
score threshold applies unless the query has a `score` term). Items a topic in `hide` exclusion mode kept out appear only
with `include: "filtered"` (a request parameter or tool argument, not an FQL term); they carry `filtered_by` and no score.
There is no sort key: lists are **newest first**. To rank by score, fetch the window and sort the items yourself.
There is no "unread" key: read `mine.read` on each item.

## 7. Webhooks

Push new items to your URL instead of polling. A webhook belongs to one topic (or, for a channel, to the channel).
Create it with a write key: REST `POST /api/v1/topics/:id/webhooks {"url":"https://..."}` or MCP `create_webhook`. The
answer carries the `whsec_...` signing secret **once**; keep it. At most 3 per topic and 10 per account. The URL must be a
public `https` address (no private or loopback addresses, no credentials in the URL, redirects are not followed).

feedql POSTs JSON in `seq` order, up to 20 items per request, within about 30 seconds of a match or a score:

```
POST /your/hook
Content-Type: application/json
X-Feedql-Event: items
X-Feedql-Delivery: wd_5adecd407d3e1f8167a014b1
X-Feedql-Timestamp: 1791420000
X-Feedql-Signature: sha256=<hex HMAC-SHA256 of the raw body, key = your whsec_ secret>

{"id":"wd_5adecd...","event":"items","sent_at":"...","topic":{"id":"...","title":"..."},"items":[<item payload>, ...]}
```

- Verify the signature over the **raw body** before trusting it (constant-time compare): `printf %s "$BODY" | openssl dgst -sha256 -hmac "$WHSEC"`. `sent_at` is inside the signed body: reject old deliveries.
- Answer any `2xx` within 10 seconds. Anything else is a failure, retried with growing delays (10 s doubling to 30 min) for 24 hours, always with the same `X-Feedql-Delivery` id: delivery is **at least once**, so deduplicate by that id (or item id + `seq`). A re-scored item can arrive again.
- After 24 hours of failures the webhook is switched off (and the owner emailed); nothing is lost. `POST /api/v1/webhooks/:id/enable` (or `enable_webhook`) delivers what it missed.

### 7.1 Inbound webhooks: push items in

The other direction: your own address that accepts data from scripts, Zapier, n8n, CI or monitoring. Create it in Settings →
Inbound webhooks or with MCP `create_inbound_webhook` or REST `/api/v1/inbound-webhooks` (write key; `list_inbound_webhooks`, `update_inbound_webhook`,
`rotate_inbound_webhook_key`, `delete_inbound_webhook`, `inbound_webhook_deliveries` manage it). A webhook has a name, one
target topic (of the main feed or a channel; omit it to get a new raw, followed topic `Inbox: <name>`), an optional field mapping and an on/off switch. It gets an address
`https://api.feedql.com/in/<uuid>` (a random v4 UUID that says nothing about you) and its own secret key `fqi_...`, shown **once**
(rotatable). Items arrive as source `in:<name as a slug>` (`source:in:deploys` in FQL), private to you and your channels, and go
through the normal pipeline (duplicates, events, scoring by the target topic, ranking). At most 20 webhooks per account.

```
POST /in/<uuid>
Authorization: Bearer fqi_...            (or the signed form below)
Content-Type: application/json
Idempotency-Key: deploy-42-attempt-1     (optional)

{"title":"Deploy finished","url":"https://example.com/deploys/42","text":"v1.8 is live","published":"2026-10-13T10:00:00Z",
 "author":"ci","source":"GitHub Actions","tags":["ci","prod"],"fields":{"env":"prod"},"metrics":{"duration_s":412},"id":"deploy-42"}
```

One item, or `{"items":[...]}` with up to 100. Only `title` or `text` is required (a missing title is cut from the text). `url` is
`http(s)`; an item without one is kept under an identifier of its own and is not a page. `published` is RFC 3339 or unix seconds
(default: now; not more than a day ahead). `tags` become the field `tags`; `fields` are text facts (20 keys at most), `metrics` numbers.
A field mapping (`{"title":"data.subject"}`) fills the item's fields from dotted paths of the pushed object, for senders that cannot
rename theirs.

**Authentication**: `Authorization: Bearer fqi_...`, or sign the request: send `X-Feedql-Timestamp` (unix seconds, within 5 minutes
of now) and `X-Feedql-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>", key = the fqi_ key>`. The same signed request
sent again gets the stored answer (`Idempotent-Replay: true`) and changes nothing. A missing or wrong key, and an address that
does not exist, both answer `401` (the address cannot be probed); `404` only when the key is right and the webhook is switched off.

**Answer** `200` within the usual budget, the pipeline runs in the worker:

```json
{"results":[{"index":0,"id":"deploy-42","status":"created","item_id":"..."},
            {"index":1,"status":"duplicate","item_id":"..."},
            {"index":2,"status":"rejected","reason":"send a title or a text"}],"created":1,"duplicate":1,"rejected":1}
```

Duplicates are by address (and per item `id`, remembered 30 days); a repeated `Idempotency-Key` with the same body repeats the
answer, with another body it is `422`. **Limits**: body 512 KB and 100 items (`413`), text 64 KB per item (that item is
`rejected`), 120 items a minute and 5,000 a day per webhook, 300 a minute and 20,000 a day per account (`429` with
`retry_after_seconds` and `Retry-After`). The webhook's page keeps the last 50 requests (status, counts, error, the first
200 characters of the first item), and has "Send test item".

## 8. Recipes

All MCP calls below are `tools/call` with `name` and `arguments`; with an MCP client call the tool directly.

### 8.1 Daily digest

1. `search_items` `{"query":"since:1d score>=60"}`. Follow `next` while it is present (cap at about 5 pages for a digest, and say so if the cap cut the list).
2. Group by `topic.title`, then by `event.id` (several reports of one story: show one line and mention the others).
3. One line per item: title, `score`, `rationale`, **original `url`**. Say what looks like noise. Do not mark anything unless asked.
   (The `daily_digest` prompt does exactly this.)

### 8.2 "Give me today's top items for topic golang and mark them read"

```json
{"name":"list_topics","arguments":{}}
```
Pick the topic whose `title` is golang (note its `id`). Or skip this step: `topic:golang` in FQL matches by name.

```json
{"name":"search_items","arguments":{"query":"topic:golang since:1d"}}
```
Result: `{"items":[{"id":"...","title":"...","url":"...","score":82,"mine":{...}}],"next":"...","retention_days":365}`.
"Today" here is the last 24 hours (for a calendar day use `since:2026-10-11`). The topic's own score threshold already
hides weak items, so no `score` term is needed; add `score>=70` only to demand a higher bar. "Top" means the highest `score`:
lists are newest first and there is no sort key, so follow `next` until it is absent (the `since:` window bounds it), dedupe by
`id`, sort the collected items by `score` descending (items with `score: null` are not scored yet: put them last) and keep the first
10 unless the person said another number. Show title, score, `rationale` and `url`. Leave out items whose `mine.read` is already
true if the person wants fresh ones. If you stopped early, say the list is partial.

```json
{"name":"mark_items","arguments":{"item_ids":["6720f1c2a5b7d9e8f0a1b2c3","6720f1c2a5b7d9e8f0a1b2c4"],"read":true}}
```
Needs a write key. Up to 200 ids per call; ids are handled one by one, so a bad id is listed in `not_found` and does not fail the call
(`isError` stays false). Result: `{"changed":["..."],"unchanged":["..."],"not_found":["..."]}`. Mark only the items you showed the person
(when they asked you to mark "them", that is the list you showed), and ask first if they did not say to. Over REST, the same two steps with curl (write key):

```bash
curl -s -H "Authorization: Bearer $FEEDQL_KEY" "https://api.feedql.com/api/v1/items?q=topic%3Agolang%20since%3A1d"
curl -s -X POST -H "Authorization: Bearer $FEEDQL_KEY" -H "Content-Type: application/json" \
  -d '{"ids":["6720f1c2a5b7d9e8f0a1b2c3","6720f1c2a5b7d9e8f0a1b2c4"],"field":"read","value":true}' \
  https://api.feedql.com/api/v1/items/marks
```
Same result: `{"changed":[...],"unchanged":[...],"not_found":[...]}`. One item with more than read: `POST /api/v1/items/:id/mark`
`{"vote":"up","saved":true,"note":"..."}`.

### 8.3 Triage the feed

`topic_feed` `{"topic_id":"..."}` (or `search_items`) ten items at a time. For each, give one line on why it matters, ask the
person, then use `mark_item` (`vote` up/down, `saved`, `read`, `not_this`) and `save_note`. Votes teach the topic's scoring, so
vote only on what the person confirmed. For many items at once use `mark_items`.
(The `triage_feed` prompt does this.)

### 8.4 Create a topic with About and follow the suggested sources

1. `get_account`: check `limits.topics` against `usage.topics`.
2. `list_topics`: avoid a duplicate.
3. `create_topic`:
   `{"title":"golang","about":"Senior Go: runtime, performance, concurrency, releases. Not tutorials for beginners.","languages":["en"]}`.
   Without a `keyword_pack` the model builds one (`status` `expanding`): poll `get_topic` every few seconds until `status` is `ready`
   (this spends the daily AI budget). With a `keyword_pack` it is `ready` at once.
4. `list_source_types`: the source types offered (`type`, `label`, `searchable`). Add the person's own sites, feeds, repositories or subreddits with `add_topic_links` (`links`).
5. `start_test_run` `{"topic_id":"..."}`, then poll `get_test_run` `{"topic_id":"...","n":1}` until `status` is `done`.
   Each entry of `sources` has `type`, `state`, `found`, `kept`, `scored` and the scored `items` with their `url`.
6. Show the best and the worst items, and ask which sources to keep. Sources with `kept` 0 or low scores are noise.
7. `follow_topic` `{"topic_id":"...","sources":["reddit","hackernews"]}` (or `"all":true`). Collection starts; read with `topic_feed` / `new_items`.
8. Optionally `update_topic` (its `changes` object takes `threshold` 1-100, `excluded` words, `dont_show`, `must_see`, `mute_sources` ...), and `create_webhook` for pushes.
   After the person votes items down, `get_topic` `source_suggestions[].key` offers places to turn off (`update_topic` `changes.mute_sources`).
(The `follow_a_topic` prompt walks this.)

### 8.5 Watch for new items (monitoring)

`new_items` with no cursor (or `GET /api/v1/items/new`) to get "now"; store the cursor; later call with it. Items arrive
when they match and again when they are scored, oldest first. Use a webhook when you can host an endpoint.

### 8.6 Export everything a query finds

`start_export` `{"query":"topic:golang since:30d","format":"jsonl"}` (or `POST /api/v1/exports`). It answers at once with `status`
`queued`. Poll `get_export` `{"id":"..."}` until `ready`; its `download_url` is signed, needs no key and works 24 hours (a status call
issues a fresh one). jsonl is one item per line; csv has fixed columns. At most 3 exports a day and one running at a time (`rate_limited`),
200,000 items per export (`truncated: true`: narrow the query). Files are deleted after 7 days. For counts only, use
`item_stats` / `GET /api/v1/stats` (`by` day, week, topic, source, lang, score_bucket, author_count).

### 8.7 What did the feed learn / who is the reader

`get_soul` (the person's About me and per-topic notes) and `get_memory` (what feedql learned from their votes) are private
to the person: read them only when the task needs them and never repeat them elsewhere.

## 9. Limits and etiquette

- Rate: MCP 120 calls a minute per key (writes cost 4); REST 10 a second, burst 20; the two buckets are separate. On `rate_limited` / `429` wait `retry_after_seconds` / `Retry-After`; do not retry in a tight loop.
- Account limits (see `get_account`): topics per channel (25 by default), extra channels (2), sources per topic, a daily AI budget that keyword packs, test runs and scoring draw on. `limit_reached` / `409` means one is hit: tell the person rather than working around it.
- Be frugal: use `since:` and `score` to bound searches, page only as far as the task needs, poll statuses every 2 to 5 seconds, prefer `new_items` or webhooks to re-reading the feed, ask for `get_item` only for items you need in full.
- Always show or keep the original `url` with an item. A summary is feedql's own; do not present the source's text as yours and do not republish full third-party articles (feedql does not return them; `text` is a post's text where the source gave one).
- To check what a key may do, look for write tools (`mark_items`) in `tools/list`, or `list_api_keys` (the entry with `current: true` is the key making the call, with its `scope`). Read before you write. Confirm destructive steps (`delete_topic`, `delete_channel`, `delete_webhook`) and votes with the person. Votes and "not this" change what the feed shows and learns.
- Secrets: webhook signing secrets and exported `download_url`s are shown once or expire; do not log them. Keys are the person's: never print them, never write them into files that get committed.
- Not available over MCP or REST on purpose: deleting the account, and keys that never expire. A key you create is a secret: hand it to the person or the script, never print it into logs or files that get committed.
- Report a problem or ask for access: hello@feedql.com.

## 10. Index

- This page: https://feedql.com/llm.md  ·  llms.txt index: https://feedql.com/llms.txt
- Section anchors: #1-quick-start, #2-connect-and-authenticate, #3-the-data-model-in-five-lines, #4-mcp, #tools, #5-rest-api, #6-fql-the-query-language, #7-webhooks, #8-recipes, #9-limits-and-etiquette
