@tickerbot/mcp-server 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/dist/tools.js CHANGED
@@ -15,49 +15,57 @@
15
15
  export const tools = [
16
16
  {
17
17
  "name": "tickerbot_list_tickers",
18
- "description": "List active tickers from the Tickerbot universe (~14,500 US equities, plus the US Treasury curve and Fed policy rates under `R:`, and major FX pairs, spot metals and crypto under `X:`). Use `tickers` for bulk lookup of named symbols (returns full rows); otherwise walks the universe alphabetically with `cursor` pagination. Supports filters: search, asset_type, exchange, sector, min_market_cap.",
18
+ "description": "Every symbol we track, active or delisted, as one identity row each. Use `/v2/tickers/{ticker}` for the full row.",
19
19
  "inputSchema": {
20
20
  "type": "object",
21
21
  "properties": {
22
- "tickers": {
23
- "type": "string",
24
- "description": "Comma-separated symbols (max 50). When set, returns full rows for these symbols and pagination params are ignored."
25
- },
26
- "limit": {
27
- "type": "integer",
28
- "description": "Page size. Max 1000. Default 50."
29
- },
30
- "cursor": {
22
+ "search": {
31
23
  "type": "string",
32
- "description": "Opaque cursor from a prior response."
24
+ "description": "Case-insensitive match on `ticker` or `name`, max 64 characters (longer is a 400). Results are ranked: an exact ticker match first, then symbols that start with the term, then name matches — alphabetical within each rank. The cursor carries the rank, so paging a search never repeats or skips."
33
25
  },
34
- "search": {
26
+ "asset_class": {
35
27
  "type": "string",
36
- "description": "Case-insensitive substring filter on ticker/name. Orders results by market_cap desc."
28
+ "description": "Filter by asset class — `stocks`, `rates`, `crypto`, `fx`, or a comma-separated list (the live classes today; validated for shape, not against a fixed list, so a well-formed class we don't track simply matches nothing — same contract as scan). Omit for every class. This is the class of INSTRUMENT, distinct from `asset_type` below (the instrument type within equities). Every row carries its `asset_class`, so a non-equity row identifies itself."
37
29
  },
38
30
  "asset_type": {
39
31
  "type": "string",
40
- "description": "Filter by INSTRUMENT TYPE within equities: the stored value (CS, ETF, ADRC, PFD, FUND, UNIT, SP, ETS, WARRANT, RIGHT, ETN, ETV), or \"equity\" for the equity-like set. This is not an asset class — `asset_type=crypto` is rejected; crypto is in the main list under its X: symbols."
32
+ "description": "Filter by instrument type WITHIN equities — the stored `asset_type` value (`CS`, `ETF`, `ADRC`, `PFD`, `FUND`, `UNIT`, `SP`, `ETS`, `WARRANT`, `RIGHT`, `ETN`, `ETV`), matched case-insensitively. `equity` is a convenience value expanding to the equity-like set. This is NOT an asset class: `asset_type=crypto` is rejected — use `asset_class=crypto`.",
33
+ "enum": [
34
+ "CS",
35
+ "ETF",
36
+ "ADRC",
37
+ "PFD",
38
+ "FUND",
39
+ "UNIT",
40
+ "SP",
41
+ "ETS",
42
+ "WARRANT",
43
+ "RIGHT",
44
+ "ETN",
45
+ "ETV",
46
+ "equity"
47
+ ]
41
48
  },
42
49
  "exchange": {
43
50
  "type": "string",
44
- "description": "Filter by exchange (e.g. \"XNYS\", \"XNAS\", \"BATS\")."
45
- },
46
- "sector": {
47
- "type": "string",
48
- "description": "Exact-match sector filter (e.g. \"Technology\")."
49
- },
50
- "min_market_cap": {
51
- "type": "number",
52
- "description": "Minimum market cap in USD. Orders results by market_cap desc."
51
+ "description": "Filter by exchange name — the value rows carry in their `exchange` field. MIC codes (`XNAS`, `XNYS`, `BATS`) are also accepted and match `exchange_mic`. A malformed value (non-letters, over 16 chars) is a 400.",
52
+ "enum": [
53
+ "NASDAQ",
54
+ "NYSE",
55
+ "NYSE Arca",
56
+ "Cboe BZX",
57
+ "NYSE American",
58
+ "OTC Link"
59
+ ]
53
60
  },
54
- "universe": {
55
- "type": "string",
56
- "description": "Restrict to a universe slug (top_10, top_100, or a saved one)."
61
+ "limit": {
62
+ "type": "integer",
63
+ "description": "Page size. Max 1000.",
64
+ "default": 50
57
65
  },
58
- "asset_class": {
66
+ "cursor": {
59
67
  "type": "string",
60
- "description": "Filter by asset class — `stocks`, `rates`, `crypto`, `fx`, or a comma-separated list. Omit for every class. Distinct from asset_type (the instrument type within equities)."
68
+ "description": "Opaque cursor from the previous response's `next_cursor` field. Continues the walk from after that page. A cursor minted under `search` only resumes the same search."
61
69
  }
62
70
  }
63
71
  },
@@ -65,32 +73,39 @@ export const tools = [
65
73
  "method": "GET",
66
74
  "path": "/v2/tickers",
67
75
  "paramLocation": {
68
- "tickers": "query",
69
- "limit": "query",
70
- "cursor": "query",
71
76
  "search": "query",
77
+ "asset_class": "query",
72
78
  "asset_type": "query",
73
79
  "exchange": "query",
74
- "sector": "query",
75
- "min_market_cap": "query",
76
- "universe": "query",
77
- "asset_class": "query"
80
+ "limit": "query",
81
+ "cursor": "query"
78
82
  }
79
83
  }
80
84
  },
81
85
  {
82
86
  "name": "tickerbot_get_ticker",
83
- "description": "Get the full current row for one ticker — every column on the schema (price, change, indicators like rsi_14, every boolean flag like above_sma_50, fundamentals like pe_ratio). Pass `asof` (YYYY-MM-DD or ISO timestamp like 2026-07-20T15:30:00Z) for the row as it stood at that past moment — date-only gives the close of that day.",
87
+ "description": "The full ticker row, every signal on the schema page, for one symbol or a comma list of up to 50. Right now, or with `asof`, as of any past date. Pass a comma list of up to 50 symbols for a batch (`data` keyed by symbol plus `not_found`). Crypto is the X-prefixed pair (X:BTCUSD) — bare BTC/ETH are US-listed ETFs.",
84
88
  "inputSchema": {
85
89
  "type": "object",
86
90
  "properties": {
87
91
  "ticker": {
88
92
  "type": "string",
89
- "description": "Symbol. Case-insensitive. Equities: bare symbol (AAPL). Crypto: X-prefixed pair (X:BTCUSD) — bare BTC/ETH are US-listed ETFs, not spot crypto."
93
+ "description": "One symbol, or a comma-separated list of up to 50 for a batch response keyed by symbol. Case-insensitive. Equities are bare symbols (`AAPL`); every other class carries a prefix — rates (`R:SOFR`), crypto (`X:BTCUSD`), fx (`X:EURUSD`). Bare `BTC`/`ETH` are US-listed ETFs, not spot crypto. See Tickers."
90
94
  },
91
95
  "asof": {
92
96
  "type": "string",
93
- "description": "Optional YYYY-MM-DD or ISO timestamp. Date-only returns the row at close of that day; a timestamp returns the row as of that moment (finest tier covering each column)."
97
+ "description": "Optional. Target moment as `YYYY-MM-DD` (that day's close) or an ISO timestamp (that intraday moment) — the same read as it stood then, unlimited depth. Full contract under As of a past date."
98
+ },
99
+ "interval": {
100
+ "type": "string",
101
+ "description": "Grain the past state is reconstructed at: `1m`, `1h`, `1d`, or `auto` (default). Only valid alongside `asof` — a live read with `interval` is a 400. Details under As of a past date.",
102
+ "enum": [
103
+ "1m",
104
+ "1h",
105
+ "1d",
106
+ "auto"
107
+ ],
108
+ "default": "auto"
94
109
  }
95
110
  },
96
111
  "required": [
@@ -102,116 +117,116 @@ export const tools = [
102
117
  "path": "/v2/tickers/{ticker}",
103
118
  "paramLocation": {
104
119
  "ticker": "path",
105
- "asof": "query"
120
+ "asof": "query",
121
+ "interval": "query"
106
122
  }
107
123
  }
108
124
  },
109
125
  {
110
- "name": "tickerbot_get_ticker_history",
111
- "description": "Time-travel SNAPSHOT: the full wide row for one ticker as it stood at a past date (one row, not a series — for a multi-column time SERIES use tickerbot_get_series). Returns indicators, boolean flags, and the most-recent fundamentals known on that date. Unlimited depth on every plan.",
126
+ "name": "tickerbot_subscribe_ticker",
127
+ "description": "Push one ticker: we POST your endpoint whenever it matches the condition you give. Webhooks need a paid plan (Free has no webhook slots). Omit `target_url` for in-app delivery.",
112
128
  "inputSchema": {
113
129
  "type": "object",
114
130
  "properties": {
115
131
  "ticker": {
116
132
  "type": "string",
117
- "description": "Symbol."
133
+ "description": "Case-insensitive. Equities are bare symbols (`AAPL`); every other class carries a prefix — rates (`R:SOFR`), crypto (`X:BTCUSD`), fx (`X:EURUSD`). Bare `BTC`/`ETH` are US-listed ETFs, not spot crypto. See Tickers."
118
134
  },
119
- "asof": {
120
- "type": "string",
121
- "description": "Target date as YYYY-MM-DD or full ISO timestamp."
122
- }
123
- },
124
- "required": [
125
- "ticker",
126
- "asof"
127
- ]
128
- },
129
- "endpoint": {
130
- "method": "GET",
131
- "path": "/v2/tickers/{ticker}/history",
132
- "paramLocation": {
133
- "ticker": "path",
134
- "asof": "query"
135
- }
136
- }
137
- },
138
- {
139
- "name": "tickerbot_get_series",
140
- "description": "THE series primitive: cross-ticker, multi-column time series on one aligned grid. Pick up to 25 columns (price, OHLCV, indicators like rsi_14, boolean flags, custom signals) and up to 50 tickers; get one flat row per ticker per interval step ({ticker, t, price, rsi_14, …}), cursor-paged backward. `transitions_only=true` with boolean columns returns only the rows where a flag CHANGED — \"every golden_cross flip this year\" in one call. All-time on every plan. Replaces looping asof snapshots per date, and replaces the sunset per-ticker history routes.",
141
- "inputSchema": {
142
- "type": "object",
143
- "properties": {
144
- "ticker": {
135
+ "q": {
145
136
  "type": "string",
146
- "description": "Single symbol (alias of `tickers`, wins when both are set). One of ticker/tickers is required."
137
+ "description": "WHERE-clause fragment using signal names from the schema — the same grammar as /v2/scan. (`condition` accepted as an alias.)"
147
138
  },
148
- "tickers": {
139
+ "condition": {
149
140
  "type": "string",
150
- "description": "Comma-separated symbols, max 50, all sharing one time grid. One of ticker/tickers is required."
141
+ "description": "Original name for `q` — accepted as well. The same WHERE-clause fragment; send either spelling."
151
142
  },
152
- "columns": {
143
+ "target_url": {
153
144
  "type": "string",
154
- "description": "Comma list of columns (max 25). `fields` is a permanent alias. Defaults to a small set intersected with the interval's schema (intraday tiers carry fewer columns than daily — e.g. market_cap is daily-only)."
145
+ "description": "https:// URL to POST when the condition fires. Omit for in-app delivery (visible in the dashboard)."
155
146
  },
156
- "interval": {
147
+ "channel": {
157
148
  "type": "string",
158
- "description": "Grid granularity. `1w` weekly, `1q` fiscal-quarterly (fundamentals).",
149
+ "description": "Delivery channel. `webhook` (POST to `target_url`), `discord` (post an embed to `discord_url`), `in_app` (dashboard only), or `mobile_push` (notify a phone signed in to the Tickerbot mobile app; requires a `device_id` from `POST /v2/devices/register`). Inferred when omitted: `webhook` if `target_url` is set, `discord` if `discord_url` is set, else `in_app`. `slack` is reserved and returns `501`. See the Delivery channels guide.",
159
150
  "enum": [
160
- "1m",
161
- "1h",
162
- "1d",
163
- "1w",
164
- "1q"
151
+ "webhook",
152
+ "discord",
153
+ "in_app",
154
+ "mobile_push"
165
155
  ]
166
156
  },
167
- "from": {
157
+ "discord_url": {
168
158
  "type": "string",
169
- "description": "Earliest timestamp (inclusive), YYYY-MM-DD or ISO."
159
+ "description": "Discord incoming-webhook URL (`https://discord.com/api/webhooks/…`). Required when `channel` is `discord`. Stored as a posting credential: the create response echoes it back under `channel_config`, but every later read (list, get, deliveries) strips it and sets `channel_config_present: true` instead."
170
160
  },
171
- "to": {
161
+ "device_id": {
172
162
  "type": "string",
173
- "description": "Latest timestamp (inclusive; a bare date means through that day)."
163
+ "description": "Device to notify, from `POST /v2/devices/register`. Required when `channel` is `mobile_push`; unknown ids are a 404 `device_not_found`."
174
164
  },
175
- "transitions_only": {
176
- "type": "boolean",
177
- "description": "Only rows where a boolean column changed value (requires at least one boolean column). Each row carries `transition_drivers` naming the flags that flipped."
165
+ "cadence": {
166
+ "type": "string",
167
+ "description": "How often to evaluate. `realtime` (the default) is evaluated on every data refresh (~1×/min); `hourly` and `nyse_open` throttle to a batch schedule. `1m` is a deprecated alias for `realtime`.",
168
+ "enum": [
169
+ "realtime",
170
+ "hourly",
171
+ "nyse_open"
172
+ ]
178
173
  },
179
- "limit": {
180
- "type": "integer",
181
- "description": "Rows per page. Max 1000. Default 252."
174
+ "name": {
175
+ "type": "string",
176
+ "description": "Human-readable label (up to 80 chars). Defaults to `<TICKER>: <query>`."
182
177
  },
183
- "cursor": {
178
+ "columns": {
179
+ "type": "string",
180
+ "description": "Comma-separated extra signals to include in each fired payload match row, beyond the standard set (`ticker`, `name`, `asset_type`, `price`, `change_1d_pct`, `market_cap`). Each must be a real signal; an unknown signal is rejected at creation. `fields` accepted as an alias — and note the RESPONSE reports them under `fields`, as an array."
181
+ },
182
+ "order": {
183
+ "type": "string",
184
+ "description": "Signal the fired payload's match lists are sorted by before the 100-row cap is applied, so a truncated list is the deterministic top 100 rather than an arbitrary sample. Must be a real signal (validated at creation).",
185
+ "default": "market_cap"
186
+ },
187
+ "dir": {
184
188
  "type": "string",
185
- "description": "Opaque cursor from a prior response — pages older."
189
+ "description": "Sort direction for `order`.",
190
+ "enum": [
191
+ "asc",
192
+ "desc"
193
+ ],
194
+ "default": "desc"
186
195
  }
187
196
  },
188
- "required": []
197
+ "required": [
198
+ "ticker",
199
+ "q"
200
+ ]
189
201
  },
190
202
  "endpoint": {
191
- "method": "GET",
192
- "path": "/v2/series",
203
+ "method": "POST",
204
+ "path": "/v2/tickers/{ticker}/subscribe",
193
205
  "paramLocation": {
194
- "ticker": "query",
195
- "tickers": "query",
196
- "columns": "query",
197
- "interval": "query",
198
- "from": "query",
199
- "to": "query",
200
- "transitions_only": "query",
201
- "limit": "query",
202
- "cursor": "query"
206
+ "ticker": "path",
207
+ "q": "body",
208
+ "condition": "body",
209
+ "target_url": "body",
210
+ "channel": "body",
211
+ "discord_url": "body",
212
+ "device_id": "body",
213
+ "cadence": "body",
214
+ "name": "body",
215
+ "columns": "body",
216
+ "order": "body",
217
+ "dir": "body"
203
218
  }
204
219
  }
205
220
  },
206
221
  {
207
222
  "name": "tickerbot_get_ticker_coverage",
208
- "description": "Data-coverage report for one ticker — which intervals and columns exist, from when, at what depth. The honesty endpoint: ask this before assuming a gap in bars/series is a data outage vs. genuinely-never-covered (e.g. sub-hour bars outside the active universe, fundamentals on non-equities).",
223
+ "description": "For one ticker, what we hold and how far back — so an empty result is never ambiguous. Ask this before treating a gap in bars or series as an outage. `minute_tier.included: false` with `on_demand: true` is not a gap — sub-hour bars fetch from the provider on first request.",
209
224
  "inputSchema": {
210
225
  "type": "object",
211
226
  "properties": {
212
227
  "ticker": {
213
228
  "type": "string",
214
- "description": "Symbol."
229
+ "description": "Case-insensitive. Equities are bare symbols (`AAPL`); every other class carries a prefix — rates (`R:SOFR`), crypto (`X:BTCUSD`), fx (`X:EURUSD`). Bare `BTC`/`ETH` are US-listed ETFs, not spot crypto. See Tickers."
215
230
  }
216
231
  },
217
232
  "required": [
@@ -227,199 +242,196 @@ export const tools = [
227
242
  }
228
243
  },
229
244
  {
230
- "name": "tickerbot_get_ticker_bars",
231
- "description": "Get OHLCV (open/high/low/close/volume) bars for one or more symbols at a given interval, oldest-first. Pass a comma-separated `ticker` list (up to 50) for a bulk response keyed by symbol. Use `asof` for a single point-in-time bar, or `before`+`limit` to back-page. `1d`/`1h` cover the full universe with full history; sub-hour intervals back-fill on demand.",
245
+ "name": "tickerbot_list_signals",
246
+ "description": "Every signal you can name in a query — the built-in signals and your own custom signals, in one catalog. Use to discover the signal names and `q` vocabulary before composing a scan; custom signals appear with `kind: custom`.",
232
247
  "inputSchema": {
233
248
  "type": "object",
234
249
  "properties": {
235
- "ticker": {
236
- "type": "string",
237
- "description": "Symbol, or comma-separated list (up to 50) for a bulk response keyed by symbol."
238
- },
239
- "interval": {
250
+ "kind": {
240
251
  "type": "string",
241
- "description": "Bar interval.",
252
+ "description": "Filter by kind (`expression` accepted as a legacy alias for `custom`). Omit to return both.",
242
253
  "enum": [
243
- "1s",
244
- "1m",
245
- "5m",
246
- "15m",
247
- "30m",
248
- "1h",
249
- "1d"
254
+ "builtin",
255
+ "custom"
250
256
  ]
251
257
  },
252
258
  "limit": {
253
259
  "type": "integer",
254
- "description": "Most-recent N bars. Default 100."
255
- },
256
- "before": {
257
- "type": "string",
258
- "description": "Return the N bars ending strictly before this date/timestamp (YYYY-MM-DD or epoch-ms) — back-paging."
259
- },
260
- "from": {
261
- "type": "string",
262
- "description": "Window start (inclusive), YYYY-MM-DD / ISO / epoch-ms. Combines with `to`; page inside the window with `cursor`. Mutually exclusive with `asof` and `before` (400)."
263
- },
264
- "to": {
265
- "type": "string",
266
- "description": "Window end (inclusive; a bare YYYY-MM-DD means through the end of that day). Mutually exclusive with `asof` and `before` (400)."
267
- },
268
- "asof": {
269
- "type": "string",
270
- "description": "Return a single bar as of this date/timestamp (point-in-time)."
260
+ "description": "Page size for the custom-signal slice. Max 200.",
261
+ "default": 50
271
262
  },
272
263
  "cursor": {
273
264
  "type": "string",
274
- "description": "Continuation token from a prior response's `next_cursor` (sugar for `before`, and the way to page inside a from/to window)."
265
+ "description": "Opaque cursor from a prior response."
275
266
  }
276
- },
277
- "required": [
278
- "ticker",
279
- "interval"
280
- ]
267
+ }
281
268
  },
282
269
  "endpoint": {
283
270
  "method": "GET",
284
- "path": "/v2/tickers/{ticker}/bars/{interval}",
271
+ "path": "/v2/signals",
285
272
  "paramLocation": {
286
- "ticker": "path",
287
- "interval": "path",
273
+ "kind": "query",
288
274
  "limit": "query",
289
- "before": "query",
290
- "from": "query",
291
- "to": "query",
292
- "asof": "query",
293
275
  "cursor": "query"
294
276
  }
295
277
  }
296
278
  },
297
279
  {
298
- "name": "tickerbot_get_ticker_holdings",
299
- "description": "Get an ETF's constituent holdings and their weights, heaviest first. When the ticker is not an ETF, `is_etf` is false and `holdings` is empty. (The reverse lookup \"which ETFs hold NVDA\" is a scan filter on the `etf_holders` column, not this tool.)",
280
+ "name": "tickerbot_create_custom_signal",
281
+ "description": "A named boolean predicate you can reference anywhere a built-in signal goes.",
300
282
  "inputSchema": {
301
283
  "type": "object",
302
284
  "properties": {
303
- "ticker": {
285
+ "name": {
304
286
  "type": "string",
305
- "description": "ETF symbol."
287
+ "description": "Slug — `^[a-z][a-z0-9_]{0,63}$`. Must not collide with any built-in signal name, and 15 names are reserved outright: `columns`, plus the `/v2/series` OHLCV aliases `open`/`high`/`low`/`close`/`volume`/`vwap`/`trades` and `o`/`h`/`l`/`c`/`v`/`vw`/`n` (those resolve to bars before custom lookup). This is the signal's API handle: it's what you reference in `q` and in the CRUD path."
306
288
  },
307
- "limit": {
308
- "type": "integer",
309
- "description": "Max holdings returned. Max 5000. Default 500."
289
+ "expr": {
290
+ "type": "string",
291
+ "description": "Boolean SQL predicate. May reference built-in signals and other custom signals you own. Must evaluate to true/false. Max 4000 chars. Stricter grammar than scan `q`: comparisons, `AND`/`OR`/`NOT`, `IN`, `BETWEEN`, `IS [NOT] NULL`, arithmetic, and the functions `abs`/`coalesce`/`round`/`least`/`greatest` only — no `LIKE`/`ILIKE`, no `CASE`, no `::` casts, no other functions. An expression that scans fine can still be rejected here with `compile_failed`. What you send is what you read back: responses echo your expression VERBATIM, not its expansion. A signal referencing another custom of yours returns the reference as you typed it — the inlined SQL exists only internally, and is what a subscribe endpoint freezes into a webhook."
292
+ },
293
+ "description": {
294
+ "type": "string",
295
+ "description": "Free-form notes. Max 500 chars. Absent or empty comes back as `\"\"` rather than null."
310
296
  }
311
297
  },
312
298
  "required": [
313
- "ticker"
299
+ "name",
300
+ "expr"
314
301
  ]
315
302
  },
316
303
  "endpoint": {
317
- "method": "GET",
318
- "path": "/v2/tickers/{ticker}/holdings",
304
+ "method": "POST",
305
+ "path": "/v2/signals",
319
306
  "paramLocation": {
320
- "ticker": "path",
321
- "limit": "query"
307
+ "name": "body",
308
+ "expr": "body",
309
+ "description": "body"
322
310
  }
323
311
  }
324
312
  },
325
313
  {
326
- "name": "tickerbot_get_ticker_sectors",
327
- "description": "Get an ETF's sector allocation (sector weights, heaviest first). When the ticker is not an ETF, `is_etf` is false and `sectors` is empty.",
314
+ "name": "tickerbot_update_custom_signal",
315
+ "description": "Update a custom signal you own — its expression, description, or name.",
328
316
  "inputSchema": {
329
317
  "type": "object",
330
318
  "properties": {
331
- "ticker": {
319
+ "signal": {
332
320
  "type": "string",
333
- "description": "ETF symbol."
321
+ "description": "Custom signal slug (the signal name). A built-in name answers 404 — built-ins are read-only."
322
+ },
323
+ "expr": {
324
+ "type": "string",
325
+ "description": "New SQL expression. Re-validated and re-inlined against your other custom signals. Same strict grammar as create — no `LIKE`/`ILIKE`, `CASE`, `::` casts, or functions beyond `abs`/`coalesce`/`round`/`least`/`greatest`. The response echoes your expression verbatim, not its expansion."
326
+ },
327
+ "description": {
328
+ "type": "string",
329
+ "description": "New description. Not derived from `expr` — change both if the prose describes a threshold you are moving."
330
+ },
331
+ "new_name": {
332
+ "type": "string",
333
+ "description": "New slug — renames the signal and changes its API handle everywhere (same validation as create). Refused while other custom signals reference the current name. `name` is accepted as an alias (new_name wins when both are sent), but new_name is the unambiguous spelling since the URL already carries the current name."
334
334
  }
335
335
  },
336
336
  "required": [
337
- "ticker"
337
+ "signal"
338
338
  ]
339
339
  },
340
340
  "endpoint": {
341
- "method": "GET",
342
- "path": "/v2/tickers/{ticker}/sectors",
341
+ "method": "PATCH",
342
+ "path": "/v2/signals/{signal}",
343
343
  "paramLocation": {
344
- "ticker": "path"
344
+ "signal": "path",
345
+ "expr": "body",
346
+ "description": "body",
347
+ "new_name": "body"
345
348
  }
346
349
  }
347
350
  },
348
351
  {
349
- "name": "tickerbot_list_signals_catalog",
350
- "description": "List the unified signal catalog: every built-in column on the schema (`kind: builtin`) plus the caller's custom signals (`kind: expression`). Built-in rows carry the audited spec metadata — description, category, update cadence, ticker coverage (`ticker_scope`), history depth (`history`/`history_since`), queryable resolutions, and asset classes. Use to discover what `q=` clauses and signal names are available before composing a scan.",
352
+ "name": "tickerbot_delete_custom_signal",
353
+ "description": "Delete one of your custom signals. Refused by default if another of your signals references it.",
351
354
  "inputSchema": {
352
355
  "type": "object",
353
356
  "properties": {
354
- "kind": {
357
+ "signal": {
355
358
  "type": "string",
356
- "description": "Filter by kind. Omit for both. (`custom` is accepted as a legacy alias of `expression`.)",
357
- "enum": [
358
- "builtin",
359
- "expression"
360
- ]
359
+ "description": "Custom signal slug (the signal name). A built-in name answers 404 — built-ins are read-only."
361
360
  },
362
- "limit": {
363
- "type": "integer",
364
- "description": "Page size for custom slice. Max 200. Default 50."
365
- },
366
- "cursor": {
367
- "type": "string",
368
- "description": "Opaque cursor."
361
+ "force": {
362
+ "type": "boolean",
363
+ "description": "When `true`, skip the reference check and delete. References will break on next recompile.",
364
+ "default": false
369
365
  }
370
- }
366
+ },
367
+ "required": [
368
+ "signal"
369
+ ]
371
370
  },
372
371
  "endpoint": {
373
- "method": "GET",
374
- "path": "/v2/signals",
372
+ "method": "DELETE",
373
+ "path": "/v2/signals/{signal}",
375
374
  "paramLocation": {
376
- "kind": "query",
377
- "limit": "query",
378
- "cursor": "query"
375
+ "signal": "path",
376
+ "force": "query"
379
377
  }
380
378
  }
381
379
  },
382
380
  {
383
- "name": "tickerbot_get_signals_match",
384
- "description": "Find tickers that match a single signal right now (or at a past moment with `asof`). Booleans need no condition. Numerics need a `condition` like \">70\" or \"<=200\". Sorted by signal value desc for numerics.",
381
+ "name": "tickerbot_get_signal",
382
+ "description": "The state of a signal is the set of tickers matching it right now, or with `asof`, as of any moment. One name, the whole market, one call. Booleans need no `condition`; numerics need one like \">70\". Sorted by signal value desc for numerics.",
385
383
  "inputSchema": {
386
384
  "type": "object",
387
385
  "properties": {
388
386
  "signal": {
389
387
  "type": "string",
390
- "description": "Column name on ticker (e.g. golden_cross_today, rsi_14, market_cap)."
388
+ "description": "A signal name. Booleans (e.g. `golden_cross`, `above_sma_50`) are detected automatically; numerics (e.g. `rsi_14`, `market_cap`, `pe_ratio`) require a condition."
391
389
  },
392
- "condition": {
390
+ "asof": {
393
391
  "type": "string",
394
- "description": "Required for numerics. Single bound: <op><value>, ops in (>, >=, =, !=, <, <=)."
392
+ "description": "Optional. Target moment as `YYYY-MM-DD` (that day's close) or an ISO timestamp (that intraday moment) — the same read as it stood then, unlimited depth. Full contract under As of a past date."
395
393
  },
396
- "asof": {
394
+ "interval": {
395
+ "type": "string",
396
+ "description": "Grain the past state is reconstructed at: `1m`, `1h`, `1d`, or `auto` (default). Only valid alongside `asof` — a live read with `interval` is a 400. Details under As of a past date.",
397
+ "enum": [
398
+ "1m",
399
+ "1h",
400
+ "1d",
401
+ "auto"
402
+ ],
403
+ "default": "auto"
404
+ },
405
+ "condition": {
397
406
  "type": "string",
398
- "description": "Optional YYYY-MM-DD or ISO timestamp. Date-only matches daily state; a timestamp matches the finest intraday state covering the query."
407
+ "description": "Required for numeric signals. Single bound, format `<op><value>`. Operators: `>`, `>=`, `=`, `!=`, `<`, `<=`. Examples: `>70`, `<=200`, `!=0`. Sending one with a boolean or custom signal returns 400 (it does not apply)."
399
408
  },
400
409
  "universe": {
401
410
  "type": "string",
402
- "description": "Optional universe slug."
411
+ "description": "Optional. Scope to a system or caller-owned universe slug."
412
+ },
413
+ "limit": {
414
+ "type": "integer",
415
+ "description": "Page size. Max 200.",
416
+ "default": 50
417
+ },
418
+ "cursor": {
419
+ "type": "string",
420
+ "description": "Opaque cursor from the previous response."
403
421
  },
404
422
  "sort_by": {
405
423
  "type": "string",
406
- "description": "Row order: `default` (alphabetic for booleans, highest-value-first for numerics) or `market_cap` (desc, adds market_cap to each row).",
424
+ "description": "Row order: `default` (alphabetic for booleans, highest-value-first for numerics) or `market_cap` (desc NULLS LAST; adds `market_cap` to each row). Live only — with `asof` it is a 400 (the snapshot's order is fixed).",
407
425
  "enum": [
408
426
  "default",
409
427
  "market_cap"
410
- ]
428
+ ],
429
+ "default": "default"
411
430
  },
412
431
  "include_active_since": {
413
432
  "type": "boolean",
414
- "description": "Built-in booleans only: adds `active_since` per row — when the flag last flipped true (from the spans archive)."
415
- },
416
- "limit": {
417
- "type": "integer",
418
- "description": "Page size. Max 200. Default 50."
419
- },
420
- "cursor": {
421
- "type": "string",
422
- "description": "Opaque cursor."
433
+ "description": "Built-in booleans only: adds `active_since` and `days_live` per row — the first day of the current true streak, from daily state (the day after the last false day; if the boolean has never been false since it first computed, the first true day). Looks back five years, so a boolean true for longer reports the window edge as a lower bound. Live only — a 400 with `asof`.",
434
+ "default": false
423
435
  }
424
436
  },
425
437
  "required": [
@@ -431,191 +443,146 @@ export const tools = [
431
443
  "path": "/v2/signals/{signal}",
432
444
  "paramLocation": {
433
445
  "signal": "path",
434
- "condition": "query",
435
446
  "asof": "query",
447
+ "interval": "query",
448
+ "condition": "query",
436
449
  "universe": "query",
437
- "sort_by": "query",
438
- "include_active_since": "query",
439
450
  "limit": "query",
440
- "cursor": "query"
451
+ "cursor": "query",
452
+ "sort_by": "query",
453
+ "include_active_since": "query"
441
454
  }
442
455
  }
443
456
  },
444
457
  {
445
- "name": "tickerbot_list_signal_events",
446
- "description": "Occurrence SPANS of a boolean signal for one ticker, newest-first. For STATE flags (above_sma_50, in_uptrend) each row is a true-WINDOW: started_at when it flipped true, ended_at when it flipped back (null while still true), with prices at both ends. For EVENT flags (golden_cross, gap_up) each row is a point firing (started_at = ended_at). \"Golden crosses in June\" is from=2026-06-01&to=2026-06-30. Built-in booleans only — numerics and custom signals have no precomputed spans (use tickerbot_get_series).",
458
+ "name": "tickerbot_subscribe_signal",
459
+ "description": "Push one signal: we POST your endpoint whenever any ticker starts matching it. Webhooks need a paid plan (Free has no webhook slots). Omit `ticker` to watch the whole universe.",
447
460
  "inputSchema": {
448
461
  "type": "object",
449
462
  "properties": {
450
463
  "signal": {
451
464
  "type": "string",
452
- "description": "Built-in boolean flag name."
465
+ "description": "Signal name from the schema (case-insensitive)."
453
466
  },
454
- "ticker": {
467
+ "condition": {
455
468
  "type": "string",
456
- "description": "Symbol."
469
+ "description": "Required for numeric signals. Shape: `\">70\"`, `\"<30\"`, `\">=100\"`, `\"=50\"`. Sending one with a boolean or custom signal returns 400 (it does not apply)."
457
470
  },
458
- "from": {
471
+ "ticker": {
459
472
  "type": "string",
460
- "description": "Window start (inclusive) on each span's started_at — YYYY-MM-DD, ISO, or epoch-ms."
473
+ "description": "Restrict to a single ticker. Default: any ticker."
461
474
  },
462
- "to": {
475
+ "universe": {
463
476
  "type": "string",
464
- "description": "Window end (inclusive) on started_at; a bare YYYY-MM-DD means through the end of that day."
465
- },
466
- "merge_gap_seconds": {
467
- "type": "integer",
468
- "description": "Interval-union: contiguous windows whose gap is ≤ N seconds collapse into one — de-fragments flags with thousands of per-tick rows (e.g. 3600 for daily flags). Default 0 = no merge. A cursor pins this; resend it unchanged when paging."
477
+ "description": "Restrict to a system or user-owned universe (e.g. `top_100`). `universe_id` accepted as an alias. Unknown universes are a 404 `universe_not_found`."
469
478
  },
470
- "limit": {
471
- "type": "integer",
472
- "description": "Page size. Max 1000, newest first."
479
+ "target_url": {
480
+ "type": "string",
481
+ "description": "https:// URL to POST when fired. Omit for in-app delivery."
473
482
  },
474
- "cursor": {
483
+ "channel": {
475
484
  "type": "string",
476
- "description": "Opaque cursor — pages older, inside the from/to window if one is set."
477
- }
478
- },
479
- "required": [
480
- "signal",
481
- "ticker"
482
- ]
483
- },
484
- "endpoint": {
485
- "method": "GET",
486
- "path": "/v2/signals/{signal}/{ticker}/events",
487
- "paramLocation": {
488
- "signal": "path",
489
- "ticker": "path",
490
- "from": "query",
491
- "to": "query",
492
- "merge_gap_seconds": "query",
493
- "limit": "query",
494
- "cursor": "query"
495
- }
496
- }
497
- },
498
- {
499
- "name": "tickerbot_create_custom_signal",
500
- "description": "Save a SQL WHERE expression as a named custom signal the caller can reference by name in future scans.",
501
- "inputSchema": {
502
- "type": "object",
503
- "properties": {
504
- "name": {
485
+ "description": "Delivery channel. `webhook` (POST to `target_url`), `discord` (post an embed to `discord_url`), `in_app` (dashboard only), or `mobile_push` (notify a phone signed in to the Tickerbot mobile app; requires a `device_id` from `POST /v2/devices/register`). Inferred when omitted: `webhook` if `target_url` is set, `discord` if `discord_url` is set, else `in_app`. `slack` is reserved and returns `501`. See the Delivery channels guide.",
486
+ "enum": [
487
+ "webhook",
488
+ "discord",
489
+ "in_app",
490
+ "mobile_push"
491
+ ]
492
+ },
493
+ "discord_url": {
505
494
  "type": "string",
506
- "description": "Snake_case identifier."
495
+ "description": "Discord incoming-webhook URL (`https://discord.com/api/webhooks/…`). Required when `channel` is `discord`. Stored as a posting credential: the create response echoes it back under `channel_config`, but every later read (list, get, deliveries) strips it and sets `channel_config_present: true` instead."
507
496
  },
508
- "expr": {
497
+ "device_id": {
509
498
  "type": "string",
510
- "description": "SQL WHERE expression. Same grammar as scan `q`."
499
+ "description": "Device to notify, from `POST /v2/devices/register`. Required when `channel` is `mobile_push`; unknown ids are a 404 `device_not_found`."
511
500
  },
512
- "description": {
501
+ "cadence": {
513
502
  "type": "string",
514
- "description": "Optional human description."
515
- }
516
- },
517
- "required": [
518
- "name",
519
- "expr"
520
- ]
521
- },
522
- "endpoint": {
523
- "method": "POST",
524
- "path": "/v2/signals",
525
- "paramLocation": {
526
- "name": "body",
527
- "expr": "body",
528
- "description": "body"
529
- }
530
- }
531
- },
532
- {
533
- "name": "tickerbot_update_custom_signal",
534
- "description": "Edit one of the caller's custom signals — supply `expr`, `description`, `new_name`, or any combination. Providing `expr` recompiles it against the live column whitelist. Renaming is refused (409) while other custom signals reference the current name. Built-in signals are read-only; only custom signals the caller owns can be patched. Available on every plan.",
535
- "inputSchema": {
536
- "type": "object",
537
- "properties": {
503
+ "description": "`realtime` (the default) is evaluated on every data refresh (~1×/min); `hourly` and `nyse_open` throttle to a batch schedule. `1m` is a deprecated alias for `realtime`.",
504
+ "enum": [
505
+ "realtime",
506
+ "hourly",
507
+ "nyse_open"
508
+ ]
509
+ },
538
510
  "name": {
539
511
  "type": "string",
540
- "description": "CURRENT slug — identifies which signal to edit."
512
+ "description": "Human-readable label (up to 80 chars). Defaults to the predicate — `at_52w_high` for a boolean, `rsi_14 > 70` for a numeric, prefixed with `<TICKER>: ` when `ticker` scopes it."
541
513
  },
542
- "new_name": {
514
+ "columns": {
543
515
  "type": "string",
544
- "description": "Rename the signal to this slug (snake_case, must not collide with a built-in column or another of your signals)."
516
+ "description": "Comma-separated extra signals to include in each fired payload match row, beyond the standard set (`ticker`, `name`, `asset_type`, `price`, `change_1d_pct`, `market_cap`). Each must be a real signal; an unknown signal is rejected at creation. `fields` accepted as an alias — and note the RESPONSE reports them under `fields`, as an array."
545
517
  },
546
- "expr": {
518
+ "order": {
547
519
  "type": "string",
548
- "description": "New SQL WHERE expression. Re-validated on save."
520
+ "description": "Signal the fired payload's match lists are sorted by before the 100-row cap is applied, so a truncated list is the deterministic top 100 rather than an arbitrary sample. Must be a real signal (validated at creation).",
521
+ "default": "market_cap"
549
522
  },
550
- "description": {
551
- "type": "string",
552
- "description": "New description."
553
- }
554
- },
555
- "required": [
556
- "name"
557
- ]
558
- },
559
- "endpoint": {
560
- "method": "PATCH",
561
- "path": "/v2/signals/{name}",
562
- "paramLocation": {
563
- "name": "path",
564
- "new_name": "body",
565
- "expr": "body",
566
- "description": "body"
567
- }
568
- }
569
- },
570
- {
571
- "name": "tickerbot_delete_custom_signal",
572
- "description": "Delete one of the caller's custom signals. Cascade-safe by default: refused with 409 if another custom signal references it (the error lists the referencing signals). Pass `force: true` to delete anyway — existing references will break on next recompile. Available on every plan.",
573
- "inputSchema": {
574
- "type": "object",
575
- "properties": {
576
- "name": {
523
+ "dir": {
577
524
  "type": "string",
578
- "description": "Custom signal slug."
579
- },
580
- "force": {
581
- "type": "boolean",
582
- "description": "When true, skip the reference check and delete anyway. Default false."
525
+ "description": "Sort direction for `order`.",
526
+ "enum": [
527
+ "asc",
528
+ "desc"
529
+ ],
530
+ "default": "desc"
583
531
  }
584
532
  },
585
533
  "required": [
586
- "name"
534
+ "signal"
587
535
  ]
588
536
  },
589
537
  "endpoint": {
590
- "method": "DELETE",
591
- "path": "/v2/signals/{name}",
538
+ "method": "POST",
539
+ "path": "/v2/signals/{signal}/subscribe",
592
540
  "paramLocation": {
593
- "name": "path",
594
- "force": "query"
541
+ "signal": "path",
542
+ "condition": "body",
543
+ "ticker": "body",
544
+ "universe": "body",
545
+ "target_url": "body",
546
+ "channel": "body",
547
+ "discord_url": "body",
548
+ "device_id": "body",
549
+ "cadence": "body",
550
+ "name": "body",
551
+ "columns": "body",
552
+ "order": "body",
553
+ "dir": "body"
595
554
  }
596
555
  }
597
556
  },
598
557
  {
599
558
  "name": "tickerbot_scan",
600
- "description": "Run a SQL WHERE clause against the live ticker universe (or against a past moment with `asof` — unlimited depth on every plan). Returns matching tickers sorted by chosen column, OR — with `group_by` — aggregate rollups instead of rows (breadth stats: \"count of tickers above their 200dma by sector\", \"median RSI by sector on 2026-03-03\"). The `q` grammar is a flat WHERE: column names from the schema, AND/OR/NOT, comparison operators, numeric/string literals. No JOIN or subqueries. Example: `gap_up AND market_cap < 2000000000 AND NOT earnings_this_week`.",
559
+ "description": "Every ticker matching a SQL WHERE clause. Right now, or with `asof`, as of any past date. The `q` grammar is a flat SQL WHERE over signal names: AND/OR/NOT, comparisons, numeric and string literals, custom signals by name. No JOIN or subqueries. With `group_by` the result is rollup rows, not tickers. Example: `gap_up AND market_cap < 2000000000 AND NOT earnings_this_week`.",
601
560
  "inputSchema": {
602
561
  "type": "object",
603
562
  "properties": {
604
563
  "q": {
605
564
  "type": "string",
606
- "description": "SQL WHERE expression. Max 4000 chars."
565
+ "description": "SQL WHERE expression. Max 4000 chars; semicolons, comments and write keywords are rejected. Your custom signals are valid here — each expands to its SQL at run time."
607
566
  },
608
- "universe": {
567
+ "asof": {
609
568
  "type": "string",
610
- "description": "Optional universe slug."
569
+ "description": "Optional. Target moment as `YYYY-MM-DD` (that day's close) or an ISO timestamp (that intraday moment) — the same read as it stood then, unlimited depth. Full contract under As of a past date."
611
570
  },
612
- "asof": {
571
+ "interval": {
613
572
  "type": "string",
614
- "description": "Optional YYYY-MM-DD or ISO timestamp for a historical scan. Unlimited depth on every plan."
573
+ "description": "Grain the past state is reconstructed at: `1m`, `1h`, `1d`, or `auto` (default). Only valid alongside `asof` — a live read with `interval` is a 400. Details under As of a past date.",
574
+ "enum": [
575
+ "1m",
576
+ "1h",
577
+ "1d",
578
+ "auto"
579
+ ],
580
+ "default": "auto"
615
581
  },
616
582
  "order": {
617
583
  "type": "string",
618
- "description": "Sort column. Default day_change_pct (rows) / tickers (aggregate)."
584
+ "description": "Signal to sort by. In aggregate mode the default is the count alias `tickers` — or, with a custom `select`, the last item's alias — sorted NULLS LAST with the group keys as tiebreak.",
585
+ "default": "change_1d_pct"
619
586
  },
620
587
  "dir": {
621
588
  "type": "string",
@@ -623,35 +590,46 @@ export const tools = [
623
590
  "enum": [
624
591
  "asc",
625
592
  "desc"
626
- ]
593
+ ],
594
+ "default": "desc"
627
595
  },
628
- "fields": {
596
+ "limit": {
597
+ "type": "integer",
598
+ "description": "Page size. Max 100. Aggregate mode does not paginate — it sets `truncated: true` when groups were cut, so sort with `order` to keep the ones you want.",
599
+ "default": 50
600
+ },
601
+ "cursor": {
629
602
  "type": "string",
630
- "description": "Comma-separated extra columns to include (row mode only)."
603
+ "description": "Opaque cursor from the previous response's `next_cursor`. Row mode only."
631
604
  },
632
- "group_by": {
605
+ "columns": {
633
606
  "type": "string",
634
- "description": "AGGREGATE MODE: 1–6 comma-separated group keys (columns or expressions, e.g. `sector`). Results become rollup rows instead of tickers."
607
+ "description": "Extra signals per row, ADDITIVE — the defaults are always present (ticker, name, asset_class, asset_type, price, change_1d_pct, gap_pct, relative_volume, market_cap). `fields` accepted as an alias."
635
608
  },
636
- "select": {
609
+ "full": {
610
+ "type": "boolean",
611
+ "description": "Return every signal instead of the default set. Mutually exclusive with `columns` — passing both is a 400.",
612
+ "default": false
613
+ },
614
+ "universe": {
637
615
  "type": "string",
638
- "description": "Aggregate output items (requires group_by). Default: group keys + COUNT(*) AS tickers. Aggregates: count/avg/sum/min/max/stddev/string_agg + FILTER (WHERE …). Alias items with AS. Example: `sector, COUNT(*) AS n, AVG(rsi_14) AS avg_rsi`."
616
+ "description": "Slug of a system universe (`top_10`, `top_100`) or one of your own. Omitted, the scan runs across all ~21,033 tracked tickers."
639
617
  },
640
- "having": {
618
+ "asset_class": {
641
619
  "type": "string",
642
- "description": "Aggregate filter (requires group_by). Example: `COUNT(*) >= 10`."
620
+ "description": "One or more asset classes — slug or comma-separated list (`stocks`, `rates`, `crypto`, `fx`). Validated for shape, not against a fixed list, so a well-formed class we don't track simply matches nothing. Echoed in `query`."
643
621
  },
644
- "limit": {
645
- "type": "integer",
646
- "description": "Page size. Max 100. Default 50. Aggregate mode does not paginate — response sets `truncated: true` when groups were cut."
622
+ "group_by": {
623
+ "type": "string",
624
+ "description": "AGGREGATE MODE: 1–6 group keys (signals, expressions, or one of your custom signals as a boolean key). Results become rollup rows. Name a key with `AS` to choose its JSON key (`market_cap > 1e11 AS mega`); an un-named expression is named for you rather than returned as `?column?`. Incompatible with `columns`/`full`/`cursor`; works with `asof`."
647
625
  },
648
- "cursor": {
626
+ "select": {
649
627
  "type": "string",
650
- "description": "Opaque cursor (row mode only)."
628
+ "description": "Aggregate output items (requires `group_by`). Default: the group keys + `COUNT(*) AS tickers`. Supports count/avg/sum/min/max/stddev/string_agg/bool_and/bool_or plus `FILTER (WHERE …)`, and your custom signals inside expressions. Alias with `AS`; a last item without one is a 400."
651
629
  },
652
- "full": {
653
- "type": "boolean",
654
- "description": "Row mode: return the FULL wide row for each match (every column) instead of the slim default projection."
630
+ "having": {
631
+ "type": "string",
632
+ "description": "Filter the aggregate rows (requires `group_by`). Custom signals are valid here too."
655
633
  }
656
634
  },
657
635
  "required": [
@@ -659,294 +637,385 @@ export const tools = [
659
637
  ]
660
638
  },
661
639
  "endpoint": {
662
- "method": "GET",
640
+ "method": "POST",
663
641
  "path": "/v2/scan",
664
642
  "paramLocation": {
665
- "q": "query",
666
- "universe": "query",
667
- "asof": "query",
668
- "order": "query",
669
- "dir": "query",
670
- "fields": "query",
671
- "group_by": "query",
672
- "select": "query",
673
- "having": "query",
674
- "limit": "query",
675
- "cursor": "query",
676
- "full": "query"
643
+ "q": "body",
644
+ "asof": "body",
645
+ "interval": "body",
646
+ "order": "body",
647
+ "dir": "body",
648
+ "limit": "body",
649
+ "cursor": "body",
650
+ "columns": "body",
651
+ "full": "body",
652
+ "universe": "body",
653
+ "asset_class": "body",
654
+ "group_by": "body",
655
+ "select": "body",
656
+ "having": "body"
677
657
  }
678
658
  }
679
659
  },
680
660
  {
681
- "name": "tickerbot_list_universes",
682
- "description": "List universes — your saved ones and/or the built-in system universes (top_10, top_100). Filter with `owner` (like the signals catalog's `kind`): `me` (default, your own), `system` (built-ins), or `all` (both). Each row carries `system: true|false`.",
661
+ "name": "tickerbot_subscribe_scan",
662
+ "description": "Push a whole query: we POST your endpoint every time the match set changes. Webhooks need a paid plan (Free has no webhook slots). Use for \"alert me when this happens\" requests.",
683
663
  "inputSchema": {
684
664
  "type": "object",
685
665
  "properties": {
686
- "owner": {
666
+ "q": {
667
+ "type": "string",
668
+ "description": "WHERE-clause expression using signal names — the same grammar and the same 4000-char cap as `POST /v2/scan`, so anything scannable is subscribable. Custom signals are expanded and frozen in at creation."
669
+ },
670
+ "universe": {
671
+ "type": "string",
672
+ "description": "System or user-owned universe to scope the scan. `universe_id` accepted as an alias. Unknown universes are a 404 `universe_not_found`."
673
+ },
674
+ "target_url": {
675
+ "type": "string",
676
+ "description": "https:// URL to POST when the match set changes. Omit for in-app delivery."
677
+ },
678
+ "channel": {
687
679
  "type": "string",
688
- "description": "Which universes to list.",
680
+ "description": "Delivery channel. `webhook` (POST to `target_url`), `discord` (embed to `discord_url`), `in_app` (dashboard only), or `mobile_push` (requires a `device_id` from `POST /v2/devices/register`). Inferred when omitted: `webhook` if `target_url` is set, `discord` if `discord_url` is set, else `in_app`. `slack` is reserved and returns `501`.",
689
681
  "enum": [
690
- "me",
691
- "system",
692
- "all"
682
+ "webhook",
683
+ "discord",
684
+ "in_app",
685
+ "mobile_push"
693
686
  ]
694
687
  },
695
- "limit": {
696
- "type": "integer",
697
- "description": "Page size (applies to your own; system universes are a small fixed set returned in full on the first page)."
698
- },
699
- "cursor": {
688
+ "discord_url": {
700
689
  "type": "string",
701
- "description": "Opaque cursor."
702
- }
703
- }
704
- },
705
- "endpoint": {
706
- "method": "GET",
707
- "path": "/v2/universes",
708
- "paramLocation": {
709
- "owner": "query",
710
- "limit": "query",
711
- "cursor": "query"
712
- }
713
- }
714
- },
715
- {
716
- "name": "tickerbot_list_system_universes",
717
- "description": "List the built-in system universes (`top_10`, `top_100` — the most-actively-traded tickers by 30-day trailing dollar volume, rebalanced monthly). Available to every account regardless of plan. Use these slugs as `universe` in scans/signals or `universe_id` when subscribing.",
718
- "inputSchema": {
719
- "type": "object",
720
- "properties": {}
721
- },
722
- "endpoint": {
723
- "method": "GET",
724
- "path": "/v2/universes/system",
725
- "paramLocation": {}
726
- }
727
- },
728
- {
729
- "name": "tickerbot_get_universe",
730
- "description": "Get one universe by slug, including its ticker list.",
731
- "inputSchema": {
732
- "type": "object",
733
- "properties": {
734
- "id": {
690
+ "description": "Discord incoming-webhook URL. Required when `channel` is `discord`. Stored as a posting credential: the create response echoes it back under `channel_config`, but every later read (list, get, deliveries) strips it and sets `channel_config_present: true` instead."
691
+ },
692
+ "device_id": {
735
693
  "type": "string",
736
- "description": "Universe slug."
737
- }
738
- },
739
- "required": [
740
- "id"
741
- ]
742
- },
743
- "endpoint": {
744
- "method": "GET",
745
- "path": "/v2/universes/{id}",
746
- "paramLocation": {
747
- "id": "path"
748
- }
749
- }
750
- },
751
- {
752
- "name": "tickerbot_create_universe",
753
- "description": "Create a new universe (named set of tickers) for scoping future scans.",
754
- "inputSchema": {
755
- "type": "object",
756
- "properties": {
757
- "id": {
694
+ "description": "Device to notify, from `POST /v2/devices/register`. Required when `channel` is `mobile_push`; unknown ids are a 404 `device_not_found`."
695
+ },
696
+ "cadence": {
758
697
  "type": "string",
759
- "description": "Optional slug (lowercase letters, digits, underscore). Auto-generated from name if omitted. Must be unique within the account."
698
+ "description": "`realtime` (the default) is evaluated on every data refresh (~1×/min); `hourly` and `nyse_open` throttle to a batch schedule. `1m` is a deprecated alias for `realtime`.",
699
+ "enum": [
700
+ "realtime",
701
+ "hourly",
702
+ "nyse_open"
703
+ ]
760
704
  },
761
705
  "name": {
762
706
  "type": "string",
763
- "description": "Human-readable name."
707
+ "description": "Human-readable label (up to 80 chars). Defaults to `scan: <q>`."
764
708
  },
765
- "description": {
709
+ "columns": {
766
710
  "type": "string",
767
- "description": "Optional free-form notes."
711
+ "description": "Extra signals per fired payload match row, beyond the standard set (`ticker`, `name`, `asset_type`, `price`, `change_1d_pct`, `market_cap`). Each must be a real signal; unknown ones are rejected at creation. `fields` accepted as an alias — and the RESPONSE reports them under `fields`, as an array."
768
712
  },
769
- "tickers": {
770
- "type": "array",
771
- "description": "List of ticker symbols.",
772
- "items": {
773
- "type": "string"
774
- }
713
+ "order": {
714
+ "type": "string",
715
+ "description": "Signal the payload's match lists are sorted by before the 100-row cap applies, so a truncated list is the deterministic top 100 rather than an arbitrary sample. Must be a real signal (validated at creation).",
716
+ "default": "market_cap"
717
+ },
718
+ "dir": {
719
+ "type": "string",
720
+ "description": "Sort direction for `order`.",
721
+ "enum": [
722
+ "asc",
723
+ "desc"
724
+ ],
725
+ "default": "desc"
775
726
  }
776
727
  },
777
728
  "required": [
778
- "name",
779
- "tickers"
729
+ "q"
780
730
  ]
781
731
  },
782
732
  "endpoint": {
783
733
  "method": "POST",
784
- "path": "/v2/universes",
734
+ "path": "/v2/scan/subscribe",
785
735
  "paramLocation": {
786
- "id": "body",
736
+ "q": "body",
737
+ "universe": "body",
738
+ "target_url": "body",
739
+ "channel": "body",
740
+ "discord_url": "body",
741
+ "device_id": "body",
742
+ "cadence": "body",
787
743
  "name": "body",
788
- "description": "body",
789
- "tickers": "body"
744
+ "columns": "body",
745
+ "order": "body",
746
+ "dir": "body"
790
747
  }
791
748
  }
792
749
  },
793
750
  {
794
- "name": "tickerbot_update_universe",
795
- "description": "Update one of the caller's universes. Pass `name`/`description` to relabel, `tickers` to replace the whole list, or `add`/`remove` to adjust subsets without replacing. System universes (`top_10`/`top_100`) cannot be edited.",
751
+ "name": "tickerbot_get_series",
752
+ "description": "Any signals for any tickers on one shared time grid — up to 50 tickers by 25 columns per call. One flat row per ticker per interval step, cursor-paged backward. `transitions_only: true` with boolean signals returns only the rows where a boolean flipped.",
796
753
  "inputSchema": {
797
754
  "type": "object",
798
755
  "properties": {
799
- "id": {
756
+ "tickers": {
800
757
  "type": "string",
801
- "description": "Universe slug."
758
+ "description": "Comma-separated symbols, up to 50 (POST accepts a JSON array). Exactly one of `tickers` or `ticker` is required; when both are passed, `ticker` wins — so sending both silently narrows the request to one symbol."
802
759
  },
803
- "name": {
760
+ "ticker": {
804
761
  "type": "string",
805
- "description": "New label."
762
+ "description": "Single-symbol form — `/v2/series?ticker=AAPL` is ticker history in its canonical spelling. Exactly one of `ticker` or `tickers` is required."
806
763
  },
807
- "description": {
764
+ "columns": {
808
765
  "type": "string",
809
- "description": "New notes."
766
+ "description": "Up to 25 columns (POST accepts an array): OHLCV names, signals, and your custom signals, freely mixed. Omitted → the ticker-history default set (price, change_1d_pct, relative_volume, market_cap), intersected with what the interval carries. At `1q`, `columns` is required and quarterly-only. `fields` accepted as an alias."
810
767
  },
811
- "tickers": {
812
- "type": "array",
813
- "description": "Replace the full ticker list.",
814
- "items": {
815
- "type": "string"
816
- }
768
+ "interval": {
769
+ "type": "string",
770
+ "description": "Grid granularity. `1w` resamples the daily tier weekly (Monday-keyed); `1q` is the fiscal-quarter grid.",
771
+ "enum": [
772
+ "1m",
773
+ "1h",
774
+ "1d",
775
+ "1w",
776
+ "1q"
777
+ ],
778
+ "default": "1d"
817
779
  },
818
- "add": {
819
- "type": "array",
820
- "description": "Add these tickers (deduplicated).",
821
- "items": {
822
- "type": "string"
823
- }
780
+ "from": {
781
+ "type": "string",
782
+ "description": "Earliest timestamp (inclusive), `YYYY-MM-DD` or ISO. Intraday requests default to a recent window (`1m`: 7 days, `1h`: 60 days) — the cursor keeps walking further back window-by-window, or pass `from` to widen it up front."
824
783
  },
825
- "remove": {
826
- "type": "array",
827
- "description": "Remove these tickers.",
828
- "items": {
829
- "type": "string"
830
- }
784
+ "to": {
785
+ "type": "string",
786
+ "description": "Latest timestamp (inclusive), `YYYY-MM-DD` or ISO."
787
+ },
788
+ "asof": {
789
+ "type": "string",
790
+ "description": "Point-in-time read: ONE row per ticker — the state at that instant — rather than a range. `YYYY-MM-DD` or a full ISO timestamp, the same meaning `asof` carries on `/v2/tickers`, `/v2/scan` and `/v2/signals`. Cannot be combined with `from`/`to` or `cursor` (400) — a point and a window are contradictory, and `limit` has no meaning under it. It also resolves WHICH COMPANY held the symbol at that instant: a ticker that changed hands returns the row of whoever traded it then, so `tickers=SHLD&asof=2010-06-30` returns Sears Holdings' price and `asof=2026-01-01` returns the Global X defence ETF. Returns the most recent row at or before the instant, so a date inside a trading gap gives the last row before it. At `interval=1q` the anchor is the date the quarter was REPORTED (earnings release / filing), not fiscal period end — you get the latest quarter that was public knowledge at the instant, with restatements after it excluded."
791
+ },
792
+ "limit": {
793
+ "type": "integer",
794
+ "description": "Grid steps per page (shared across tickers). Max 1000 — an over-cap `limit` is clamped to 1000 (house convention, `limit=10000` means \"max\"). Separately, tickers × limit may not exceed 25,000 rows per page — over THAT cap is an explicit 400.",
795
+ "default": 252
796
+ },
797
+ "cursor": {
798
+ "type": "string",
799
+ "description": "Opaque cursor from the previous response — every ticker pages backward in lockstep on the shared grid, no per-ticker gaps or duplicates."
800
+ },
801
+ "transitions_only": {
802
+ "type": "boolean",
803
+ "description": "Only rows where a boolean signal changed state. Accepted spellings: `true`/`1`/`yes` and `false`/`0`/`no` (case-insensitive) — anything else is a 400, never silently off. Requires at least one boolean signal (built-in boolean or custom signal); each returned row carries `transitions: {column: \"enter\"|\"exit\"}`, and `_meta` lists the driving columns. Strict truth: only literal `true` is \"on\", so `null → true` is an enter and `true → null` an exit (a backfill boundary reads as an edge). Edges need a prior observation — on the oldest page of a walk the first row has no predecessor and yields no edge. A flip is dated by the state table and does not move with the column list: one recorded on a non-trading carry row keeps that date, with any bar columns `null` on that row (no bar exists there)."
831
804
  }
832
- },
833
- "required": [
834
- "id"
835
- ]
805
+ }
836
806
  },
837
807
  "endpoint": {
838
- "method": "PATCH",
839
- "path": "/v2/universes/{id}",
808
+ "method": "GET",
809
+ "path": "/v2/series",
840
810
  "paramLocation": {
841
- "id": "path",
842
- "name": "body",
843
- "description": "body",
844
- "tickers": "body",
845
- "add": "body",
846
- "remove": "body"
811
+ "tickers": "query",
812
+ "ticker": "query",
813
+ "columns": "query",
814
+ "interval": "query",
815
+ "from": "query",
816
+ "to": "query",
817
+ "asof": "query",
818
+ "limit": "query",
819
+ "cursor": "query",
820
+ "transitions_only": "query"
847
821
  }
848
822
  }
849
823
  },
850
824
  {
851
- "name": "tickerbot_delete_universe",
852
- "description": "Delete one of the caller's universes. System universes (`top_10`/`top_100`) cannot be deleted. Webhooks that reference the deleted universe will fail on their next eval, so clean those up first.",
825
+ "name": "tickerbot_get_bars",
826
+ "description": "OHLCV bars from 1-second through monthly. The feed underneath the table.",
853
827
  "inputSchema": {
854
828
  "type": "object",
855
829
  "properties": {
856
- "id": {
830
+ "ticker": {
831
+ "type": "string",
832
+ "description": "Ticker symbol, or a comma-separated list (up to 50) for a bulk response keyed by symbol."
833
+ },
834
+ "interval": {
835
+ "type": "string",
836
+ "description": "Bar interval. `2h`/`4h` roll up hourly bars; `1w`/`1mo` roll up daily bars into calendar weeks (Monday start) and months — the bar's `t` is the bucket start (UTC), and with `asof` the last bucket is the week/month to date.",
837
+ "enum": [
838
+ "1s",
839
+ "1m",
840
+ "5m",
841
+ "15m",
842
+ "30m",
843
+ "1h",
844
+ "2h",
845
+ "4h",
846
+ "1d",
847
+ "1w",
848
+ "1mo"
849
+ ]
850
+ },
851
+ "from": {
852
+ "type": "string",
853
+ "description": "Window start (inclusive): `YYYY-MM-DD`, ISO timestamp, or epoch-ms. Combines with `to` for an explicit window; page within it using `cursor`. Mutually exclusive with `asof` and `before` (400)."
854
+ },
855
+ "to": {
856
+ "type": "string",
857
+ "description": "Window end (inclusive): a bare `YYYY-MM-DD` means through the end of that day, same as series. Mutually exclusive with `asof` and `before` (400)."
858
+ },
859
+ "asof": {
860
+ "type": "string",
861
+ "description": "Point-in-time: the most recent bar whose period had closed at or before that moment. A bare `YYYY-MM-DD` means that day's close. A full timestamp means the last FINISHED bar — at 10:00 ET on a Wednesday the day's close has not happened, so `1d` returns Tuesday's bar. Returns one bar unless you also pass `limit`, which gives the last `limit` closed bars. Mutually exclusive with `before`/`cursor` (400). Unlimited depth."
862
+ },
863
+ "adjusted": {
864
+ "type": "boolean",
865
+ "description": "Default `true`: prices are split-adjusted — restated after each later split, as the tape is, so a series is continuous across a split. `false` returns the price as it printed that day (a name that later did a 1:10 reverse split reads `21.4` adjusted and `2.14` on the tape), which is what a broker fill or a chart from that time shows. Volume scales the other way. Un-adjusted on read from the splits table; the store is untouched."
866
+ },
867
+ "session": {
868
+ "type": "string",
869
+ "description": "Sub-hour intervals only. `all` (default) includes pre- and post-market bars. `regular` keeps bars whose start is in 09:30–16:00 ET (DST-aware). Why you might want it: the vendor buckets trades by SIP report time, and late-reported off-exchange (Form T) prints on thin names can land 20 min to hours late in a pre-market minute — a `$1.70` print at 08:13 ET on a `$3.85` stock. Daily high/low are untouched by those. `limit` counts after the filter; paging still works.",
870
+ "enum": [
871
+ "all",
872
+ "regular"
873
+ ],
874
+ "default": "all"
875
+ },
876
+ "limit": {
877
+ "type": "integer",
878
+ "description": "Most-recent N bars. Max 1000 — an over-cap value is clamped, not an error.",
879
+ "default": 100
880
+ },
881
+ "before": {
857
882
  "type": "string",
858
- "description": "Universe slug."
883
+ "description": "Return the N bars ending strictly before this date/timestamp — back-paging. Mutually exclusive with `cursor` (they are the same control — a 400 when both are sent)."
884
+ },
885
+ "cursor": {
886
+ "type": "string",
887
+ "description": "Continuation token from a prior response's `next_cursor`; sugar for `before` (sending both is a 400; a blank `cursor=` counts as absent), and the way to page inside a `from`/`to` window."
859
888
  }
860
889
  },
861
890
  "required": [
862
- "id"
891
+ "ticker",
892
+ "interval"
863
893
  ]
864
894
  },
865
895
  "endpoint": {
866
- "method": "DELETE",
867
- "path": "/v2/universes/{id}",
896
+ "method": "GET",
897
+ "path": "/v2/bars/{ticker}/{interval}",
868
898
  "paramLocation": {
869
- "id": "path"
899
+ "ticker": "path",
900
+ "interval": "path",
901
+ "from": "query",
902
+ "to": "query",
903
+ "asof": "query",
904
+ "adjusted": "query",
905
+ "session": "query",
906
+ "limit": "query",
907
+ "before": "query",
908
+ "cursor": "query"
870
909
  }
871
910
  }
872
911
  },
873
912
  {
874
913
  "name": "tickerbot_list_events",
875
- "description": "THE events primitive — one timeline over every event log, cross-ticker: dividends, splits, insider transactions, and analyst actions (\"all splits this month\", \"every analyst action in my universe this week\", \"AAPL's full corporate history\" via `ticker`), plus two opt-in kinds that join only when named: `signal` (boolean-flag firings) and `news`. Rows are { ticker, ts, kind, payload }, newest first. Analyst payloads (history to 2012) carry firm/analyst/action/rating/price_target; filter them with `q` (payload->>'firm' = 'Goldman Sachs' AND payload->>'action' = 'downgrades') — actions: upgrades, downgrades, initiates_coverage_on, maintains, reiterates, assumes, reinstates, suspends, terminates_coverage_on. Speaks the SQL grammar over exactly (ticker, ts, kind, payload jsonb); `group_by`/`select`/`having` roll the stream up (aggregates return `truncated: true` instead of paginating); `join=state` attaches the ticker's state as of each event. Requires at least one bound: a ticker scope (ticker/tickers/universe) or a time window (from/to) — q alone is not a bound.",
914
+ "description": "One timeline across every ticker: earnings, dividends, splits, insider filings, analyst actions, plus opt-in signal firings and news. Requires at least one bound: a ticker scope (ticker/tickers/universe), a time window (from/to), or firm/action — `q` alone is not a bound. `firm`/`action` match case-insensitively; a `q` payload match is case-sensitive. `join: state` attaches the ticker state as of each event.",
876
915
  "inputSchema": {
877
916
  "type": "object",
878
917
  "properties": {
879
918
  "kind": {
880
919
  "type": "string",
881
- "description": "Comma list of kinds to include. Default is the four corporate kinds: dividend, split, insider, analyst. Two more are opt-in and join only when named: `signal` (boolean-flag firings) and `news`."
882
- },
883
- "signal": {
884
- "type": "string",
885
- "description": "kind=signal only. One built-in boolean flag (e.g. golden_cross). REQUIRED to use `q` or `join` on kind=signal — naming the signal is what keeps the query on an index; optional otherwise."
886
- },
887
- "transition": {
888
- "type": "string",
889
- "description": "kind=signal only. `enter` (false->true) or `exit` (true->false). Always optional — an ordinary filter.",
920
+ "description": "Comma list of kinds. Omitted → the five corporate kinds; `signal` and `news` join only when named here.",
890
921
  "enum": [
891
- "enter",
892
- "exit"
922
+ "earnings",
923
+ "dividend",
924
+ "split",
925
+ "insider",
926
+ "analyst",
927
+ "news",
928
+ "signal"
893
929
  ]
894
930
  },
895
931
  "ticker": {
896
932
  "type": "string",
897
- "description": "Single-ticker filter, e.g. AAPL."
933
+ "description": "Single-ticker filter. When both `ticker` and `tickers` are passed, `ticker` wins."
898
934
  },
899
935
  "tickers": {
900
936
  "type": "string",
901
- "description": "Comma-separated tickers, max 50. Mutually exclusive with `universe`."
937
+ "description": "Comma list of tickers (max 50). Mutually exclusive with `universe`."
902
938
  },
903
939
  "universe": {
904
940
  "type": "string",
905
- "description": "Universe slug (top_10, top_100, or a saved one) to scope the stream. Mutually exclusive with `tickers`."
941
+ "description": "Universe slug (`top_10`, `top_100`, or one of yours) to scope the stream. Mutually exclusive with `tickers`."
942
+ },
943
+ "firm": {
944
+ "type": "string",
945
+ "description": "Analyst-only structured filter — requires `kind=analyst` alone (`400` otherwise). Exact firm-name match on the ratings feed."
946
+ },
947
+ "action": {
948
+ "type": "string",
949
+ "description": "Analyst-only structured filter — requires `kind=analyst` alone. Same `action` vocabulary as Analyst actions.",
950
+ "enum": [
951
+ "upgrades",
952
+ "downgrades",
953
+ "initiates_coverage_on",
954
+ "maintains",
955
+ "reiterates",
956
+ "assumes",
957
+ "reinstates",
958
+ "suspends",
959
+ "terminates_coverage_on"
960
+ ]
961
+ },
962
+ "signal": {
963
+ "type": "string",
964
+ "description": "Signal-only filter — requires `kind=signal` alone (`400` otherwise). One built-in boolean signal; REQUIRED with `q` or `join=state` on that kind. See Signal firings."
965
+ },
966
+ "transition": {
967
+ "type": "string",
968
+ "description": "Signal-only filter — requires `kind=signal` alone. `enter` (false→true) or `exit` (true→false).",
969
+ "enum": [
970
+ "enter",
971
+ "exit"
972
+ ]
906
973
  },
907
974
  "from": {
908
975
  "type": "string",
909
- "description": "Events at or after this ISO date/datetime (inclusive). `since` accepted as alias."
976
+ "description": "Events at/after this instant — strict ISO: `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM[:SS]Z`. A bare `YYYY-MM-DD` means from the start of that day. (`since` accepted as an alias.)"
910
977
  },
911
978
  "to": {
912
979
  "type": "string",
913
- "description": "Events strictly before this ISO date/datetime (exclusive). `until` accepted as alias."
980
+ "description": "Window end — same strict ISO subset. A bare `YYYY-MM-DD` means through the end of that day, matching bars/series/spans; a timestamp is exclusive (events strictly before it). (`until` accepted as an alias.)"
981
+ },
982
+ "q": {
983
+ "type": "string",
984
+ "description": "SQL WHERE over the projection — `ticker`, `ts`, `kind`, `payload` (plus ticker-state signals when `join=state`). When exactly ONE `kind` is named, that kind's payload fields are additionally first-class typed columns (`amount > 1`, `firm = 'Goldman Sachs'` — see each kind page for its list); multi-kind requests use `payload->>'…'`. Max 4000 chars. ANDs with the filter params."
914
985
  },
915
986
  "join": {
916
987
  "type": "string",
917
- "description": "join=state attaches each event's ticker STATE as of that event's moment (the replay join) under a `state` key — \"downgrades where rsi_14 was already under 40\" composes with q. Free on every plan.",
988
+ "description": "Set to `state` to allow ticker-state signals in `q`/`select`/`group_by`/`having`, evaluated as of each event's timestamp (daily resolution).",
918
989
  "enum": [
919
990
  "state"
920
991
  ]
921
992
  },
922
993
  "interval": {
923
994
  "type": "string",
924
- "description": "Grain for join=state replay (finest covering tier by default).",
995
+ "description": "Grain the per-event state is reconstructed at, when `join=state`: `1m`, `1h`, `1d`, or `auto` (default). `auto` resolves to `1d` — the event set's tickers are not known before the query runs, and `1d` is the only tier covering the whole universe, so it is the only grain guaranteed to satisfy every event. An explicit `1m`/`1h` trades coverage for precision: events on tickers absent from that tier join to `null`. A referenced column the grain does not store is a `400`. Reported back as `_meta.state_interval`.",
925
996
  "enum": [
926
997
  "1m",
927
998
  "1h",
928
- "1d"
929
- ]
930
- },
931
- "q": {
932
- "type": "string",
933
- "description": "SQL WHERE over (ticker, ts, kind, payload jsonb) — ONLY those four identifiers. Payload fields via jsonb operators: payload->>'firm' = 'Goldman Sachs', (payload->>'shares')::numeric > 1e6. On kind=signal this requires `signal` (the firing log is ~175M rows); kind=signal takes no group_by."
999
+ "1d",
1000
+ "auto"
1001
+ ],
1002
+ "default": "auto"
934
1003
  },
935
1004
  "select": {
936
1005
  "type": "string",
937
- "description": "Aggregate-mode output columns (requires group_by). Default: group keys + COUNT(*) AS events."
1006
+ "description": "Aggregate-mode output columns (requires `group_by`). Default: group keys + `COUNT(*) AS events`. Same naming rule as `group_by` — alias with `AS`, or take the name derived for you."
938
1007
  },
939
1008
  "group_by": {
940
1009
  "type": "string",
941
- "description": "Comma list of rollup keys — switches to aggregate rows, e.g. payload->>'firm' AS firm, or kind."
1010
+ "description": "Comma list of rollup keys — switches the response to aggregate rows. Columns (`kind`, `ticker`), payload fields (`firm`, or the explicit `payload->>'firm'`), and expressions over them all roll up. Name a key with `AS` to choose its JSON key: `payload->>'firm' AS firm`. Un-named keys are named for you — a payload read takes its key (`payload->>'firm'` → `firm`), a function keeps the function's name (`lower(ticker)` → `lower`), and anything else falls back to `group_1`, `group_2`."
942
1011
  },
943
1012
  "having": {
944
1013
  "type": "string",
945
- "description": "Post-aggregation filter (requires group_by), e.g. COUNT(*) > 5."
1014
+ "description": "Post-aggregation filter. Requires `group_by`."
946
1015
  },
947
1016
  "order": {
948
1017
  "type": "string",
949
- "description": "Aggregate-mode sort column/alias. Default: events."
1018
+ "description": "Aggregate-mode sort — a bare column name or an output name only (put expressions in `select` and sort by their alias). A group key's name works too, whether you aliased it or it was named for you: `group_by=payload->>'firm' AS firm&order=firm`. Default: `events`. (Row mode is always newest-first.)"
950
1019
  },
951
1020
  "dir": {
952
1021
  "type": "string",
@@ -954,15 +1023,17 @@ export const tools = [
954
1023
  "enum": [
955
1024
  "asc",
956
1025
  "desc"
957
- ]
1026
+ ],
1027
+ "default": "desc"
958
1028
  },
959
1029
  "limit": {
960
1030
  "type": "integer",
961
- "description": "Page size. Max 1000."
1031
+ "description": "Page size (row modes) / max rollup rows (aggregate mode). Max 1000.",
1032
+ "default": 50
962
1033
  },
963
1034
  "cursor": {
964
1035
  "type": "string",
965
- "description": "Opaque cursor from a prior response — carries the original filters, pass it alone (long q values must be resent alongside it)."
1036
+ "description": "Opaque cursor from the previous response — carries the original filters (and `q` when short), so pass it alone. Not valid with `group_by`."
966
1037
  }
967
1038
  }
968
1039
  },
@@ -974,56 +1045,69 @@ export const tools = [
974
1045
  "ticker": "query",
975
1046
  "tickers": "query",
976
1047
  "universe": "query",
1048
+ "firm": "query",
1049
+ "action": "query",
1050
+ "signal": "query",
1051
+ "transition": "query",
977
1052
  "from": "query",
978
1053
  "to": "query",
1054
+ "q": "query",
979
1055
  "join": "query",
980
1056
  "interval": "query",
981
- "limit": "query",
982
- "cursor": "query",
983
- "q": "query",
984
1057
  "select": "query",
985
1058
  "group_by": "query",
986
1059
  "having": "query",
987
1060
  "order": "query",
988
1061
  "dir": "query",
989
- "signal": "query",
990
- "transition": "query"
1062
+ "limit": "query",
1063
+ "cursor": "query"
991
1064
  }
992
1065
  }
993
1066
  },
994
1067
  {
995
1068
  "name": "tickerbot_subscribe_events",
996
- "description": "Create an event-trigger webhook: fires when NEW events land — dividends, splits, insider filings, analyst actions (\"every split in my universe\", \"Goldman downgrades on large caps\"). TWO composable filters: `q` filters the event's TICKER STATE (market_cap > 1e10); `event_q` filters the EVENT CONTENT in the /v2/events grammar (payload->>'firm' = 'Goldman Sachs'). Paid plans (webhook slots). Deliveries carry event: \"events.fired\" with an events array. Latency = ingest cadence: analyst ≤1h, corporate kinds daily — NOT sub-minute like state webhooks.",
1069
+ "description": "Push new events: we POST your endpoint when events of the kinds you chose land in the archives. Webhooks need a paid plan (Free has no webhook slots). `q` filters the ticker STATE; `event_q` filters the EVENT payload in the /v2/events grammar. Latency is the ingest cadence (analyst ≤1h, corporate kinds daily), not sub-minute.",
997
1070
  "inputSchema": {
998
1071
  "type": "object",
999
1072
  "properties": {
1000
1073
  "kinds": {
1001
1074
  "type": "string",
1002
- "description": "Comma list of kinds to fire on — any of: dividend, split, insider, analyst (e.g. \"split,analyst\"). NOTE: no enum here on purpose — a scalar enum would reject multi-kind values."
1075
+ "description": "Event kinds to fire on — array or comma list.",
1076
+ "enum": [
1077
+ "earnings",
1078
+ "dividend",
1079
+ "split",
1080
+ "insider",
1081
+ "analyst"
1082
+ ]
1003
1083
  },
1004
1084
  "tickers": {
1005
1085
  "type": "string",
1006
- "description": "Scope to specific tickers (comma list, max 50). Mutually exclusive with universe; omit both for all tickers."
1086
+ "description": "Scope to specific tickers (max 50). Mutually exclusive with `universe` — and with the singular alias `ticker` (sending both is a 400). Omit both for all tickers."
1087
+ },
1088
+ "ticker": {
1089
+ "type": "string",
1090
+ "description": "Single-symbol shorthand for `tickers`."
1007
1091
  },
1008
1092
  "universe": {
1009
1093
  "type": "string",
1010
- "description": "Scope to a universe slug (top_10, top_100, or a saved one)."
1094
+ "description": "Scope to a universe slug (`top_10`, `top_100`, or one of yours). `universe_id` accepted as an alias."
1011
1095
  },
1012
1096
  "q": {
1013
1097
  "type": "string",
1014
- "description": "Optional row-STATE filter evaluated against the event's ticker at fire time, e.g. market_cap > 1e10."
1098
+ "description": "Optional row-STATE filter evaluated against the event's ticker at fire time. Same grammar as scan `q`; custom signals are expanded and frozen at creation."
1015
1099
  },
1016
1100
  "event_q": {
1017
1101
  "type": "string",
1018
- "description": "Optional event-CONTENT filter over (ticker, ts, kind, payload jsonb) — only those four identifiers, e.g. payload->>'firm' = 'Goldman Sachs' AND payload->>'action' = 'downgrades'."
1102
+ "description": "Optional event-CONTENT filter in the `/v2/events` grammar — only `ticker`, `ts`, `kind`, `payload` may appear. Composes with `q`."
1019
1103
  },
1020
1104
  "target_url": {
1021
1105
  "type": "string",
1022
- "description": "HTTPS delivery URL. Omit for in-app delivery."
1106
+ "description": "HTTPS delivery URL; or use `channel` + `discord_url`/`device_id`. Omit for in-app."
1023
1107
  },
1024
1108
  "channel": {
1025
1109
  "type": "string",
1026
- "description": "Delivery channel.",
1110
+ "description": "Delivery channel. `slack` is reserved and returns `501`.",
1027
1111
  "enum": [
1028
1112
  "webhook",
1029
1113
  "discord",
@@ -1033,24 +1117,15 @@ export const tools = [
1033
1117
  },
1034
1118
  "discord_url": {
1035
1119
  "type": "string",
1036
- "description": "Discord incoming-webhook URL (channel discord)."
1120
+ "description": "Discord incoming-webhook URL. Required when `channel` is `discord`. Stored as a posting credential: the create response echoes it back under `channel_config`, but every later read (list, get, deliveries) strips it and sets `channel_config_present: true` instead."
1037
1121
  },
1038
1122
  "device_id": {
1039
1123
  "type": "string",
1040
- "description": "Registered device id from the mobile app (channel mobile_push)."
1041
- },
1042
- "cadence": {
1043
- "type": "string",
1044
- "description": "Evaluation cadence. Default realtime; hourly/nyse_open throttle. (`1m` accepted as a deprecated alias of realtime.)",
1045
- "enum": [
1046
- "realtime",
1047
- "hourly",
1048
- "nyse_open"
1049
- ]
1124
+ "description": "Device to notify, from `POST /v2/devices/register`. Required when `channel` is `mobile_push`; unknown ids are a 404 `device_not_found`."
1050
1125
  },
1051
1126
  "name": {
1052
1127
  "type": "string",
1053
- "description": "Display name."
1128
+ "description": "Display name. Defaults to `events: <kinds> · <scope>`."
1054
1129
  }
1055
1130
  },
1056
1131
  "required": [
@@ -1063,6 +1138,7 @@ export const tools = [
1063
1138
  "paramLocation": {
1064
1139
  "kinds": "body",
1065
1140
  "tickers": "body",
1141
+ "ticker": "body",
1066
1142
  "universe": "body",
1067
1143
  "q": "body",
1068
1144
  "event_q": "body",
@@ -1070,36 +1146,47 @@ export const tools = [
1070
1146
  "channel": "body",
1071
1147
  "discord_url": "body",
1072
1148
  "device_id": "body",
1073
- "cadence": "body",
1074
1149
  "name": "body"
1075
1150
  }
1076
1151
  }
1077
1152
  },
1078
1153
  {
1079
1154
  "name": "tickerbot_search_news",
1080
- "description": "Search the news archive (back to 2015) with a SQL WHERE clause. Available on every plan. Columns on news_article include `time_published`, `title`, `summary`, `source`, `source_domain`, `category`, `authors`, `topics`, `tickers` (array), `overall_sentiment_score`, `overall_sentiment_label`, `url`. To filter to one ticker use `'NVDA' = ANY(tickers)` or the auto-unnest alias `tk = 'NVDA'`. Example: `q=tk='NVDA' AND time_published >= NOW() - INTERVAL '1 day'`. Supports group_by + having for aggregation (e.g. count of articles per day).",
1155
+ "description": "SQL query over the news archive. Article rows, or rollups when you group them. Filter to a ticker with the `ticker` param, or in `q` via the auto-unnest alias `tk = 'NVDA'`. `search` is full-text over title and summary. Rollups with group_by/having return `truncated: true` instead of paging.",
1081
1156
  "inputSchema": {
1082
1157
  "type": "object",
1083
1158
  "properties": {
1084
- "q": {
1159
+ "ticker": {
1085
1160
  "type": "string",
1086
- "description": "SQL WHERE on news_article. Required."
1161
+ "description": "Articles mentioning this symbol (ANDed with `q`)."
1087
1162
  },
1088
- "select": {
1163
+ "tickers": {
1089
1164
  "type": "string",
1090
- "description": "Comma-separated columns to include. Defaults to a slim set."
1165
+ "description": "Comma list, up to 50 — articles mentioning ANY of them. Not combinable with `ticker` or `universe`."
1091
1166
  },
1092
- "group_by": {
1167
+ "universe": {
1093
1168
  "type": "string",
1094
- "description": "Comma-separated columns for aggregation."
1169
+ "description": "Universe slug — articles mentioning any member. Not combinable with `ticker`/`tickers`."
1095
1170
  },
1096
- "having": {
1171
+ "from": {
1172
+ "type": "string",
1173
+ "description": "Earliest `time_published` (inclusive) — strict ISO: `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM[:SS]Z`. (`since` accepted as an alias.)"
1174
+ },
1175
+ "to": {
1176
+ "type": "string",
1177
+ "description": "Articles strictly before this instant — same strict ISO subset, matching `/v2/events`. (`until` accepted as an alias.)"
1178
+ },
1179
+ "search": {
1180
+ "type": "string",
1181
+ "description": "Full-text search over `title` + `summary` — websearch grammar: `apple earnings` (all words), `\"price target\"` (phrase), `chips OR semiconductors`, `-crypto` (negation). Max 200 chars. ANDs with `q` and the scoping params. Language-stemmed English."
1182
+ },
1183
+ "q": {
1097
1184
  "type": "string",
1098
- "description": "WHERE-style filter on aggregates. Requires group_by."
1185
+ "description": "WHERE clause over the news_article table. Max 4000 chars. Required UNLESS `search` or a scoping param (`ticker`/`tickers`/`universe`/`from`/`to`) is present — the simplest call needs no SQL. Queryable columns: `time_published`, `title`, `summary`, `source`, `source_domain`, `category`, `authors`, `topics`, `overall_sentiment_score`, `overall_sentiment_label`, `tickers`, `ticker_data`, `banner_image`, `url`, `id`, `created_at` — plus `tk`, the per-ticker UNNEST alias. Signal/state columns are not joinable here."
1099
1186
  },
1100
1187
  "order": {
1101
1188
  "type": "string",
1102
- "description": "Sort column or SELECT alias. Default time_published (non-aggregate) or volume (aggregate)."
1189
+ "description": "Sort — a bare column name or SELECT alias only (put expressions in `select` and order by their alias). Defaults to `time_published` (article rows) or `volume` (aggregate rows)."
1103
1190
  },
1104
1191
  "dir": {
1105
1192
  "type": "string",
@@ -1107,275 +1194,392 @@ export const tools = [
1107
1194
  "enum": [
1108
1195
  "asc",
1109
1196
  "desc"
1110
- ]
1197
+ ],
1198
+ "default": "desc"
1111
1199
  },
1112
1200
  "limit": {
1113
1201
  "type": "integer",
1114
- "description": "Page size."
1202
+ "description": "Page size. Max 1000.",
1203
+ "default": 50
1115
1204
  },
1116
1205
  "cursor": {
1117
1206
  "type": "string",
1118
- "description": "Opaque cursor."
1207
+ "description": "Opaque pagination cursor from a prior response's `next_cursor`."
1208
+ },
1209
+ "group_by": {
1210
+ "type": "string",
1211
+ "description": "AGGREGATE MODE: comma-separated group keys, 1-6 (max 1000 chars). Switches the response to rollup rows. Use `tk` to roll up per ticker without writing the UNNEST. Name a key with `AS` to choose its JSON key; an un-named expression is named for you rather than returned as `?column?`."
1212
+ },
1213
+ "select": {
1214
+ "type": "string",
1215
+ "description": "Columns/expressions to return (max 2000 chars). Defaults to article columns (no `group_by`) or `<group_by cols>, COUNT(*) AS volume` (with `group_by`)."
1216
+ },
1217
+ "having": {
1218
+ "type": "string",
1219
+ "description": "HAVING clause on the aggregate (max 1000 chars). Requires `group_by`."
1119
1220
  }
1120
- },
1121
- "required": [
1122
- "q"
1123
- ]
1221
+ }
1124
1222
  },
1125
1223
  "endpoint": {
1126
1224
  "method": "GET",
1127
- "path": "/v2/news/scan",
1225
+ "path": "/v2/news",
1128
1226
  "paramLocation": {
1227
+ "ticker": "query",
1228
+ "tickers": "query",
1229
+ "universe": "query",
1230
+ "from": "query",
1231
+ "to": "query",
1232
+ "search": "query",
1129
1233
  "q": "query",
1130
- "select": "query",
1131
- "group_by": "query",
1132
- "having": "query",
1133
1234
  "order": "query",
1134
1235
  "dir": "query",
1135
1236
  "limit": "query",
1136
- "cursor": "query"
1237
+ "cursor": "query",
1238
+ "group_by": "query",
1239
+ "select": "query",
1240
+ "having": "query"
1137
1241
  }
1138
1242
  }
1139
1243
  },
1140
1244
  {
1141
- "name": "tickerbot_subscribe_ticker",
1142
- "description": "Register a webhook that fires when one ticker matches a condition. `condition` is a SQL WHERE-clause fragment scoped to that ticker (e.g. \"rsi_14 > 70 AND relative_volume > 2\"). Pass `target_url` for an https POST, or `channel:\"discord\"` + `discord_url` to post to Discord; omit for in-app.",
1245
+ "name": "tickerbot_get_etf_holdings",
1246
+ "description": "Returns an ETF's constituents and their weights, heaviest first. When the ticker is not an ETF, `is_etf` is false and `holdings` is empty; `is_etf: true` with zero holdings means a real ETF whose holdings are not ingested yet. The reverse lookup (\"which ETFs hold NVDA\") is a scan filter on `etf_holders`, not this tool.",
1143
1247
  "inputSchema": {
1144
1248
  "type": "object",
1145
1249
  "properties": {
1146
1250
  "ticker": {
1147
1251
  "type": "string",
1148
- "description": "Symbol."
1252
+ "description": "ETF symbol. Case-insensitive."
1149
1253
  },
1150
- "condition": {
1254
+ "limit": {
1255
+ "type": "integer",
1256
+ "description": "Max holdings returned. Max 5000. When the cap cuts the list, the response sets `truncated: true` and `total` (the ETF's full holding count) — raise `limit` to at least `total` to get the full set, possible whenever `total` is within the 5000 cap (an over-cap `limit` is clamped to 5000, not an error). No `truncated` in the response means the list is complete.",
1257
+ "default": 500
1258
+ }
1259
+ },
1260
+ "required": [
1261
+ "ticker"
1262
+ ]
1263
+ },
1264
+ "endpoint": {
1265
+ "method": "GET",
1266
+ "path": "/v2/etf/{ticker}/holdings",
1267
+ "paramLocation": {
1268
+ "ticker": "path",
1269
+ "limit": "query"
1270
+ }
1271
+ }
1272
+ },
1273
+ {
1274
+ "name": "tickerbot_get_etf_sectors",
1275
+ "description": "Returns an ETF's sector weights, heaviest first. Always complete, since sector breakdowns are small. When the ticker is not an ETF, `is_etf` is false and `sectors` is empty.",
1276
+ "inputSchema": {
1277
+ "type": "object",
1278
+ "properties": {
1279
+ "ticker": {
1151
1280
  "type": "string",
1152
- "description": "SQL WHERE fragment evaluated for this ticker."
1153
- },
1154
- "name": {
1281
+ "description": "ETF symbol. Case-insensitive."
1282
+ }
1283
+ },
1284
+ "required": [
1285
+ "ticker"
1286
+ ]
1287
+ },
1288
+ "endpoint": {
1289
+ "method": "GET",
1290
+ "path": "/v2/etf/{ticker}/sectors",
1291
+ "paramLocation": {
1292
+ "ticker": "path"
1293
+ }
1294
+ }
1295
+ },
1296
+ {
1297
+ "name": "tickerbot_list_universes",
1298
+ "description": "Every universe you can reference: your own named ticker lists and the built-in ones. `owner: system` lists the built-in universes (top_10, top_100); `all` lists both. Use a slug as `universe` on scan, signal, and subscribe tools.",
1299
+ "inputSchema": {
1300
+ "type": "object",
1301
+ "properties": {
1302
+ "owner": {
1155
1303
  "type": "string",
1156
- "description": "Human-readable label."
1304
+ "description": "Which universes to list: `me` (your own), `system` (built-ins), or `all` (both).",
1305
+ "enum": [
1306
+ "me",
1307
+ "system",
1308
+ "all"
1309
+ ],
1310
+ "default": "me"
1157
1311
  },
1158
- "target_url": {
1159
- "type": "string",
1160
- "description": "Optional https URL for the `webhook` channel; omit for in-app."
1312
+ "limit": {
1313
+ "type": "integer",
1314
+ "description": "Page size (applies to your own). Max 100.",
1315
+ "default": 50
1161
1316
  },
1162
- "channel": {
1317
+ "cursor": {
1163
1318
  "type": "string",
1164
- "description": "Delivery channel: `webhook` (POST to target_url), `discord` (embed to discord_url), `mobile_push` (to a registered device), or `in_app` (dashboard only). Inferred from the URL you pass if omitted.",
1165
- "enum": [
1166
- "webhook",
1167
- "discord",
1168
- "in_app",
1169
- "mobile_push"
1170
- ]
1171
- },
1172
- "discord_url": {
1319
+ "description": "Opaque cursor from the previous response."
1320
+ }
1321
+ }
1322
+ },
1323
+ "endpoint": {
1324
+ "method": "GET",
1325
+ "path": "/v2/universes",
1326
+ "paramLocation": {
1327
+ "owner": "query",
1328
+ "limit": "query",
1329
+ "cursor": "query"
1330
+ }
1331
+ }
1332
+ },
1333
+ {
1334
+ "name": "tickerbot_create_universe",
1335
+ "description": "Create a named ticker list owned by your account.",
1336
+ "inputSchema": {
1337
+ "type": "object",
1338
+ "properties": {
1339
+ "name": {
1173
1340
  "type": "string",
1174
- "description": "Discord incoming-webhook URL (https://discord.com/api/webhooks/…). Required when channel is \"discord\"."
1341
+ "description": "Human-readable label, up to 80 characters. Display-only — never used to reference the universe."
1175
1342
  },
1176
- "device_id": {
1343
+ "tickers": {
1344
+ "type": "array",
1345
+ "items": {
1346
+ "type": "string"
1347
+ },
1348
+ "description": "Ticker symbols, up to 10,000. Validated against the active universe. `[]` is accepted — a shell universe you can fill later via PATCH."
1349
+ },
1350
+ "id": {
1177
1351
  "type": "string",
1178
- "description": "Registered device id from the mobile app. Required when channel is \"mobile_push\"."
1352
+ "description": "Optional slug — becomes the universe's permanent handle everywhere (`?universe=`, subscribe `universe`, CRUD path). Pattern `^[a-z][a-z0-9_]{0,62}$` — starts with a lowercase letter, then lowercase letters/digits/underscore, 63 chars max; the value is trimmed and lowercased before validation. `top_10` and `top_100` are reserved for system universes and rejected with 400. Must be unique within your account. Generated (`u_…`) if omitted."
1179
1353
  },
1180
- "cadence": {
1354
+ "description": {
1181
1355
  "type": "string",
1182
- "description": "Evaluation cadence. Default realtime; hourly/nyse_open throttle. (`1m` accepted as a deprecated alias of realtime.)",
1183
- "enum": [
1184
- "realtime",
1185
- "hourly",
1186
- "nyse_open"
1187
- ]
1356
+ "description": "Free-form notes, up to 500 characters. Stored as `\"\"` when omitted."
1188
1357
  }
1189
1358
  },
1190
1359
  "required": [
1191
- "ticker",
1192
- "condition"
1360
+ "name",
1361
+ "tickers"
1193
1362
  ]
1194
1363
  },
1195
1364
  "endpoint": {
1196
1365
  "method": "POST",
1197
- "path": "/v2/tickers/{ticker}/subscribe",
1366
+ "path": "/v2/universes",
1198
1367
  "paramLocation": {
1199
- "ticker": "path",
1200
- "condition": "body",
1201
1368
  "name": "body",
1202
- "target_url": "body",
1203
- "channel": "body",
1204
- "discord_url": "body",
1205
- "device_id": "body",
1206
- "cadence": "body"
1369
+ "tickers": "body",
1370
+ "id": "body",
1371
+ "description": "body"
1372
+ }
1373
+ }
1374
+ },
1375
+ {
1376
+ "name": "tickerbot_get_universe",
1377
+ "description": "Returns the universe doc. Use `top_10`/`top_100` to fetch a system universe; any other slug must be one your account owns.",
1378
+ "inputSchema": {
1379
+ "type": "object",
1380
+ "properties": {
1381
+ "id": {
1382
+ "type": "string",
1383
+ "description": "Universe slug."
1384
+ }
1385
+ },
1386
+ "required": [
1387
+ "id"
1388
+ ]
1389
+ },
1390
+ "endpoint": {
1391
+ "method": "GET",
1392
+ "path": "/v2/universes/{id}",
1393
+ "paramLocation": {
1394
+ "id": "path"
1207
1395
  }
1208
1396
  }
1209
1397
  },
1210
1398
  {
1211
- "name": "tickerbot_subscribe_signal",
1212
- "description": "Register a webhook that fires when a signal turns true (booleans) or its value crosses a condition (numerics). Optional `ticker` restricts to one symbol; omit to watch the whole universe. Pass `target_url` for an https POST, or `channel:\"discord\"` + `discord_url` to post to Discord; omit for in-app.",
1399
+ "name": "tickerbot_update_universe",
1400
+ "description": "Update one of your universes: its name, description, or members. `tickers` replaces the whole list; `add`/`remove` adjust it. System universes cannot be edited.",
1213
1401
  "inputSchema": {
1214
1402
  "type": "object",
1215
1403
  "properties": {
1216
- "signal": {
1217
- "type": "string",
1218
- "description": "Column name (e.g. golden_cross_today, rsi_14)."
1219
- },
1220
- "ticker": {
1221
- "type": "string",
1222
- "description": "Optional ticker to restrict the watch to one symbol."
1223
- },
1224
- "universe": {
1225
- "type": "string",
1226
- "description": "Optional universe slug."
1227
- },
1228
- "condition": {
1404
+ "id": {
1229
1405
  "type": "string",
1230
- "description": "Required for numerics: single bound like \">70\" or \"<=200\". Ignored for booleans."
1406
+ "description": "Universe slug."
1231
1407
  },
1232
1408
  "name": {
1233
1409
  "type": "string",
1234
- "description": "Human-readable label."
1410
+ "description": "New label. Non-empty, max 80 characters."
1235
1411
  },
1236
- "target_url": {
1237
- "type": "string",
1238
- "description": "Optional https URL for the `webhook` channel; omit for in-app."
1239
- },
1240
- "channel": {
1412
+ "description": {
1241
1413
  "type": "string",
1242
- "description": "Delivery channel: `webhook` (POST to target_url), `discord` (embed to discord_url), `mobile_push` (to a registered device), or `in_app` (dashboard only). Inferred from the URL you pass if omitted.",
1243
- "enum": [
1244
- "webhook",
1245
- "discord",
1246
- "in_app",
1247
- "mobile_push"
1248
- ]
1414
+ "description": "New notes. Max 500 characters."
1249
1415
  },
1250
- "discord_url": {
1251
- "type": "string",
1252
- "description": "Discord incoming-webhook URL (https://discord.com/api/webhooks/…). Required when channel is \"discord\"."
1416
+ "tickers": {
1417
+ "type": "array",
1418
+ "items": {
1419
+ "type": "string"
1420
+ },
1421
+ "description": "Replace the full ticker list (up to 10,000; validated against the active universe). Does not combine with `add`/`remove` (400)."
1253
1422
  },
1254
- "device_id": {
1255
- "type": "string",
1256
- "description": "Registered device id from the mobile app. Required when channel is \"mobile_push\"."
1423
+ "add": {
1424
+ "type": "array",
1425
+ "items": {
1426
+ "type": "string"
1427
+ },
1428
+ "description": "Add these tickers (deduplicated)."
1257
1429
  },
1258
- "cadence": {
1259
- "type": "string",
1260
- "description": "Evaluation cadence. Default realtime; hourly/nyse_open throttle. (`1m` accepted as a deprecated alias of realtime.)",
1261
- "enum": [
1262
- "realtime",
1263
- "hourly",
1264
- "nyse_open"
1265
- ]
1430
+ "remove": {
1431
+ "type": "array",
1432
+ "items": {
1433
+ "type": "string"
1434
+ },
1435
+ "description": "Remove these tickers."
1266
1436
  }
1267
1437
  },
1268
1438
  "required": [
1269
- "signal"
1439
+ "id"
1270
1440
  ]
1271
1441
  },
1272
1442
  "endpoint": {
1273
- "method": "POST",
1274
- "path": "/v2/signals/{signal}/subscribe",
1443
+ "method": "PATCH",
1444
+ "path": "/v2/universes/{id}",
1275
1445
  "paramLocation": {
1276
- "signal": "path",
1277
- "ticker": "body",
1278
- "universe": "body",
1279
- "condition": "body",
1446
+ "id": "path",
1280
1447
  "name": "body",
1281
- "target_url": "body",
1282
- "channel": "body",
1283
- "discord_url": "body",
1284
- "device_id": "body",
1285
- "cadence": "body"
1448
+ "description": "body",
1449
+ "tickers": "body",
1450
+ "add": "body",
1451
+ "remove": "body"
1286
1452
  }
1287
1453
  }
1288
1454
  },
1289
1455
  {
1290
- "name": "tickerbot_subscribe_scan",
1291
- "description": "Register a webhook that fires when matches for a scan query change. Pass `target_url` for an https POST, or `channel:\"discord\"` + `discord_url` to post an embed to Discord; omit for in-app delivery in the dashboard. `cadence` is real-time (1m) by default; throttle to hourly or nyse_open. Use to satisfy \"alert me when this happens\" prompts.",
1456
+ "name": "tickerbot_delete_universe",
1457
+ "description": "Permanently delete one of your universes. Webhooks that reference the deleted universe fail on their next eval — clean those up first.",
1292
1458
  "inputSchema": {
1293
1459
  "type": "object",
1294
1460
  "properties": {
1295
- "q": {
1296
- "type": "string",
1297
- "description": "SQL WHERE expression — same grammar as scan."
1298
- },
1299
- "name": {
1300
- "type": "string",
1301
- "description": "Human-readable label. Defaults to a truncated version of the query."
1302
- },
1303
- "universe": {
1304
- "type": "string",
1305
- "description": "Optional universe slug to scope the watch."
1306
- },
1307
- "target_url": {
1461
+ "id": {
1308
1462
  "type": "string",
1309
- "description": "Optional https URL to POST matches to (the `webhook` channel). Omit for in-app delivery."
1463
+ "description": "Universe slug."
1310
1464
  },
1311
- "channel": {
1465
+ "force": {
1466
+ "type": "boolean",
1467
+ "description": "A universe still referenced by live webhooks refuses to delete with `409 universe_referenced`. Pass `force=true` to delete anyway — those webhooks will match nothing until re-pointed or deleted.",
1468
+ "default": false
1469
+ }
1470
+ },
1471
+ "required": [
1472
+ "id"
1473
+ ]
1474
+ },
1475
+ "endpoint": {
1476
+ "method": "DELETE",
1477
+ "path": "/v2/universes/{id}",
1478
+ "paramLocation": {
1479
+ "id": "path",
1480
+ "force": "query"
1481
+ }
1482
+ }
1483
+ },
1484
+ {
1485
+ "name": "tickerbot_list_webhooks",
1486
+ "description": "Every webhook subscription on this account, newest first.",
1487
+ "inputSchema": {
1488
+ "type": "object",
1489
+ "properties": {
1490
+ "status": {
1312
1491
  "type": "string",
1313
- "description": "Delivery channel: `webhook` (POST to target_url), `discord` (embed to discord_url), `mobile_push` (to a registered device), or `in_app` (dashboard only). Inferred from the URL you pass if omitted.",
1492
+ "description": "Filter by status: `active` or `disabled` — the only two states a webhook has (`disabled` covers both a user pause and the automatic disable after repeated delivery failures; `consecutive_failures`/`last_error` on each record say which). Omit for all.",
1314
1493
  "enum": [
1315
- "webhook",
1316
- "discord",
1317
- "in_app",
1318
- "mobile_push"
1494
+ "active",
1495
+ "disabled"
1319
1496
  ]
1320
1497
  },
1321
- "discord_url": {
1322
- "type": "string",
1323
- "description": "Discord incoming-webhook URL (https://discord.com/api/webhooks/…). Required when channel is \"discord\"."
1324
- },
1325
- "device_id": {
1326
- "type": "string",
1327
- "description": "Registered device id from the mobile app. Required when channel is \"mobile_push\"."
1498
+ "limit": {
1499
+ "type": "integer",
1500
+ "description": "Page size. Max 100.",
1501
+ "default": 50
1328
1502
  },
1329
- "cadence": {
1503
+ "cursor": {
1330
1504
  "type": "string",
1331
- "description": "Evaluation cadence. Default realtime; hourly/nyse_open throttle. (`1m` accepted as a deprecated alias of realtime.)",
1332
- "enum": [
1333
- "realtime",
1334
- "hourly",
1335
- "nyse_open"
1336
- ]
1505
+ "description": "Opaque cursor from the previous response."
1337
1506
  }
1338
- },
1339
- "required": [
1340
- "q"
1341
- ]
1507
+ }
1342
1508
  },
1343
1509
  "endpoint": {
1344
- "method": "POST",
1345
- "path": "/v2/scan/subscribe",
1510
+ "method": "GET",
1511
+ "path": "/v2/webhooks",
1346
1512
  "paramLocation": {
1347
- "q": "body",
1348
- "name": "body",
1349
- "universe": "body",
1350
- "target_url": "body",
1351
- "channel": "body",
1352
- "discord_url": "body",
1353
- "device_id": "body",
1354
- "cadence": "body"
1513
+ "status": "query",
1514
+ "limit": "query",
1515
+ "cursor": "query"
1355
1516
  }
1356
1517
  }
1357
1518
  },
1358
1519
  {
1359
1520
  "name": "tickerbot_create_webhook",
1360
- "description": "The canonical webhook create — POST /v2/webhooks with an explicit `trigger` object: { type: \"scan\" | \"ticker\" | \"signal\" | \"event\", … } plus delivery fields. The subscribe tools above are flat sugar over exactly this; use this form when composing the trigger programmatically or when a sugar door doesn't fit. Trigger shapes: scan {type:\"scan\", q, universe?}; ticker {type:\"ticker\", ticker, condition}; signal {type:\"signal\", signal, ticker?, universe?, condition?}; event {type:\"event\", kinds, tickers?, universe?, event_q?}.",
1521
+ "description": "Canonical create: a webhook is a trigger plus a delivery. Trigger shapes: scan {type:\"scan\", q, universe?}; ticker {type:\"ticker\", ticker, condition}; signal {type:\"signal\", signal, ticker?, universe?, condition?}; event {type:\"event\", kinds, tickers?, universe?, event_q?}. The subscribe tools are flat sugar over exactly this. Webhooks need a paid plan (Free has no webhook slots).",
1361
1522
  "inputSchema": {
1362
1523
  "type": "object",
1363
1524
  "properties": {
1364
1525
  "trigger": {
1365
1526
  "type": "object",
1366
- "description": "What fires the webhook: { type: \"scan\" | \"ticker\" | \"signal\" | \"event\", … } — see the tool description for each shape."
1367
- },
1368
- "name": {
1369
- "type": "string",
1370
- "description": "Display name. Defaults from the trigger."
1527
+ "description": "What fires the webhook. A discriminated object — `trigger.type` picks the shape, and the fields below belong inside it. Each shape is also available as a flat-params shortcut: `POST /v2/scan/subscribe`, `/v2/tickers/{t}/subscribe`, `/v2/signals/{s}/subscribe`, `/v2/events/subscribe`.",
1528
+ "properties": {
1529
+ "type": {
1530
+ "type": "string",
1531
+ "description": "Which trigger shape the rest of the object uses.",
1532
+ "enum": [
1533
+ "scan",
1534
+ "ticker",
1535
+ "signal",
1536
+ "event"
1537
+ ]
1538
+ },
1539
+ "q": {
1540
+ "type": "string",
1541
+ "description": "scan: required — the SQL WHERE any ticker must match to fire. ticker: required — WHERE fragment evaluated for that ticker (auto-scoped; don't add `ticker = …` yourself; `trigger.condition` accepted as an alias). event: optional row-STATE filter on the event's ticker at fire time (`market_cap > 1e10`)."
1542
+ },
1543
+ "signal": {
1544
+ "type": "string",
1545
+ "description": "signal: required — a built-in signal name (e.g. `rsi_14`) or one of your custom signals (custom SQL is expanded and frozen at creation)."
1546
+ },
1547
+ "condition": {
1548
+ "type": "string",
1549
+ "description": "signal: required for numeric signals — a single bound like `>70`; sending one with a boolean or custom signal returns 400 (it does not apply). ticker: accepted as the original alias of `trigger.q`."
1550
+ },
1551
+ "ticker": {
1552
+ "type": "string",
1553
+ "description": "ticker: required — the symbol to watch (e.g. `NVDA`). signal: optional — restrict the signal to one symbol (omit to watch the whole universe)."
1554
+ },
1555
+ "tickers": {
1556
+ "type": "string",
1557
+ "description": "event: optional symbol list, max 50 (e.g. `AAPL,NVDA`). Mutually exclusive with `trigger.universe`."
1558
+ },
1559
+ "kinds": {
1560
+ "type": "string",
1561
+ "description": "event: required — event kinds to fire on, array or comma list (e.g. `split,analyst`)."
1562
+ },
1563
+ "event_q": {
1564
+ "type": "string",
1565
+ "description": "event: optional event-CONTENT filter in the `/v2/events` grammar over `(ticker, ts, kind, payload)` — e.g. `payload->>'firm' = 'Goldman Sachs'`. Composes with `trigger.q`."
1566
+ },
1567
+ "universe": {
1568
+ "type": "string",
1569
+ "description": "scan / signal / event: optional universe slug (`top_10`, `top_100`, or one of yours) scoping which tickers can fire. Mutually exclusive with `trigger.tickers` on event."
1570
+ }
1571
+ },
1572
+ "required": [
1573
+ "type"
1574
+ ]
1371
1575
  },
1372
1576
  "target_url": {
1373
1577
  "type": "string",
1374
- "description": "HTTPS delivery URL (`webhook` channel). Omit for in-app."
1578
+ "description": "HTTPS delivery URL (the `webhook` channel), max 1024 characters. Omit for in-app delivery, or use `channel` + `discord_url`/`device_id` for other channels."
1375
1579
  },
1376
1580
  "channel": {
1377
1581
  "type": "string",
1378
- "description": "Delivery channel. Inferred from the URL you pass if omitted.",
1582
+ "description": "Delivery channel. See Delivery channels.",
1379
1583
  "enum": [
1380
1584
  "webhook",
1381
1585
  "discord",
@@ -1385,24 +1589,43 @@ export const tools = [
1385
1589
  },
1386
1590
  "discord_url": {
1387
1591
  "type": "string",
1388
- "description": "Discord incoming-webhook URL (channel discord)."
1592
+ "description": "Discord webhook URL (channel `discord`)."
1389
1593
  },
1390
1594
  "device_id": {
1391
1595
  "type": "string",
1392
- "description": "Registered device id from the mobile app (channel mobile_push)."
1596
+ "description": "Registered device id (channel `mobile_push`, see /v2/devices)."
1393
1597
  },
1394
1598
  "cadence": {
1395
1599
  "type": "string",
1396
- "description": "Evaluation cadence. Default realtime. (`1m` accepted as a deprecated alias of realtime.)",
1600
+ "description": "Evaluation cadence — a user preference — never gated. Event triggers deliver on ingest — only `realtime` is accepted on them (400 otherwise).",
1397
1601
  "enum": [
1398
1602
  "realtime",
1399
1603
  "hourly",
1400
1604
  "nyse_open"
1401
- ]
1605
+ ],
1606
+ "default": "realtime"
1607
+ },
1608
+ "name": {
1609
+ "type": "string",
1610
+ "description": "Display name, max 80 characters. Defaults to an auto-generated one from the trigger."
1402
1611
  },
1403
1612
  "columns": {
1404
1613
  "type": "string",
1405
- "description": "Comma list of extra columns to include with each delivered match (`fields` accepted as alias)."
1614
+ "description": "Extra columns echoed in fired payloads' match rows (`fields` accepted as an alias). Not accepted on `event` triggers (400) — event deliveries carry the event payload, not state rows."
1615
+ },
1616
+ "order": {
1617
+ "type": "string",
1618
+ "description": "Signal the fired payload's match lists are sorted by before the 100-row cap is applied — so a truncated list is the deterministic top 100, not an arbitrary sample. Same contract as `POST /v2/scan`. Not accepted on `event` triggers (they deliver one event at a time).",
1619
+ "default": "market_cap"
1620
+ },
1621
+ "dir": {
1622
+ "type": "string",
1623
+ "description": "Sort direction for `order`. Not accepted on `event` triggers (400).",
1624
+ "enum": [
1625
+ "asc",
1626
+ "desc"
1627
+ ],
1628
+ "default": "desc"
1406
1629
  }
1407
1630
  },
1408
1631
  "required": [
@@ -1414,33 +1637,58 @@ export const tools = [
1414
1637
  "path": "/v2/webhooks",
1415
1638
  "paramLocation": {
1416
1639
  "trigger": "body",
1417
- "name": "body",
1418
1640
  "target_url": "body",
1419
1641
  "channel": "body",
1420
1642
  "discord_url": "body",
1421
1643
  "device_id": "body",
1422
1644
  "cadence": "body",
1423
- "columns": "body"
1645
+ "name": "body",
1646
+ "columns": "body",
1647
+ "order": "body",
1648
+ "dir": "body"
1649
+ }
1650
+ }
1651
+ },
1652
+ {
1653
+ "name": "tickerbot_get_webhook",
1654
+ "description": "The current state of one webhook subscription.",
1655
+ "inputSchema": {
1656
+ "type": "object",
1657
+ "properties": {
1658
+ "id": {
1659
+ "type": "string",
1660
+ "description": "Webhook id returned by a subscribe endpoint (`POST /v2/tickers/{T}/subscribe`, etc.)."
1661
+ }
1662
+ },
1663
+ "required": [
1664
+ "id"
1665
+ ]
1666
+ },
1667
+ "endpoint": {
1668
+ "method": "GET",
1669
+ "path": "/v2/webhooks/{id}",
1670
+ "paramLocation": {
1671
+ "id": "path"
1424
1672
  }
1425
1673
  }
1426
1674
  },
1427
1675
  {
1428
- "name": "tickerbot_patch_webhook",
1429
- "description": "Edit a webhook in place: `name`, `cadence`, `target_url`, `enabled` (pausing/resuming without losing match-state). The trigger (q) and channel are immutable by design — delete and re-create to change what fires or where it delivers. Unknown fields are a 400, never silently ignored.",
1676
+ "name": "tickerbot_update_webhook",
1677
+ "description": "Edit a webhook in place — send only the fields you want to change. The trigger and channel are immutable — delete and re-create to change what fires or where it delivers. Unknown fields are a 400.",
1430
1678
  "inputSchema": {
1431
1679
  "type": "object",
1432
1680
  "properties": {
1433
1681
  "id": {
1434
1682
  "type": "string",
1435
- "description": "Webhook id (looks like `wh_…`)."
1683
+ "description": "Webhook id."
1436
1684
  },
1437
1685
  "name": {
1438
1686
  "type": "string",
1439
- "description": "New display name."
1687
+ "description": "New display name. Non-empty, max 80 characters."
1440
1688
  },
1441
1689
  "cadence": {
1442
1690
  "type": "string",
1443
- "description": "New evaluation cadence.",
1691
+ "description": "Evaluation cadence. A user preference — never gated. Event triggers deliver on ingest — only `realtime` is accepted on them (400 otherwise).",
1444
1692
  "enum": [
1445
1693
  "realtime",
1446
1694
  "hourly",
@@ -1449,11 +1697,11 @@ export const tools = [
1449
1697
  },
1450
1698
  "target_url": {
1451
1699
  "type": "string",
1452
- "description": "New HTTPS delivery URL."
1700
+ "description": "New https:// delivery URL (webhook channel only — a Discord/mobile subscription 400s here). `null` or empty switches to in-app delivery; `status` is untouched — a disabled webhook stays disabled until `POST /v2/webhooks/{id}/enable` (the only path that re-checks your account's webhook cap)."
1453
1701
  },
1454
1702
  "enabled": {
1455
1703
  "type": "boolean",
1456
- "description": "false pauses deliveries; true resumes. (Re-enabling after auto-disable also works via tickerbot_enable_webhook, which additionally clears match-state.)"
1704
+ "description": "`false` disables the webhook (status → `disabled`). `true` is a no-op unless disabled, in which case use `POST /v2/webhooks/{id}/enable` instead."
1457
1705
  }
1458
1706
  },
1459
1707
  "required": [
@@ -1473,49 +1721,14 @@ export const tools = [
1473
1721
  }
1474
1722
  },
1475
1723
  {
1476
- "name": "tickerbot_list_webhooks",
1477
- "description": "List the caller's webhook subscriptions (rules created via the subscribe tools), newest-first. Use `status` to filter to active or disabled rules.",
1478
- "inputSchema": {
1479
- "type": "object",
1480
- "properties": {
1481
- "status": {
1482
- "type": "string",
1483
- "description": "Filter by status. Omit for all. (`pending_verification` = created but never successfully pinged.)",
1484
- "enum": [
1485
- "active",
1486
- "pending_verification",
1487
- "disabled"
1488
- ]
1489
- },
1490
- "limit": {
1491
- "type": "integer",
1492
- "description": "Page size. Max 100. Default 50."
1493
- },
1494
- "cursor": {
1495
- "type": "string",
1496
- "description": "Opaque cursor."
1497
- }
1498
- }
1499
- },
1500
- "endpoint": {
1501
- "method": "GET",
1502
- "path": "/v2/webhooks",
1503
- "paramLocation": {
1504
- "status": "query",
1505
- "limit": "query",
1506
- "cursor": "query"
1507
- }
1508
- }
1509
- },
1510
- {
1511
- "name": "tickerbot_get_webhook",
1512
- "description": "Fetch one webhook subscription by id (current state, match-set, schedule). Account-scoped: any key on the account can read any of the account's webhooks.",
1724
+ "name": "tickerbot_delete_webhook",
1725
+ "description": "Delete a webhook subscription, and its delivery history with it.",
1513
1726
  "inputSchema": {
1514
1727
  "type": "object",
1515
1728
  "properties": {
1516
1729
  "id": {
1517
1730
  "type": "string",
1518
- "description": "Webhook id (looks like `wh_…`)."
1731
+ "description": "Webhook id."
1519
1732
  }
1520
1733
  },
1521
1734
  "required": [
@@ -1523,7 +1736,7 @@ export const tools = [
1523
1736
  ]
1524
1737
  },
1525
1738
  "endpoint": {
1526
- "method": "GET",
1739
+ "method": "DELETE",
1527
1740
  "path": "/v2/webhooks/{id}",
1528
1741
  "paramLocation": {
1529
1742
  "id": "path"
@@ -1532,7 +1745,7 @@ export const tools = [
1532
1745
  },
1533
1746
  {
1534
1747
  "name": "tickerbot_list_webhook_deliveries",
1535
- "description": "List recent deliveries (pings and fires) for a webhook, newest-first — for diagnosing failures. Returns metadata only (status, attempt, response code, error); the POST body is not stored.",
1748
+ "description": "Recent deliveries for one webhook — what was sent, and what came back.",
1536
1749
  "inputSchema": {
1537
1750
  "type": "object",
1538
1751
  "properties": {
@@ -1551,11 +1764,16 @@ export const tools = [
1551
1764
  },
1552
1765
  "from": {
1553
1766
  "type": "string",
1554
- "description": "Only deliveries created at/after this moment — epoch seconds or ISO datetime. (90-day retention on every plan.)"
1767
+ "description": "Only deliveries created at/after this moment — epoch seconds, epoch milliseconds (13+ digits), or an ISO datetime (`since` is accepted as an alias). Delivery history is retained for 90 days; deleting a webhook deletes its delivery history with it."
1768
+ },
1769
+ "to": {
1770
+ "type": "string",
1771
+ "description": "Only deliveries created at/before this moment — same value grammar as `from`. A date-only value means through the end of that UTC day. `from` after `to` is a 400."
1555
1772
  },
1556
1773
  "limit": {
1557
1774
  "type": "integer",
1558
- "description": "Page size. Max 100. Default 50."
1775
+ "description": "Page size. Max 100.",
1776
+ "default": 50
1559
1777
  },
1560
1778
  "cursor": {
1561
1779
  "type": "string",
@@ -1573,37 +1791,15 @@ export const tools = [
1573
1791
  "id": "path",
1574
1792
  "status": "query",
1575
1793
  "from": "query",
1794
+ "to": "query",
1576
1795
  "limit": "query",
1577
1796
  "cursor": "query"
1578
1797
  }
1579
1798
  }
1580
1799
  },
1581
- {
1582
- "name": "tickerbot_test_webhook",
1583
- "description": "Send a real-shape `webhook.fired` POST to the webhook's target_url synchronously, right now. The body is byte-identical to a real fire (same signing); the test marker rides in an `X-Tickerbot-Test: true` header. Returns the inline outcome (`delivered`, `http_status`, `elapsed_ms`, `error`). One-shot — a failed test never retries and never auto-disables the webhook. Fails with 400 if the webhook has no target_url (in-app deliveries have nothing to fire over the wire).",
1584
- "inputSchema": {
1585
- "type": "object",
1586
- "properties": {
1587
- "id": {
1588
- "type": "string",
1589
- "description": "Webhook id."
1590
- }
1591
- },
1592
- "required": [
1593
- "id"
1594
- ]
1595
- },
1596
- "endpoint": {
1597
- "method": "POST",
1598
- "path": "/v2/webhooks/{id}/test",
1599
- "paramLocation": {
1600
- "id": "path"
1601
- }
1602
- }
1603
- },
1604
1800
  {
1605
1801
  "name": "tickerbot_enable_webhook",
1606
- "description": "Re-enable a disabled webhook — flips it back to `active` and clears its match-state so the next eval treats every currently-matching ticker as new. Use after fixing whatever caused auto-disable. No-op on an already-active webhook.",
1802
+ "description": "Re-enable a disabled webhook and start it clean. Clears match-state, so the next eval treats every currently-matching ticker as new.",
1607
1803
  "inputSchema": {
1608
1804
  "type": "object",
1609
1805
  "properties": {
@@ -1625,14 +1821,14 @@ export const tools = [
1625
1821
  }
1626
1822
  },
1627
1823
  {
1628
- "name": "tickerbot_delete_webhook",
1629
- "description": "Delete a webhook subscription by id. Use after listing webhooks when the user wants to remove an alert.",
1824
+ "name": "tickerbot_test_webhook",
1825
+ "description": "Send a real-shape test POST to your endpoint, instantly. One-shot: a failed test never retries and never auto-disables the webhook. 400 when the webhook has no target_url.",
1630
1826
  "inputSchema": {
1631
1827
  "type": "object",
1632
1828
  "properties": {
1633
1829
  "id": {
1634
1830
  "type": "string",
1635
- "description": "Webhook id (looks like `wh_…`)."
1831
+ "description": "Webhook id."
1636
1832
  }
1637
1833
  },
1638
1834
  "required": [
@@ -1640,8 +1836,8 @@ export const tools = [
1640
1836
  ]
1641
1837
  },
1642
1838
  "endpoint": {
1643
- "method": "DELETE",
1644
- "path": "/v2/webhooks/{id}",
1839
+ "method": "POST",
1840
+ "path": "/v2/webhooks/{id}/test",
1645
1841
  "paramLocation": {
1646
1842
  "id": "path"
1647
1843
  }