redditapis-mcp 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +2 -1
- package/src/tools.js +144 -23
package/README.md
CHANGED
|
@@ -104,7 +104,8 @@ Authentication is a Bearer token: the server sends `Authorization: Bearer <REDDI
|
|
|
104
104
|
A few conventions across the catalog:
|
|
105
105
|
|
|
106
106
|
- Subreddit names go in **without** the `r/` prefix, and usernames **without** the `u/` prefix. `reddit_subreddit_posts` takes its community as `subreddit`; the `/sub/{name}/...` tools take it as `name`.
|
|
107
|
-
- Listing and search tools return an `after` cursor. Pass it back as `after` to fetch the next page. `limit` accepts 1 to 100 (the API clamps out-of-range values).
|
|
107
|
+
- Listing and search tools return an `after` cursor. Pass it back as `after` to fetch the next page, exactly as it was returned. `limit` accepts 1 to 100 (the API clamps out-of-range values).
|
|
108
|
+
- When `after` comes back `null` there is no next page to ask for. That does not always mean you have every item: Reddit often stops serving a busy listing long before it runs out. The final response also carries `listing_status`, which reads `complete`, `truncated` or `unknown`. Only `complete` means nothing is missing. Treat the other two as a partial answer and widen across sorts, timeframes or search terms rather than paging deeper.
|
|
108
109
|
- `t` (`hour`, `day`, `week`, `month`, `year`, `all`) sets the time window; on subreddit listings it applies to the `top` and `controversial` sorts, and on search it bounds the whole result set.
|
|
109
110
|
|
|
110
111
|
### Search and discovery
|
|
@@ -164,7 +165,7 @@ v1 monitors are **subreddit-scoped, posts-only** (no all-of-Reddit keyword watch
|
|
|
164
165
|
| `reddit_monitor_list` | `GET /api/reddit/monitor/list` | List every monitor on your account, plus `slots` ({used, total, tier}). |
|
|
165
166
|
| `reddit_monitor_update` | `POST /api/reddit/monitor/update` | Pause/resume (`active`), re-cadence, or replace a monitor's filter. Passing any filter field REPLACES the whole filter -- resupply everything you want kept. |
|
|
166
167
|
| `reddit_monitor_remove` | `POST /api/reddit/monitor/remove` | Permanently delete a monitor. Cannot be undone. |
|
|
167
|
-
| `reddit_monitor_health` | `GET /api/reddit/monitor/health` | Per-monitor delivered/failed/suppressed counts (last 24h) and whether the delivery ceiling has been hit. |
|
|
168
|
+
| `reddit_monitor_health` | `GET /api/reddit/monitor/health` | Per-monitor delivered/failed/suppressed counts (last 24h), `suppressed_breakdown` splitting those suppressions into `ceiling` and `stale` reasons, and whether the delivery ceiling specifically has been hit. |
|
|
168
169
|
| `reddit_monitor_deliveries` | `GET /api/reddit/monitor/deliveries` | The actual posts delivered (or attempted), newest first, with real content -- not just counts. Omit `id` to aggregate across every monitor you own. |
|
|
169
170
|
| `reddit_monitor_webhook_create` | `POST /api/reddit/monitor/webhook/create` | Register a delivery target (`webhook`/`slack`/`discord`). Returns a signing secret shown ONCE. |
|
|
170
171
|
| `reddit_monitor_webhook_list` | `GET /api/reddit/monitor/webhook/list` | List your webhooks. Never returns the secret. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redditapis-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"mcpName": "io.github.redditapis/redditapis-mcp",
|
|
4
5
|
"description": "Official MCP server for redditapis.com, the Reddit API (subreddit listings, post + comment + community + user + media search, comment trees, top posts, user profiles) plus monitor/webhook management (create/list/update/remove a monitor, register/test/delete a webhook, read delivery history) as native tools for Claude, Cursor, and any MCP client.",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
package/src/tools.js
CHANGED
|
@@ -29,9 +29,13 @@ const LIMIT = {
|
|
|
29
29
|
"Max items to return (1 to 100). The API clamps out-of-range values; endpoint default applies if omitted.",
|
|
30
30
|
),
|
|
31
31
|
};
|
|
32
|
+
// Shared by every listing and search tool, so the rule about what an EMPTY
|
|
33
|
+
// cursor means is stated once rather than pasted into a dozen tool descriptions
|
|
34
|
+
// where one of them would eventually be missed.
|
|
32
35
|
const AFTER = {
|
|
33
36
|
after: z.string().optional().describe(
|
|
34
|
-
"Opaque
|
|
37
|
+
"Opaque pagination cursor. Pass back the previous response's `after` value EXACTLY as it was returned; the format is not stable and a hand-written Reddit fullname loses the paging depth the cursor carries. Omit on the first call. " +
|
|
38
|
+
"When `after` comes back null there is no next page to request, and that does NOT reliably mean you have every item: Reddit often stops serving a busy listing long before it runs out. Read `listing_status` on that final response instead. It is `complete`, `truncated` or `unknown`, and ONLY `complete` means nothing is missing. Never report `truncated` or `unknown` to a user as the end of the data; say the answer is partial and widen across sorts, timeframes or search terms.",
|
|
35
39
|
),
|
|
36
40
|
};
|
|
37
41
|
// Sort vocabularies differ by endpoint (they map to different Reddit listings):
|
|
@@ -75,16 +79,51 @@ const QUERY = {
|
|
|
75
79
|
"Search query text. Supports Reddit search syntax (e.g. `subreddit:webdev`, `author:spez`, `\"exact phrase\"`, `title:...`).",
|
|
76
80
|
),
|
|
77
81
|
};
|
|
82
|
+
// Auth-scoped private-listing cookie fragment (upvoted/saved/hidden/gilded).
|
|
83
|
+
// These four are READS, but of PRIVATE data Reddit only serves to the account
|
|
84
|
+
// that owns it, so -- unlike every other read tool above -- they need the
|
|
85
|
+
// caller's own Reddit session cookies, obtained from POST /api/reddit/login
|
|
86
|
+
// (a REST call, not an MCP tool here). GET can't carry cookies as a body, so
|
|
87
|
+
// they travel as flat query-string args, same convention routes/vote.js uses
|
|
88
|
+
// for its flat POST-body cookie fields.
|
|
89
|
+
const LISTING_COOKIES = {
|
|
90
|
+
reddit_session: z.string().min(1).describe(
|
|
91
|
+
"Reddit account session cookie. Obtain it by calling POST /api/reddit/login on the REST API first (not an MCP tool) and reusing the `reddit_session` cookie it returns. Required.",
|
|
92
|
+
),
|
|
93
|
+
loid: z.string().min(1).describe(
|
|
94
|
+
"Reddit account loid cookie, from the same POST /api/reddit/login response. Required.",
|
|
95
|
+
),
|
|
96
|
+
csrf_token: z.string().optional().describe(
|
|
97
|
+
"Reddit CSRF cookie, from the same login response. Not required for a read (CSRF only guards state-changing calls) but harmless to pass if you have it.",
|
|
98
|
+
),
|
|
99
|
+
};
|
|
78
100
|
|
|
79
101
|
// ── monitoring filter_spec fragments, shared by monitor_add and monitor_update ──
|
|
80
|
-
// v1 is SUBREDDIT-SCOPED ONLY (all-Reddit keyword monitoring is not available)
|
|
81
|
-
//
|
|
82
|
-
// both match apps/api/src/lib/monitor-filter.js's own validation exactly, so a
|
|
102
|
+
// v1 is SUBREDDIT-SCOPED ONLY (all-Reddit keyword monitoring is not available),
|
|
103
|
+
// matching apps/api/src/lib/monitor-filter.js's own validation exactly, so a
|
|
83
104
|
// tool call fails fast in the client rather than round-tripping a 400.
|
|
105
|
+
//
|
|
106
|
+
// THE "POSTS ONLY" HALF OF THIS COMMENT WAS STALE AND COST A REAL GAP. It said
|
|
107
|
+
// comment monitoring was "validated but rejected server-side", which stopped
|
|
108
|
+
// being true when comment monitoring shipped (migrations 009/010, PRs
|
|
109
|
+
// #532/#537/#541). Because `kind` was never added here, the capability was
|
|
110
|
+
// unreachable from MCP entirely: verified live 2026-08-13 by setting
|
|
111
|
+
// filter_spec.kind="both" over raw REST and watching real comment deliveries
|
|
112
|
+
// land within two minutes, on a tier the pricing page sells as "posts and
|
|
113
|
+
// comments". A stale comment is a defect report, not documentation.
|
|
114
|
+
// SITEWIDE (2026-08-13). `subreddit` is OPTIONAL now: omit it and pass `q` to
|
|
115
|
+
// watch ALL of Reddit for that keyword. Leaving it required here would repeat
|
|
116
|
+
// the exact failure the comment above describes -- a shipped, sold capability
|
|
117
|
+
// that no MCP client can reach because one schema field was never relaxed,
|
|
118
|
+
// with nothing anywhere reporting an error.
|
|
119
|
+
const MONITOR_SUBREDDIT_DESC =
|
|
120
|
+
"Subreddits to watch, without the r/ prefix (e.g. ['SaaS', 'startups']). 1 to 50. " +
|
|
121
|
+
"OMIT this entirely (and set `q`) to watch ALL of Reddit for a keyword -- a monitor must be anchored by " +
|
|
122
|
+
"either a subreddit list or a keyword, never neither. Do NOT pass ['all']: r/all is Reddit's site-wide " +
|
|
123
|
+
"listing rather than a subreddit, so it is refused with 400 subreddit_reserved. Sitewide monitors are " +
|
|
124
|
+
"capped per plan tier (see reddit_monitor_list's `slots`) and cover POSTS only.";
|
|
84
125
|
const MONITOR_SUBREDDIT = {
|
|
85
|
-
subreddit: z.array(z.string().min(1)).min(1).max(50).describe(
|
|
86
|
-
"Subreddits to watch, without the r/ prefix (e.g. ['SaaS', 'startups']). REQUIRED, 1 to 50. All-of-Reddit monitoring is not available in v1 -- every monitor must scope to specific subreddits.",
|
|
87
|
-
),
|
|
126
|
+
subreddit: z.array(z.string().min(1)).min(1).max(50).optional().describe(MONITOR_SUBREDDIT_DESC),
|
|
88
127
|
};
|
|
89
128
|
const MONITOR_FILTER_FIELDS = {
|
|
90
129
|
q: z.string().max(200).optional().describe(
|
|
@@ -95,7 +134,7 @@ const MONITOR_FILTER_FIELDS = {
|
|
|
95
134
|
"Posts containing any of these terms are suppressed even if they otherwise match. Use to cut noise (e.g. exclude 'giveaway' from a brand-mention monitor).",
|
|
96
135
|
),
|
|
97
136
|
domain: z.array(z.string().min(1).max(200)).max(50).optional().describe(
|
|
98
|
-
"Outbound link domains to watch for (e.g. ['example.com']). Matches the post's link URL, any URL inside a self-post/comment body, and
|
|
137
|
+
"Outbound link domains to watch for (e.g. ['example.com']). Matches the post's link URL, any URL inside a self-post/comment body, and a crosspost's original link, including one that only appeared in the original post's body. Exact-or-subdomain match only: 'example.com' matches 'blog.example.com' but never 'notexample.com'. Does NOT resolve shortened links (bit.ly, t.co).",
|
|
99
138
|
),
|
|
100
139
|
include_any: z.array(z.string().min(1).max(200)).max(50).optional().describe(
|
|
101
140
|
"At least ONE of these terms must appear (OR match) for the post to qualify, on top of any `q`."
|
|
@@ -104,13 +143,24 @@ const MONITOR_FILTER_FIELDS = {
|
|
|
104
143
|
"EVERY one of these terms must appear (AND match) for the post to qualify, on top of any `q`."
|
|
105
144
|
),
|
|
106
145
|
search_in: z.array(z.enum(["title", "body", "url", "permalink"])).min(1).optional().describe(
|
|
107
|
-
"Which fields keyword/term matching is scoped to. Default ['title', 'body', 'url']. Narrow to avoid false positives, e.g. a term that only appears in a URL slug matching a post that never mentions it in prose.",
|
|
146
|
+
"Which fields keyword/term matching is scoped to. Default ['title', 'body', 'url']. Narrow to avoid false positives, e.g. a term that only appears in a URL slug matching a post that never mentions it in prose. All four resolve on comments as well as posts: on a comment, 'title' matches the title of the THREAD the comment sits under (a comment has no title of its own), which also applies through the default scope and can deliver every comment under a busy matching thread. Scope to ['body'] if you only want comments that say the term themselves.",
|
|
108
147
|
),
|
|
109
148
|
group: z.string().max(64).optional().describe(
|
|
110
149
|
"Optional label to bundle multiple matches into one delivery instead of one webhook call per match. Omit for one delivery per matching item.",
|
|
111
150
|
),
|
|
151
|
+
kind: z.enum(["post", "comment", "both"]).optional().describe(
|
|
152
|
+
"What to watch in the named subreddits: 'post' (default when omitted), 'comment', or 'both'. Comment monitoring requires a Growth, Pro or Scale plan -- on a lower tier this returns `comment_monitoring_requires_higher_tier` (402). Comments run roughly 7x the volume of posts, so expect proportionally more deliveries and check reddit_monitor_health's delivery ceiling before enabling it on a busy subreddit.",
|
|
153
|
+
),
|
|
112
154
|
min_score: z.number().int().optional().describe("Only match posts with at least this many upvotes."),
|
|
113
|
-
nsfw: z.boolean().optional().describe("
|
|
155
|
+
nsfw: z.boolean().optional().describe("Set false to EXCLUDE NSFW/over-18 posts. Omitted or true both mean NSFW is allowed through -- there is no exclude-by-default; you must explicitly pass false to filter it out. POSTS ONLY: nsfw=false is REJECTED with a field-level 400 when kind is 'comment' or 'both', because Reddit flags NSFW on a post and never on an individual comment, so there is no field to filter a comment on. Run a kind='post' monitor to keep NSFW filtering, and cut unwanted comment text with exclude_terms."),
|
|
156
|
+
};
|
|
157
|
+
// Per-monitor webhook targeting (task #66, migration 009). NOT a filter_spec
|
|
158
|
+
// field -- stays top-level in the request body, same as cadence_s/active, so
|
|
159
|
+
// it is deliberately absent from MONITOR_FILTER_FIELDS/filterSpecFields.
|
|
160
|
+
const MONITOR_WEBHOOK_IDS = {
|
|
161
|
+
webhook_ids: z.array(z.string().min(1)).max(20).optional().describe(
|
|
162
|
+
"Restrict delivery to specific webhook(s) instead of every active webhook on the account. Pass id(s) from reddit_monitor_webhook_create/reddit_monitor_webhook_list. Omit (or pass an empty array) for the default: deliver to every active webhook you've registered. Every id must be a webhook you own -- returns `webhook_not_found` (400) otherwise.",
|
|
163
|
+
),
|
|
114
164
|
};
|
|
115
165
|
const MONITOR_ID = {
|
|
116
166
|
id: z.string().min(1).describe("The monitor's id, from reddit_monitor_add's response or reddit_monitor_list."),
|
|
@@ -127,7 +177,7 @@ export const TOOLS = [
|
|
|
127
177
|
name: "reddit_subreddit_posts",
|
|
128
178
|
path: "/api/reddit/posts",
|
|
129
179
|
description:
|
|
130
|
-
"List posts from a subreddit by sort order. Use this to read a community's feed: newest, hot/trending, top-of-week, rising, etc. Returns post title, author, score, comment count, and permalink, plus an `after` cursor for paging. Example: subreddit='programming' sort='top' t='week'.",
|
|
180
|
+
"List posts from a subreddit by sort order. Use this to read a community's feed: newest, hot/trending, top-of-week, rising, etc. Returns post title, author, score, comment count, and permalink, plus an `after` cursor for paging. When `after` comes back null the response carries `listing_status`: `complete` means no older posts and is only claimed when the whole run came back in under one page, `truncated` means Reddit's cap cut you off, and `unknown` means we cannot tell, so do NOT report `unknown` as the end of the data. A busy feed that Reddit simply stops serving reports `unknown`, not `complete`, so treat `unknown` as an incomplete answer and widen across sorts, timeframes or search rather than paging deeper. Example: subreddit='programming' sort='top' t='week'.",
|
|
131
181
|
shape: {
|
|
132
182
|
subreddit: z.string().min(1).describe(
|
|
133
183
|
"Subreddit name WITHOUT the r/ prefix (e.g. 'programming', 'AskReddit'). Required.",
|
|
@@ -306,6 +356,70 @@ export const TOOLS = [
|
|
|
306
356
|
...LIMIT,
|
|
307
357
|
},
|
|
308
358
|
},
|
|
359
|
+
{
|
|
360
|
+
name: "reddit_user_upvoted",
|
|
361
|
+
path: "/api/reddit/user/{name}/upvoted",
|
|
362
|
+
description:
|
|
363
|
+
"List the posts and comments a Reddit account has UPVOTED. PRIVATE data -- Reddit only serves it to the account that owns it, so you must be logged in as `name` (see the `reddit_session`/`loid` args) or this returns 403. Mixed listing: each item is a post or a comment, tagged `kind`. Requires calling POST /api/reddit/login on the REST API first to get session cookies (not an MCP tool). Example: name='spez' (must match the logged-in account).",
|
|
364
|
+
shape: {
|
|
365
|
+
name: z.string().min(1).describe(
|
|
366
|
+
"Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
|
|
367
|
+
),
|
|
368
|
+
...LISTING_COOKIES,
|
|
369
|
+
...SORT_USER,
|
|
370
|
+
...TIME,
|
|
371
|
+
...AFTER,
|
|
372
|
+
...LIMIT,
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "reddit_user_saved",
|
|
377
|
+
path: "/api/reddit/user/{name}/saved",
|
|
378
|
+
description:
|
|
379
|
+
"List the posts and comments a Reddit account has SAVED. PRIVATE data -- Reddit only serves it to the account that owns it, so you must be logged in as `name` (see the `reddit_session`/`loid` args) or this returns 403. Mixed listing: each item is a post or a comment, tagged `kind`. Requires calling POST /api/reddit/login on the REST API first to get session cookies (not an MCP tool). Example: name='spez' (must match the logged-in account).",
|
|
380
|
+
shape: {
|
|
381
|
+
name: z.string().min(1).describe(
|
|
382
|
+
"Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
|
|
383
|
+
),
|
|
384
|
+
...LISTING_COOKIES,
|
|
385
|
+
...SORT_USER,
|
|
386
|
+
...TIME,
|
|
387
|
+
...AFTER,
|
|
388
|
+
...LIMIT,
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
name: "reddit_user_hidden",
|
|
393
|
+
path: "/api/reddit/user/{name}/hidden",
|
|
394
|
+
description:
|
|
395
|
+
"List the posts and comments a Reddit account has HIDDEN. PRIVATE data -- Reddit only serves it to the account that owns it, so you must be logged in as `name` (see the `reddit_session`/`loid` args) or this returns 403. Mixed listing: each item is a post or a comment, tagged `kind`. Requires calling POST /api/reddit/login on the REST API first to get session cookies (not an MCP tool). Example: name='spez' (must match the logged-in account).",
|
|
396
|
+
shape: {
|
|
397
|
+
name: z.string().min(1).describe(
|
|
398
|
+
"Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
|
|
399
|
+
),
|
|
400
|
+
...LISTING_COOKIES,
|
|
401
|
+
...SORT_USER,
|
|
402
|
+
...TIME,
|
|
403
|
+
...AFTER,
|
|
404
|
+
...LIMIT,
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "reddit_user_gilded",
|
|
409
|
+
path: "/api/reddit/user/{name}/gilded",
|
|
410
|
+
description:
|
|
411
|
+
"List the posts and comments a Reddit account has received an award (gold) on. PRIVATE data -- Reddit only serves it to the account that owns it, so you must be logged in as `name` (see the `reddit_session`/`loid` args) or this returns 403. Mixed listing: each item is a post or a comment, tagged `kind`. Requires calling POST /api/reddit/login on the REST API first to get session cookies (not an MCP tool). Example: name='spez' (must match the logged-in account).",
|
|
412
|
+
shape: {
|
|
413
|
+
name: z.string().min(1).describe(
|
|
414
|
+
"Reddit username WITHOUT the u/ prefix. Must be the SAME account the supplied cookies belong to, or Reddit returns 403. Required (path parameter).",
|
|
415
|
+
),
|
|
416
|
+
...LISTING_COOKIES,
|
|
417
|
+
...SORT_USER,
|
|
418
|
+
...TIME,
|
|
419
|
+
...AFTER,
|
|
420
|
+
...LIMIT,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
309
423
|
{
|
|
310
424
|
name: "reddit_subreddit_comments",
|
|
311
425
|
path: "/api/reddit/sub/{name}/comments",
|
|
@@ -370,7 +484,7 @@ export const TOOLS = [
|
|
|
370
484
|
name: "reddit_by_id",
|
|
371
485
|
path: "/api/reddit/by_id/{fullnames}",
|
|
372
486
|
description:
|
|
373
|
-
"Bulk-fetch posts by their t3_ fullnames in ONE call (up to 100), instead of a request per post. Pass a comma-separated list of fullnames you already have from a search or listing to hydrate them. Returns posts with title, author, score, comment count, and permalink
|
|
487
|
+
"Bulk-fetch posts by their t3_ fullnames in ONE call (up to 100), instead of a request per post. Pass a comma-separated list of fullnames you already have from a search or listing to hydrate them. Returns posts with title, author, score, comment count, and permalink, the same post shape as the listing endpoints. The result is NOT always one-to-one with your request, so read `meta`: `listing_status` is `complete` only when every fullname came back, `truncated` is the boolean to branch on, and `missing_fullnames` names exactly which ids did not. Example: fullnames='t3_abc123,t3_def456'.",
|
|
374
488
|
shape: {
|
|
375
489
|
fullnames: z.string().min(1).describe(
|
|
376
490
|
"Comma-separated post fullnames, each a t3_ prefix followed by the base-36 id (e.g. 't3_abc123,t3_def456'). Up to 100. Required (path parameter).",
|
|
@@ -405,9 +519,9 @@ export const TOOLS = [
|
|
|
405
519
|
path: "/api/reddit/monitor/add",
|
|
406
520
|
method: "POST",
|
|
407
521
|
write: true,
|
|
408
|
-
filterSpecFields: ["subreddit", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
|
|
522
|
+
filterSpecFields: ["subreddit", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
|
|
409
523
|
description:
|
|
410
|
-
"Create a new Reddit monitor: watch one or more subreddits for new posts matching a filter, and get every match delivered to a webhook you've registered with reddit_monitor_webhook_create.
|
|
524
|
+
"Create a new Reddit monitor: watch one or more subreddits, or ALL of Reddit, for new posts (or comments, via `kind`) matching a filter, and get every match delivered to a webhook you've registered with reddit_monitor_webhook_create. Omit `subreddit` and set `q` for a sitewide keyword monitor covering every subreddit at once (posts only). By default matches go to EVERY active webhook on your account; pass `webhook_ids` to route this monitor's matches to only specific webhook(s). Every redditapis.com account holds a free entitlement of ONE all-of-Reddit post watch at a 60s cadence (up to 10,000 deliveries a day), so no subscription is needed to create that monitor. Naming a `subreddit`, matching comments, a faster cadence and any additional watch require a paid plan. Needs at least one monitor slot free (see reddit_monitor_list's `slots`). Forward-looking only from the moment of creation, or from `baseline_item_id` if given -- it never backfills posts that already existed. Returns the created monitor (with its `id`) on success, or `subscription_required` (402) if the account holds no recognised entitlement at all, `subreddit_scope_requires_paid_plan` (402) if a free account named a `subreddit`, `monitor_slots_exhausted` (402) if the plan's slot limit is reached, `sitewide_slots_exhausted` (402) if the plan's separate sitewide cap is reached, `distinct_subreddit_limit_reached` (402) if the account already watches as many DIFFERENT subreddits as the plan covers (the limit counts distinct subreddits across all your monitors, not monitors, and the same subreddit in two monitors counts once; see reddit_monitor_list's `slots.distinct_subreddits_total`), `sitewide_comment_monitoring_not_available` (501) if a sitewide monitor asks for comments, `subreddit_reserved` (400) if `subreddit` names 'all', `subreddit_not_found` (400) if a named subreddit does not exist, or `webhook_not_found` (400) if a `webhook_ids` entry is not yours.",
|
|
411
525
|
shape: {
|
|
412
526
|
...MONITOR_SUBREDDIT,
|
|
413
527
|
...MONITOR_FILTER_FIELDS,
|
|
@@ -417,13 +531,14 @@ export const TOOLS = [
|
|
|
417
531
|
baseline_item_id: z.string().optional().describe(
|
|
418
532
|
"A Reddit post fullname (e.g. 't3_abc123') to use as the starting point instead of 'now' -- matching starts strictly after this item. Omit to start from the moment of creation.",
|
|
419
533
|
),
|
|
534
|
+
...MONITOR_WEBHOOK_IDS,
|
|
420
535
|
},
|
|
421
536
|
},
|
|
422
537
|
{
|
|
423
538
|
name: "reddit_monitor_list",
|
|
424
539
|
path: "/api/reddit/monitor/list",
|
|
425
540
|
description:
|
|
426
|
-
"List every monitor on the caller's account, with each one's filter, active state, and cadence, plus a `slots` object ({used, total, tier}) showing how many monitor slots are purchased vs in use. Reading your own list never requires an active plan (a lapsed subscription shows an empty or paused list, not an error).",
|
|
541
|
+
"List every monitor on the caller's account, with each one's filter, active state, and cadence, plus a `slots` object ({used, total, tier}) showing how many monitor slots are purchased vs in use. Reading your own list never requires an active plan (a lapsed subscription shows an empty or paused list, not an error). `webhook_ids` NULL DOES NOT MEAN THE MONITOR HAS NO DESTINATION: null is the default and means matches go to EVERY active webhook on the account, which is the normal healthy state. A non-empty array narrows delivery to just those webhook ids. Never report a monitor as having no delivery target on the strength of a null here -- to see where a monitor's matches actually went, read reddit_monitor_deliveries, whose rows carry the resolved `webhook_id`.",
|
|
427
542
|
shape: {},
|
|
428
543
|
},
|
|
429
544
|
{
|
|
@@ -431,15 +546,21 @@ export const TOOLS = [
|
|
|
431
546
|
path: "/api/reddit/monitor/update",
|
|
432
547
|
method: "POST",
|
|
433
548
|
write: true,
|
|
434
|
-
filterSpecFields: ["subreddit", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
|
|
549
|
+
filterSpecFields: ["subreddit", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
|
|
435
550
|
description:
|
|
436
|
-
"Update an existing monitor: pause/resume it (`active`), change its poll interval (`cadence_s`),
|
|
551
|
+
"Update an existing monitor: pause/resume it (`active`), change its poll interval (`cadence_s`), switch between posts and comments (`kind`), replace its filter entirely, or re-target which webhook(s) it delivers to (`webhook_ids`). IMPORTANT: if you pass ANY filter field (subreddit, q, kind, domain, etc.), it REPLACES the whole filter, it does not merge with the existing one -- resupply every field you want kept, including `subreddit` AND `kind` (omitting `kind` reverts that monitor to posts-only). Same rule for `webhook_ids`: passing it REPLACES the monitor's targeting outright (an empty array clears back to 'every active webhook'); omitting it entirely leaves the monitor's existing targeting untouched. Omit all filter/webhook_ids fields to change only `active`/`cadence_s`. Returns 404 `monitor_not_found` if the id does not exist or is not yours, or 400 `webhook_not_found` if a `webhook_ids` entry is not yours.",
|
|
437
552
|
shape: {
|
|
438
553
|
...MONITOR_ID,
|
|
439
|
-
...{
|
|
554
|
+
...{
|
|
555
|
+
subreddit: z.array(z.string().min(1)).min(1).max(50).optional().describe(
|
|
556
|
+
MONITOR_SUBREDDIT_DESC +
|
|
557
|
+
" On an update, omitting this while passing another filter field makes the monitor SITEWIDE (the filter is replaced wholesale, not merged), so resupply it if you meant to keep the monitor scoped.",
|
|
558
|
+
),
|
|
559
|
+
},
|
|
440
560
|
...MONITOR_FILTER_FIELDS,
|
|
441
561
|
active: z.boolean().optional().describe("Set false to pause the monitor (stops matching/delivering), true to resume it."),
|
|
442
562
|
cadence_s: z.number().int().positive().optional().describe("New poll interval in seconds, same tier-floor clamping as reddit_monitor_add."),
|
|
563
|
+
...MONITOR_WEBHOOK_IDS,
|
|
443
564
|
},
|
|
444
565
|
},
|
|
445
566
|
{
|
|
@@ -449,14 +570,14 @@ export const TOOLS = [
|
|
|
449
570
|
write: true,
|
|
450
571
|
destructive: true,
|
|
451
572
|
description:
|
|
452
|
-
"
|
|
573
|
+
"Delete a monitor. There is no undo endpoint -- it stops matching immediately, its slot is freed for a new monitor, and it disappears from reddit_monitor_list. Its past deliveries are NOT erased: they remain queryable via reddit_monitor_deliveries (both scoped by monitor_id and in the aggregate, no-id view) forever. Returns 404 `monitor_not_found` if the id does not exist or is not yours.",
|
|
453
574
|
shape: { ...MONITOR_ID },
|
|
454
575
|
},
|
|
455
576
|
{
|
|
456
577
|
name: "reddit_monitor_health",
|
|
457
578
|
path: "/api/reddit/monitor/health",
|
|
458
579
|
description:
|
|
459
|
-
"Per-monitor health: whether it's active, its poll cadence, when it last matched something, and delivery counts for the last 24h (`delivered_24h`, `failed_24h`, `suppressed_24h`) plus whether its delivery ceiling has been hit (`ceiling_reached`).
|
|
580
|
+
"Per-monitor health: whether it's active, its poll cadence, when it last matched something, and delivery counts for the last 24h (`delivered_24h`, `failed_24h`, `suppressed_24h`) plus whether its delivery ceiling has been hit (`ceiling_reached`) and what that ceiling is (`daily_delivery_ceiling`). `suppressed_24h` COUNTS TWO DIFFERENT THINGS AND `suppressed_breakdown` SPLITS THEM: `ceiling` (the monitor's daily cap was reached) and `stale` (the item was already older than the freshness window when we first saw it, so it was withheld rather than delivered as if it were new). A stale withhold is the usual reason a monitor's delivered count sits far below its matched count with no error anywhere, and it is the freshness gate working as intended -- nothing failed, the endpoint is fine, no limit on the plan rejected them, and retrying cannot recover the items. Do NOT read `suppressed_24h > 0` as 'this monitor is over its limit': `ceiling_reached` is the field that answers that, and a stale withhold deliberately does not set it. When `suppressed_breakdown.resolved` is false the counts do not add up to the total and `unresolved_reason` says why, so do not report a split off them; `ceiling_reached` stays conservative in that case rather than being cleared. THOSE COUNTS ONLY COVER POSTS WE FETCHED. A post we never fetched was never matched and leaves no row behind, so it is absent from all three counters rather than counted in any of them -- zeros there mean 'no matches recorded', never 'nothing was missed'. `coverage_24h` is the separate field that speaks to fetching, and its `status` is the one to read first: `complete` (every poll reached the point where the previous poll finished), `degraded` (at least one poll was truncated and posts were lost unrecoverably -- see `gaps[]`, `posts_in_window_at_least` and the estimated `posts_missed_estimate`), `partial` (nothing found, but not every kind of loss was checked -- see `unobserved_events`), or `unknown` (the check could not be run at all -- see `reason`). Do NOT report a monitor as healthy on `unknown` or `partial`, and do not treat a null count as zero: both mean the question went unanswered. `coverage_24h` is fed by the poll log and covers BOTH ways a poll loses posts unrecoverably (`listing_exhausted`, where Reddit stopped serving older posts, and `poll_overflow`, where the feed outran one poll's page budget); `observed_events` names what was actually checked, and a status of `complete` is only ever awarded when both were. WHAT `complete` DOES AND DOES NOT ESTABLISH: it means every poll that RAN got back to where the previous poll finished, AND that every feed was actually polled. The second half is `stream_liveness`, the field to read FIRST. `coverage_24h` is computed from the poll log, and a feed that is never polled writes nothing to that log, so it records no truncated poll and the coverage read comes back clean -- identical to a healthy monitor. `stream_liveness` reads a different source (the polling registry plus the per-feed last-successful-poll stamp), neither of which a poll that never ran can produce. Its `status` is `live` (every feed checked within three times its own interval), `degraded` (at least one is not: see `streams[]` for `never_polled`, `stalled` or `unregistered` per feed), or `unknown` (could not be established, including `streams_awaiting_first_poll` on a monitor created moments ago, which clears itself). `coverage_24h` can never read `complete` while this is anything but `live`. Do NOT report a monitor as healthy unless `stream_liveness.status` is `live`, and do not read a zero delivery count on a `never_polled` feed as a quiet subreddit: nothing was checked, so nothing could match. `gaps[]` IS A SAMPLE, NOT THE WHOLE LIST: it carries the most recent gaps only, `gaps_returned` says how many are in it, `gaps_truncated` says whether more exist, and `gap_events_24h` is always the true total. Count gaps from `gap_events_24h` and never from the length of `gaps[]`. FINALLY, `cadence` ANSWERS 'AM I BEING CHECKED AS OFTEN AS I PAY TO BE', which none of the fields above can. Cadence is a priced feature, so this is the field that says whether the monitor is being served the interval its plan sells. `promised_cadence_s` is the floor the account's CURRENT plan includes and `requested_cadence_s` is what this monitor is actually set to; `meets_entitlement` false means the monitor is set slower than the plan allows, which happens because a plan upgrade does NOT re-cadence monitors that already exist -- the fix is to set `cadence_s` on the monitor, and a deliberately slower cadence is also a legitimate choice. `last_checked_s_ago`, `freshness_ratio` and `within_margin` describe the slowest feed feeding this monitor, named in `slowest_stream`. READ `freshness_reading` BEFORE QUOTING ANY OF THEM: it is ONE INSTANTANEOUS SAMPLE, not an average and not a sustained verdict, so a single reading past the margin is not by itself proof of under-service. Every one of these is TRI-STATE and null NEVER means fine: a null `within_margin` or `meets_entitlement` means the question could not be answered, and `unknown_reason` says why. Do not report a monitor as on-cadence on a null.",
|
|
460
581
|
shape: { ...MONITOR_ID },
|
|
461
582
|
},
|
|
462
583
|
{
|
|
@@ -467,7 +588,7 @@ export const TOOLS = [
|
|
|
467
588
|
shape: {
|
|
468
589
|
id: z.string().optional().describe("Narrow to one monitor's history. Omit to aggregate across every monitor you own."),
|
|
469
590
|
status: z.enum(["pending", "delivered", "failed", "dead", "suppressed"]).optional().describe(
|
|
470
|
-
"Filter to one delivery status. 'dead' = retries exhausted, gave up. 'suppressed' = matched but deliberately not sent
|
|
591
|
+
"Filter to one delivery status. 'dead' = retries exhausted, gave up. 'suppressed' = matched but deliberately not sent, and `payload.suppressed.reason` says which of the two reasons applied: 'delivery_ceiling' (the monitor's daily cap) or 'stale_item' (the item was already older than the freshness window when we first saw it, so it was withheld rather than delivered as if it were new). A 'stale_item' row is NOT a fault and was NOT rejected by any plan limit, and retrying cannot recover it; `payload.suppressed` carries the age and the threshold. Omit for all statuses.",
|
|
471
592
|
),
|
|
472
593
|
limit: z.number().int().min(1).max(200).optional().describe("Max rows to return, 1 to 200. Default 50."),
|
|
473
594
|
before: z.string().optional().describe("ISO 8601 timestamp cursor for pagination -- pass the `created_at` of the oldest row from the previous page to fetch older deliveries."),
|
|
@@ -483,7 +604,7 @@ export const TOOLS = [
|
|
|
483
604
|
shape: {
|
|
484
605
|
url: z.string().url().describe("HTTPS URL to deliver matches to. Must be publicly reachable HTTPS, no embedded credentials, no loopback/private/link-local address."),
|
|
485
606
|
kind: z.enum(["webhook", "slack", "discord", "email"]).optional().describe(
|
|
486
|
-
"Payload shape to send. 'slack'/'discord' format as native incoming-webhook messages; 'webhook'
|
|
607
|
+
"Payload shape to send. PREFER OMITTING THIS: for a hooks.slack.com or discord.com/api/webhooks URL the kind is inferred from the host, and the response reports what it inferred in `kind_inferred_from`. 'slack'/'discord' format as native incoming-webhook messages; 'webhook' sends redditapis' generic signed JSON envelope and is the fallback only for a host we do not recognise; 'email' is not yet a real delivery transport. Passing 'webhook' for a Slack or Discord URL does NOT force the generic envelope (that combination can never deliver -- Slack answers 400 invalid_payload); the host wins and `kind_corrected_from` says so. Passing one SPECIFIC kind for a different platform's host (e.g. 'discord' with a hooks.slack.com URL) is refused with `webhook_kind_mismatch` (400) rather than stored.",
|
|
487
608
|
),
|
|
488
609
|
},
|
|
489
610
|
},
|
|
@@ -499,7 +620,7 @@ export const TOOLS = [
|
|
|
499
620
|
method: "POST",
|
|
500
621
|
write: true,
|
|
501
622
|
description:
|
|
502
|
-
"Send a one-off test delivery to a registered webhook (rate-limited to 10/min) so you can confirm it's wired up correctly before waiting for a real match. Uses the webhook's `kind` to format the test payload the same way a real delivery would. Returns 404 `webhook_not_found` if the id does not exist or is not yours, or `webhook_url_rejected` if the URL fails re-validation (e.g. now resolves to a private address).",
|
|
623
|
+
"Send a one-off test delivery to a registered webhook (rate-limited to 10/min) so you can confirm it's wired up correctly before waiting for a real match. Uses the webhook's `kind` to format the test payload the same way a real delivery would. On failure the response carries `reason` and `status` plus TWO fields that say what to actually do: `hint`, our sentence naming the fix (most often that the target's `kind` does not match its host, which no test can succeed through), and `detail`, a bounded, sanitised copy of what the destination itself replied. Report `hint` to the user rather than the bare `reason` -- `http_error` with a 400 names no field, no value and no remedy. Returns 404 `webhook_not_found` if the id does not exist or is not yours, or `webhook_url_rejected` if the URL fails re-validation (e.g. now resolves to a private address).",
|
|
503
624
|
shape: { ...WEBHOOK_ID },
|
|
504
625
|
},
|
|
505
626
|
{
|