thoughtleaders-cli 0.6.45__tar.gz → 0.6.46__tar.gz

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.
Files changed (93) hide show
  1. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/pyproject.toml +1 -1
  4. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl/SKILL.md +12 -17
  5. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl/references/firebolt-schema.md +12 -3
  6. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl/references/postgres-schema.md +42 -125
  7. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/SKILL.md +2 -4
  8. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/__init__.py +1 -1
  9. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/brands.py +13 -1
  10. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/channels.py +31 -7
  11. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/.claude-plugin/marketplace.json +0 -0
  12. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/.github/workflows/python-publish.yml +0 -0
  13. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/.gitignore +0 -0
  14. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/AGENTS.md +0 -0
  15. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/CLAUDE.md +0 -0
  16. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/LICENSE +0 -0
  17. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/README.md +0 -0
  18. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/agents/tl-analyst.md +0 -0
  19. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/docs/architecture.md +0 -0
  20. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/hooks/hooks.json +0 -0
  21. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/hooks/scripts/load-tl-skill.mjs +0 -0
  22. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/hooks/scripts/post-usage.sh +0 -0
  23. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/hooks/scripts/pre-check.sh +0 -0
  24. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl/references/business-glossary.md +0 -0
  25. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl/references/elasticsearch-schema.md +0 -0
  26. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-import/SKILL.md +0 -0
  27. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
  28. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/examples/golden_queries.md +0 -0
  29. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/columns_brands.md +0 -0
  30. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/columns_channels.md +0 -0
  31. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/columns_content.md +0 -0
  32. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
  33. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
  34. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
  35. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/report_glossary.md +0 -0
  36. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/sortable_columns.json +0 -0
  37. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
  38. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
  39. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/references/widgets.md +0 -0
  40. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/tools/column_builder.md +0 -0
  41. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/tools/database_query.md +0 -0
  42. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/tools/keyword_research.md +0 -0
  43. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/tools/name_resolver.md +0 -0
  44. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/tools/sample_judge.md +0 -0
  45. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/tools/similar_channels.md +0 -0
  46. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
  47. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/skills/tl-report-builder/tools/widget_builder.md +0 -0
  48. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/_completions.py +0 -0
  49. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/auth/__init__.py +0 -0
  50. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/auth/commands.py +0 -0
  51. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/auth/finalize.py +0 -0
  52. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/auth/login.py +0 -0
  53. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/auth/pkce.py +0 -0
  54. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/auth/token_store.py +0 -0
  55. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/client/__init__.py +0 -0
  56. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/client/errors.py +0 -0
  57. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/client/http.py +0 -0
  58. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/__init__.py +0 -0
  59. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/_comments_common.py +0 -0
  60. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/ask.py +0 -0
  61. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/balance.py +0 -0
  62. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/bulk_import.py +0 -0
  63. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/changelog.py +0 -0
  64. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/credits.py +0 -0
  65. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/db.py +0 -0
  66. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/deals.py +0 -0
  67. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/describe.py +0 -0
  68. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/doctor.py +0 -0
  69. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/matches.py +0 -0
  70. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/proposals.py +0 -0
  71. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/recommender.py +0 -0
  72. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/reports.py +0 -0
  73. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/schema.py +0 -0
  74. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/setup.py +0 -0
  75. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/snapshots.py +0 -0
  76. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/sponsorships.py +0 -0
  77. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/uploads.py +0 -0
  78. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/commands/whoami.py +0 -0
  79. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/config.py +0 -0
  80. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/filters.py +0 -0
  81. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/hints.py +0 -0
  82. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/main.py +0 -0
  83. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/output/__init__.py +0 -0
  84. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/output/formatter.py +0 -0
  85. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/src/tl_cli/self_update.py +0 -0
  86. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/tests/__init__.py +0 -0
  87. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/tests/test_auth.py +0 -0
  88. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/tests/test_filters.py +0 -0
  89. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/tests/test_http_auth.py +0 -0
  90. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/tests/test_output.py +0 -0
  91. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/tests/test_reports.py +0 -0
  92. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/tests/test_sponsorships.py +0 -0
  93. {thoughtleaders_cli-0.6.45 → thoughtleaders_cli-0.6.46}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.6.45",
3
+ "version": "0.6.46",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.6.45
3
+ Version: 0.6.46
4
4
  Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
5
5
  Project-URL: Homepage, https://thoughtleaders.io
6
6
  Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.6.45"
7
+ version = "0.6.46"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -39,7 +39,7 @@ Always run `tl schema pg|fb|es` before writing a raw query. When you only need t
39
39
  duckdb -c "SELECT brand, SUM(price) AS revenue FROM 'deals.csv' GROUP BY brand ORDER BY revenue DESC LIMIT 10"
40
40
  ```
41
41
 
42
- The pattern is always: server-side narrowing first (usuakky by filters in the `tl db` query, but could be from similarity searches), then shell tool to shape the result, then read only the final summary into context. If `tl doctor` reports any of these as missing, ask the user to install them — `tl-internal setup` installs all four by default.
42
+ The pattern is always: server-side narrowing first (usually by filters in the `tl db` query, but could be from similarity / recommender searches), then shell tool to shape the result, then read only the final summary into context. If `tl doctor` reports any of these as missing, ask the user to install them — `tl-internal setup` installs all four by default.
43
43
 
44
44
  Always assume there will be more than 1 page of results. You MUST always pass `LIMIT` and `OFFSET` to every `tl db pg|fb|es` query (and use the response envelope's `next_offset` / breadcrumbs to walk forward) so the entire data set is retrieved. The maximum number of rows per page is 500.
45
45
 
@@ -64,8 +64,6 @@ The centre of the data model are **Sponsorships** — business relationships bet
64
64
 
65
65
  Sponsorships are sometimes called "Ads" or "Ad campaigns". **"AdLink"** is another name for the same thing — it's the term the database uses (`thoughtleaders_adlink`) and shows up across internal code, schema docs, and AM Slack threads. Treat "sponsorship" and "adlink" as interchangeable; the user-facing word is "sponsorship," the engineering/DB word is "adlink."
66
66
 
67
- The CLI has shortcut commands for each type: `tl matches`, `tl proposals`, `tl deals`. These are aliases for `tl sponsorships` with filtering by status.
68
-
69
67
  Other key concepts:
70
68
  - **Channels** — YouTube channels, but could also be podcasts
71
69
  - **Brands** — Entities (usually companies / organizations, but could be narrowed down to individual brands of a company)
@@ -92,7 +90,7 @@ Other key concepts:
92
90
  - **CPM** has two distinct meanings depending on level — pick the one the user actually wants:
93
91
  - **Channel CPM** = `(adspot.price / channel.impression) * 1000` — projected price per thousand projected views. Used for pricing decisions **before** a sponsorship is sold. Available for channels with active adspots via `tl channels show <channel_id>`.
94
92
  - **Sponsorship CPM** = calculated in either of two ways: if `views` is present, then CPM is `(sponsorship.price / sponsorship.views) × 1000`, meaning realized cost per thousand actual views, computed post-publication. If `views` is null, Compute from the sponsorship's `price` and the channel's `impression` fields.
95
- - CPM does not have a range filter. To find sponsorships in a CPM range (e.g. "around $15"), fetch the record set with other filters first, then apply the CPM range in post-processing (jq, Python, etc.) on the returned `cpm` field. Plan queries and pagination accordingly — the server cannot reduce the result count based on CPM.
93
+ - Where possible, calculate the correct CPM in a SQL expression.
96
94
  - **Sponsorship dates** — each sponsorship has four distinct dates, useful for different queries:
97
95
  - **`created_at`** — when the sponsorship record was created in the system
98
96
  - **`purchase_date`** — when the sponsorship was purchased (i.e. when the deal was made); These make up bookings.
@@ -105,7 +103,7 @@ Users see data scoped by their organization and plan:
105
103
  - **Media sellers** see sponsorships where their org is the publisher. They see `cost` but never `price`.
106
104
  - **Intelligence plan** is required for accessing information not strictly related to the user's organisation.
107
105
 
108
- When querying sponsorship bookings, query by `status:sold` and filter the the date range only by `purchase_date`. Otherwise, query for `status:sold` and filter by `created_at`.
106
+ When querying sponsorship bookings, filter the rows with `publish_status = 3` (sold) and use `purchase_date` for the date range. For all-flow / not-yet-sold inclusive queries, drop the `publish_status` predicate and filter by `created_at` instead.
109
107
 
110
108
  ## Methodology
111
109
 
@@ -137,19 +135,18 @@ Notes:
137
135
 
138
136
  Unless the user specifically asks for running a specific report or showing the result of a specific report, find the data by using other, low-level commands.
139
137
 
140
- 1. **Discover first**: Run `tl describe show <resource> --json` to learn available fields, filters, and credit costs before querying. Use `tl schema pg`, `tl schema es`, and `tl schema fb` to find information about the main database (pg), the articles / uploads database (es), and the channel metrics database (fb).
141
- 2. **Check saved reports**: Run `tl reports --json` to see if the user has a saved report that already answers their question
142
- 3. **Check credits**: Run `tl balance --json` before expensive queries. Warn the user if a query will cost many credits.
143
- 4. **Query with filters**: Use `key:value` filter syntax for structured queries
144
- 5. **Always use --json**: Parse JSON output for multi-step analysis.
145
- 6. **Chain commands**: For complex questions, chain multiple `tl` commands
146
- 7. **Format results**: When the user asks for a list or tabular data, present the results as a well-formatted markdown table. Pick the most relevant columns and use clear headers.
138
+ 1. **Discover first**: Use `tl schema pg`, `tl schema es`, and `tl schema fb` to find information about the main database (pg), the articles / uploads database (es), and the channel metrics database (fb).
139
+ 2. **Check credits**: Run `tl balance --json` before expensive queries. Warn the user if a query will cost many credits.
140
+ 3. **Decide the method of discovery**: If the user want to explore certain topics, use the recommender commands. If it's more about filtering, construct a query for PG or ES.
141
+ 4. **Always use --json**: Parse JSON output for multi-step analysis.
142
+ 5. **Chain commands**: For complex questions, chain multiple `tl` commands, shell commands, and other tools.
143
+ 6. **Format results**: When the user asks for a list or tabular data, present the results as a well-formatted markdown table. Pick the most relevant columns and use clear headers. In this case, ask the user if they want to save the list as a report, and invoke the `tl-save-report` skill.
147
144
 
148
- Prefer writing Python code, shell code, or `jq` commands that fetche or analysise large sets of data, instead of analysing it yourself. Create temporary files in `/tmp` that can be analysed later in different ways. Before bulk data analysis by running `jq`, Python or Bash commands, first try fetching just a single result with `--limit 1` without `jq` etc, to see the shape of the data and any error messages.
145
+ Prefer writing shell code, `jq` commands, or `duckdb` commands that fetch or analysise large sets of data instead of analysing it yourself. Create temporary files in `/tmp` that can be analysed later in different ways. Before analysing a potentially large result set, first try fetching just a single result with `LIMIT 1` without `jq` etc, to see the shape of the data and any error messages.
149
146
 
150
147
  ## Available Commands
151
148
 
152
- Note that if you're working on Windows, you need to set up UTF-8 in the console, because all of these commands return UTF-8 data.
149
+ Note that if you're working on Windows, you must set up UTF-8 in the console, because all of these commands return UTF-8 data.
153
150
 
154
151
  ### Data queries
155
152
 
@@ -225,8 +222,6 @@ tl channels update 12345 '{"demographic_geo": {"US": 60, "UK": 12, "CA": 8}}'
225
222
  tl channels update 12345 '{"demographic_male_share": 55, "demographic_usa_share": 70}'
226
223
  ```
227
224
 
228
- Each call costs 2 credits. If a request is rejected with a 400, the response body names the offending key — read it and retry with a smaller body. If the user wants to edit something the API rejects, the change has to be made in the app or by a human with DB access.
229
-
230
225
  ### Creating and vetting sponsorships
231
226
 
232
227
  This is the end-to-end workflow for proposing a sponsorship, then moving it through the funnel as the two sides respond. Three create commands plus `tl sponsorships update` cover every state transition the CLI exposes.
@@ -463,7 +458,7 @@ For category- or demographic-driven discovery, **use the recommender, not `conte
463
458
  tl recommender tags cooking
464
459
  tl recommender tags "usa"
465
460
 
466
- # Top channels & profiles loaded on a similarity tag (25 credits; Intelligence)
461
+ # Top channels & profiles loaded on a similarity tag (Intelligence)
467
462
  tl recommender top-channels "Cooking" msn:yes --limit 50
468
463
  tl recommender top-channels "Tech" --limit 30
469
464
  tl recommender top-brands "USA share" mbn:yes --limit 50
@@ -120,9 +120,18 @@ Every Firebolt workflow has two steps:
120
120
  tl recommender top-channels "Tech" msn:yes --limit 50 --json \
121
121
  | jq '.results[].channel_id'
122
122
 
123
- # Or videos for a specific brand's deals (Postgres side, via tl sponsorships)
124
- tl deals list brand:"Nike" --json --limit 500 \
125
- | jq -r '.results[] | select(.article_id != null) | "\(.channel_id):\(.article_id)"'
123
+ # Or videos for a specific brand's deals (Postgres side, via raw SQL)
124
+ tl db pg "SELECT al.id, al.article_id, s.channel_id
125
+ FROM thoughtleaders_adlink al
126
+ JOIN thoughtleaders_adspot s ON s.id = al.ad_spot_id
127
+ JOIN thoughtleaders_profile p ON p.id = al.creator_profile_id
128
+ JOIN thoughtleaders_profile_brands pb ON pb.profile_id = p.id
129
+ JOIN thoughtleaders_brand b ON b.id = pb.brand_id
130
+ WHERE al.publish_status = 3
131
+ AND b.name = 'Nike'
132
+ AND al.article_id IS NOT NULL
133
+ LIMIT 500 OFFSET 0" --json \
134
+ | jq -r '.results[] | "\(.channel_id):\(.article_id)"'
126
135
 
127
136
  # Or videos via Elasticsearch content search
128
137
  tl db es '{
@@ -1,28 +1,21 @@
1
1
  # ThoughtLeaders PostgreSQL Schema Reference
2
2
 
3
- > **Canonical home (within this plugin).** This file is the single source of truth for TL Postgres schema facts inside `tl-cli` (tables, columns, fetch SQL, hallucinated-column markers, join paths). Dependent skills here — most notably `tl-report-builder` — must **link to entries in this file** rather than restate columns / fetch SQL / "do not exist" markers in their own `references/*.md`. Forking schema content into a parallel `<skill>/references/*.md` produces silent drift; that anti-pattern is what this preamble exists to prevent. Upstream source of truth is `thoughtleaders-skills/tl-data/references/postgres-schema.md`; this file is a managed sync.
3
+ **Canonical anchor (within this plugin).** This file is the single source of truth for TL Postgres schema facts for the `tl` command (tables, columns, fetch SQL, hallucinated-column markers, join paths).
4
4
 
5
- ## How to query
6
-
7
- ```bash
8
- tl db pg "SELECT id, weighted_price FROM thoughtleaders_adlink
9
- WHERE publish_status = 2
10
- LIMIT 50 OFFSET 0"
11
- ```
12
-
13
- `tl schema pg` prints the live table/column listing visible to your user.
5
+ This file does not describe every table and column. For the actual current schema, execute `tl schema pg --json`.
14
6
 
15
7
  Accepted SQL:
16
8
  - **SELECT only**, single statement. No DDL/DML/transactions/SET/COPY/MERGE.
17
9
  - Functions accepted from an explicit list (aggregates, window, string, JSON, math, date-time, array). Catalog-resolving casts (`::regclass`, `::regprocedure`, …) are not accepted.
18
10
  - `LIMIT` and `OFFSET` are optional. Omit them and the server fills in `LIMIT 50 OFFSET 0`. Explicit `LIMIT` must be an integer literal ≤ 500. Explicit `OFFSET` ≥ 10,000 is rejected with HTTP 403 (`OFFSET_TOO_DEEP`); paginate with the response's `next_offset`/breadcrumbs instead of jumping deep.
19
- - SQL ≤ 50,000 chars; AST depth ≤ 64; node count ≤ 5,000.
20
11
 
21
12
  ## Core Tables
22
13
 
23
14
  ### `thoughtleaders_adlink` (Deals/Sponsorships)
24
15
 
25
- The main deals table. Each row = one sponsorship deal between a brand and a YouTube channel. Also called "AdLink" in code, exposed as **sponsorship** in the CLI.
16
+ The main sponsorships table. Each row = one sponsorship between a brand and a YouTube channel. Also called "AdLink" in code, exposed as **sponsorship** in the CLI.
17
+
18
+ The profile table is tightly coupled with the brand table for media buyers, so many reports that operate on the brand levels must access the profile data first.
26
19
 
27
20
  > 🚨 **Columns that DO NOT exist on `thoughtleaders_adlink` — common hallucinations:**
28
21
  > - ❌ `brand_id` — there is NO direct brand FK. Brand is reached via `creator_profile_id → profile → profile_brands → brand`.
@@ -32,9 +25,7 @@ The main deals table. Each row = one sponsorship deal between a brand and a YouT
32
25
  > - ❌ `msn_join_date` (on channel) — use `media_selling_network_join_date`.
33
26
  > - ❌ `mbn_join_date` (on profile) — use `media_buying_network_join_date`.
34
27
 
35
- The profile table is tightly coupled with the brand table for media buyers, so many reports that operate on the brand levels must access the profile data first.
36
-
37
- #### Key Columns
28
+ #### Key Columns for the thoughtleaders_adlink table
38
29
 
39
30
  | Column | Type | Description |
40
31
  |--------|------|-------------|
@@ -42,11 +33,6 @@ The profile table is tightly coupled with the brand table for media buyers, so m
42
33
  | `created_at` | timestamptz | When the deal was created |
43
34
  | `updated_at` | timestamptz | Last modification |
44
35
  | `publish_status` | int | Deal status (see constants below) |
45
- | `price` | numeric | Deal price (USD) |
46
- | `price_currency` | varchar | Always USD |
47
- | `weighted_price` | numeric | `price * (status_weight/100)`, pre-calculated on save |
48
- | `weighted_price_currency` | varchar | Always USD |
49
- | `cost` | numeric | Cost to TL |
50
36
  | `ad_spot_id` | int FK | → `thoughtleaders_adspot.id` |
51
37
  | `creator_profile_id` | int FK | → `thoughtleaders_profile.id` (the brand/advertiser's profile). ⚠️ The table is named `thoughtleaders_profile`, NOT `creator_profile` — the "creator_" prefix lives on the FK column, not the table. |
52
38
  | `owner_advertiser_id` | int FK | → `auth_user.id` (brand-side owner) |
@@ -63,13 +49,13 @@ The profile table is tightly coupled with the brand table for media buyers, so m
63
49
  | `actual_end_date` | timestamptz | Actual end date |
64
50
  | `scheduled_end_date` | timestamptz | Scheduled end date |
65
51
  | `rejection_reason` | int | Rejection reason code (1–24). See "`rejection_reason` Constants" below for the code → label mapping. Set when `publish_status IN (4, 5, 9)` (closed-lost). |
66
- | `rejection_reason_details` | text | Free-text rejection details. Often empty (~78% of lost deals). When populated, contains AM/agency notes like *"english content only"*, *"isn't talking about stocks"*, *"channel does not exist"*. Use as supplementary context, not primary classification. |
52
+ | `rejection_reason_details` | text | Free-text rejection details. Sometimes contains AM/agency notes like *"english content only"*, *"isn't talking about stocks"*, *"channel does not exist"*. Use as supplementary context, not primary classification. |
53
+ | `cost` | float | Cost of the deal to TL |
54
+ | `price` | float | The price of the deal for the brand |
67
55
  | `payment_status` | int | 0=Unpaid, 1=Paid |
68
56
  | `performance_grade` | int | Performance rating (see business-glossary) |
69
57
  | `article_id` | varchar | Compound `<channel_id>:<youtube_id>` — links to ES `_id` and ES `id` field |
70
- | `dashboard_campaign_id` | int FK | Campaign grouping |
71
- | `created_where` | varchar | Where the deal originated |
72
- | `tx_data` | jsonb | Transaction metadata |
58
+ | `created_where` | varchar | What mechanism / software / agent created the record |
73
59
 
74
60
  #### `publish_status` Constants
75
61
 
@@ -85,27 +71,9 @@ The profile table is tightly coupled with the brand table for media buyers, so m
85
71
  | 7 | MATCHED | Matched (default) | 1% |
86
72
  | 8 | OUTREACH | Reached Out | 5% |
87
73
  | 9 | REJECTED_AGENCY | Rejected by Agency | 0% |
88
- | -1 | CLIENT_SIDE_AVAILABLE | Client Side Available | — |
89
- | -2 | CLIENT_SIDE_TAKEN | Client Side Taken | — |
90
74
 
91
75
  #### `rejection_reason` Constants
92
76
 
93
- Source of truth: `thoughtleaders.models.AdLink.REJECTION_REASON` (Django `IntegerField` choices in the main `thoughtleaders` repo).
94
-
95
- **Storage model:** `thoughtleaders_adlink.rejection_reason` is an `IntegerField`. **Postgres stores only the integer code.** The labels live in the Django `IntegerField.choices` tuple in application code — they are NOT a queryable column or a join key. The integer is the only thing you can `WHERE` against; the labels below are display mappings only.
96
-
97
- The **Enum Label** column is the verbatim string from the Django choices tuple (some have typos / internal vocabulary). The **AM-friendly label** is the recommended phrasing for AM-facing reports and proposals — use it when surfacing rejection reasons in any human-readable output.
98
-
99
- **Grouping — be careful, codes 18–24 are NOT homogeneous:**
100
- - **Codes 1–9** — brand-side rejections (brand said no)
101
- - **Codes 10–17** — publisher-side rejections (channel said no)
102
- - **Code 18 (`DEMOGRAPHICS_NO_MATCH`)** — audience-fit mismatch. Neither side is "wrong"; the channel may be excellent but its audience doesn't align with the brand's target. Don't bucket as "channel quality."
103
- - **Codes 19, 21, 22, 24** (`NOT_BRAND_SAFE`, `HIGH_VOLATILITY`, `LOW_ENGAGEMENT`, `NO_FACE_ON_SCREEN`) — channel-quality / channel-performance signals (production and delivery).
104
- - **Code 20 (`POOR_BRAND_HISTORY`)** — **brand-quality**, NOT channel quality. The brand has a poor sponsorship track record (e.g., known to ghost / pay late / be difficult). Do not include when reporting on channel quality.
105
- - **Code 23 (`DUPLICATE_PROPOSAL`)** — **process/timing**, NOT channel quality. Channel was pitched too recently. Outreach-cadence issue.
106
-
107
- ⚠️ Naïve "codes 18–24 = channel quality" bucketing will misattribute brand-quality (20), audience-fit (18), and process (23) failures to channel quality and skew rejection-rate analysis.
108
-
109
77
  | Code | Constant | Enum Label (verbatim from Django) | AM-friendly label |
110
78
  |------|----------|---------------------|-------------------|
111
79
  | 1 | OTHER | Other (brand) | Brand declined — other reason |
@@ -186,7 +154,7 @@ A channel can have multiple adspots (different sellers: talent manager, direct,
186
154
  | `is_active` | boolean | Active flag |
187
155
  | `publisher_id` | int FK | → `auth_user.id` (NOT `thoughtleaders_profile.id` — see gotcha below) |
188
156
 
189
- ### `thoughtleaders_channel` (YouTube Channels)
157
+ ### Key columns for the `thoughtleaders_channel` table
190
158
 
191
159
  | Column | Type | Description |
192
160
  |--------|------|-------------|
@@ -194,98 +162,43 @@ A channel can have multiple adspots (different sellers: talent manager, direct,
194
162
  | `channel_name` | varchar | Display name. ⚠️ The column is `channel_name`, NOT `name`. |
195
163
  | `external_channel_id` | varchar | YouTube channel ID (e.g., `UCxxxxxx`). ⚠️ There is NO `youtube_id` column — use this one. |
196
164
  | `url` | varchar | Channel URL (external — usually the YouTube URL). |
197
- | `slug` | varchar | TL platform slug. Used to build the canonical TL channel URL: `https://app.thoughtleaders.io/youtube/<slug>`. Prefer this over `url` when linking to a channel from any AM-facing surface (reports, samples, Slack posts) — the TL URL keeps the user inside the platform and is the company's hyperlink contract. Falls back to an ID-based TL path if `slug` is NULL; never fall back to the external YouTube URL. |
165
+ | `slug` | varchar | TL-platform-specific slug. Used to build the canonical TL channel URL: `https://app.thoughtleaders.io/youtube/<slug>`. Prefer this over `url` when linking to a channel from any user-facing surface (reports, samples, Slack posts). Fall back to an ID-based TL path if `slug` is NULL; never fall back to the external YouTube URL. |
166
+ | `total_views` | int | Total views for the entire channel |
198
167
  | `reach` | bigint | Subscriber count. ⚠️ There is NO `subscribers` column — `reach` is the subscriber count. Many internal docs and outputs use the word "subscribers"; in SQL, always query `reach`. |
199
- | `media_selling_network_join_date` | date/timestamptz | When channel joined MSN. **MSN membership = this column IS NOT NULL.** |
200
- | `is_tl_channel` | boolean | True = TPP channel — TL's closest-partner channels (~144 at 100k+ reach), a strict subset of MSN. Prefer when booking: fastest response, easiest to close. ⚠️ **Not the MSN flag.** Naive `WHERE is_tl_channel = true` as an "MSN filter" silently drops ~98% of the MSN pool (8,652 → 144 at 100k+). For MSN, use `media_selling_network_join_date IS NOT NULL`. |
201
- | `content_category` | int | Content category code (1–22). See "`content_category` Constants" below for the code → label mapping. ⚠️ **Data-quality notes:** (1) per-row category assignments are often inconsistent with the official label (e.g. cat 15 = Technology, but many top-`reach` channels in cat 15 are clearly Entertainment). (2) Several codes are essentially unused in practice — codes 1, 2, 4, 6, 7, 8, 9, 11, 13 (Backend Development, Design, Frontend Development, Marketing, Mobile Development, Sales, Travel, Photography, Personal Finance) return ~0 active high-reach channels. Most travel creators land under Lifestyle (5), not Travel (9). The label table below is authoritative; the per-row assignment is best-effort. **For topic/category discovery, prefer `tl recommender top-channels "<tag>"` (ranked) over `WHERE content_category = <code>` (equality). |
168
+ | `media_selling_network_join_date` | date/timestamptz | When the channel joined the MSN. **MSN membership = this column IS NOT NULL.** |
169
+ | `is_tl_channel` | boolean | True = TPP channel — TL's closest-partner channels (~144 at 100k+ reach), a strict subset of MSN. Prefer when booking: fastest response, easiest to close. ⚠️ **This is not the MSN flag.** For MSN, use `media_selling_network_join_date IS NOT NULL`. |
170
+ | `content_category` | int | Content category code (1–22), as assigned by YouTube. This assignment is too unreliable, do not use it for discovering channels. **For topic/category discovery, prefer `tl recommender top-channels "<tag>"` |
202
171
  | `is_active` | boolean | Whether the channel is active. ⚠️ **Always include `is_active = true` in channel queries** unless explicitly looking for archived rows. |
203
- | `country` | varchar | Channel's primary country (ISO 3166-1 alpha-2 code, e.g. `US`, `GB`, `BR`). Often the cleanest answer to "geo" questions on sponsorships (since adlink itself has no geo). May be NULL or blank on ~10% of channels. |
204
- | `language` | varchar | Primary content language. ⚠️ **Short ISO 639 codes — NOT BCP-47.** Mostly 2-letter ISO 639-1 (`en`, `pt`, `hi`) for major languages; occasionally 3-letter ISO 639-2/3 (`arc`, `arz`, `ase`, `ceb`) for languages without a 2-letter code. Filtering with `language = 'en-US'` returns zero rows. **Don't assume `LENGTH(language) = 2`** — that silently drops the 3-letter long-tail. May be NULL on ~10% of channels. |
205
- | `last_published` | date | Date of the channel's most recent video. Use for "is the channel still active?" filters — e.g. `last_published >= CURRENT_DATE - INTERVAL '120 days'`. |
206
- | `sponsorship_score` | double precision | TL-internal channel quality score (higher is better). Useful as a tiebreaker when ranking candidate channels. |
207
- | `description` | text | LLM-generated description of the channel. Sometimes useful as a regex-target for thematic filtering when the integer category is too coarse (e.g. filtering "Technology" cat 15 down to actual tech reviewers via keywords like `tech|gadget|review|software`). |
208
- | `evergreenness` | float | Cached evergreen score |
172
+ | `country` | varchar | Channel's primary country (ISO 3166-1 alpha-2 code, e.g. `US`, `GB`, `BR`). This is often the cleanest answer to "geography" questions on sponsorships. May be NULL or blank. |
173
+ | `language` | varchar | Primary content language. ⚠️ **Short ISO 639 codes — NOT BCP-47.** Mostly 2-letter ISO 639-1 (`en`, `pt`, `hi`) for major languages; occasionally 3-letter ISO 639-2/3 (`arc`, `arz`, `ase`, `ceb`) for languages without a 2-letter code. Filtering with `language = 'en-US'` returns zero rows. **Don't assume `LENGTH(language) = 2`** — that silently drops the 3-letter long-tail. May be NULL. |
174
+ | `last_published` | date | Date of the channel's most recently seen video. Use for "is the channel still active?" filters — e.g. `last_published >= CURRENT_DATE - INTERVAL '120 days'`. |
175
+ | `sponsorship_score` | double precision | TL-internal channel quality score (range 0-10, higher is better, if below 5, the channel is low quality). Useful as a tiebreaker when ranking candidate channels. |
176
+ | `ai_description` | JSON | Descriptive information about a channel. Contains fields such as `description`, `audience`, `topic_descriptions`, and `brand_safety`. Useful as a regex-target for thematic filtering when the recommender results are too coarse (e.g. filtering "technology" down to actual tech reviewers via keywords like `tech|gadget|review|software`). |
177
+ | `evergreenness` | float | Evergreen score |
178
+ | `demographic_usa_share` | smallint (0–100) | Percentage of the channel's audience based in the US. Convenience for the common "is this a US-heavy channel?" filter — pre-computed from `demographic_geo['US']`. NULL when the channel has no demographic data. |
179
+ | `demographic_male_share` | smallint (0–100) | Percentage of the channel's audience that's male. `female_share = 100 - demographic_male_share` (no separate column). NULL when the channel has no demographic data. |
180
+ | `demographic_age_median_value` | varchar | The age-bucket label (e.g. `25-34`) corresponding to the median of `demographic_age`, pre-computed on save. Indexed; cheap to filter on. NULL when there's no age data. |
181
+ | `demographic_device_primary` | varchar | The dominant viewing-device token from `demographic_device` (e.g. `mobile`, `computer`, `tablet`, `tv`, `game_console`). Pre-computed on save. ⚠️ The DB uses `computer` (not `desktop`) and `game_console` (not `game-console`); the CLI's structured filters translate, but raw SQL filters do not. Indexed. NULL when there's no device data. |
182
+ | `demographic_age` | JSONB | Audience age distribution, e.g. `{"18-24": 7, "25-34": 20, "35-44": 21, "45-54": 18, "55-64": 15}`. Percentages don't always sum to 100 (the long-tail buckets are dropped). NULL when the channel has no demographic data. Filter with `demographic_age->>'25-34' >= '20'` (text comparison) or cast to int. |
183
+ | `demographic_geo` | JSONB | Audience geography as 2-letter ISO country code → percentage, e.g. `{"US": 53.0, "UK": 12.0, "CA": 8.0, "IN": 5.0}`. Long tail is dropped — entries summing to ≥ ~95% is normal. Filter with `(demographic_geo->>'US')::float >= 60`. NULL when there's no demographic data. |
184
+ | `demographic_device` | JSONB | Audience device-mix percentages, e.g. `{"computer": 35, "mobile": 45, "tablet": 8, "tv": 10, "game_console": 2}`. Same DB-token caveats as `demographic_device_primary`. Filter with `(demographic_device->>'mobile')::float >= 60`. NULL when there's no demographic data. |
185
+ | `demographics_updated_at` | timestamptz | When any of the demographic_* fields last changed (auto-stamped on save via `Channel.FIELDS_TO_CHECK`). Use as a recency filter when sampling — older demographics are stale. NULL when demographics were never set. |
186
+ | `outreach_email` | varchar | Channel outreach email |
187
+
188
+ **IMPORTANT**: Demographics and outreach columns have additional pricing attached! They are the most valuable, and the most expensive fields to fetch. Never do "SELECT *" on this table because that will also fetch these expensive columns.
209
189
 
210
190
  #### Hallucination shapes to avoid
211
191
 
212
- When composing `SELECT ... FROM thoughtleaders_channel ...`, do not improvise column names from semantic intuition — consult the column table above. Failed guesses return *"column '\<name\>' does not exist"* and cost a round-trip. Recurring shapes:
192
+ When composing `SELECT ... FROM thoughtleaders_channel ...`, do not improvise column names from semantic intuition — consult the output of `tl schema pg thoughtleaders_channel` or the column table above. The `tl schema` command is authoritative. Failed guesses return *"column '\<name\>' does not exist"* and cost a round-trip. Recurring problems:
213
193
 
214
- - ❌ **Suffix/qualifier variants of date columns** (e.g. an `_max` / `latest_` / `_date` form when the canonical column has neither). Date columns above use bare names.
194
+ - ❌ **Suffix/qualifier variants of date columns** (e.g. an `_max` / `latest_` / `_date` form when the canonical column has neither). Date columns use bare names.
215
195
  - ❌ **Platform-name-prefixed ID forms** (e.g. a platform-name prefix when the canonical column uses a neutral `external_` prefix). See the column table for the actual ID column.
216
196
  - ❌ **Bare-noun forms without the table-prefix** (e.g. `name` instead of `channel_name`). This table prefixes its display fields with `channel_` to avoid SQL keyword collisions and ambiguity in joins.
217
197
  - ❌ **User-facing-term forms used as SQL column names** (the user-facing word is sometimes different from the SQL column name; consult [business-glossary](business-glossary.md) for the canonical mapping when the two diverge).
218
198
 
219
- When the canonical column you need isn't obvious from the table above, consult the column table first. Do **not** rely on a 400 to correct you, and do **not** fall back to `information_schema.columns` as the recovery path — that's a regression marker too.
220
-
221
- #### `content_category` Constants
222
-
223
- Source of truth: `thoughtleaders.taxonomies.ContentCategory` (Django `IntEnum` in the main `thoughtleaders` repo).
224
-
225
- | Value | Constant | Pretty Label |
226
- |-------|----------|--------------|
227
- | 1 | BACKEND_DEVELOPMENT | Backend Development |
228
- | 2 | DESIGN | Design |
229
- | 3 | ENTREPRENEURSHIP | Entrepreneurship |
230
- | 4 | FRONTEND_DEVELOPMENT | Frontend Development |
231
- | 5 | LIFESTYLE | Lifestyle |
232
- | 6 | MARKETING | Marketing |
233
- | 7 | MOBILE_DEVELOPMENT | Mobile Development |
234
- | 8 | SALES | Sales |
235
- | 9 | TRAVEL | Travel |
236
- | 10 | BUSINESS | Business |
237
- | 11 | PHOTOGRAPHY | Photography |
238
- | 12 | GENERAL_KNOWLEDGE | General Knowledge |
239
- | 13 | PERSONAL_FINANCE | Personal Finance |
240
- | 14 | NEWS_POLITICS | News & Politics |
241
- | 15 | TECHNOLOGY | Technology |
242
- | 16 | GAMING | Gaming |
243
- | 17 | FOOD | Food |
244
- | 18 | SPORTS | Sports |
245
- | 19 | HOWTO | How To & Crafts |
246
- | 20 | ENTERTAINMENT | Entertainment |
247
- | 21 | HEALTH_FITNESS | Health & Fitness |
248
- | 22 | MUSIC | Music |
249
-
250
- ### `thoughtleaders_topics` (Curated Topic Taxonomy)
251
-
252
- A small (under 20 rows), live-edited taxonomy of curated topics. Each row bundles a topic name with a one-paragraph description and a `keywords` JSONB array of head + long-tail terms. The report-builder skill's `topic_matcher` tool consumes this table to match a user's natural-language report query against curated topics; downstream filter-building uses the matched topics' `keywords` arrays as keyword groups. The taxonomy is **actively migrating** — content drifts week to week — so consumers must fetch live, never bundle a snapshot.
253
-
254
- #### Fetch query (canonical — use verbatim)
255
-
256
- ```bash
257
- tl db pg --json "SELECT id, name, description, keywords FROM thoughtleaders_topics ORDER BY id LIMIT 100 OFFSET 0"
258
- ```
199
+ When the canonical column you need isn't obvious from the table above, consult `tl schema pg thoughtleaders_channel` or the above table first. Do **not** rely on a 400 to correct you, and do **not** fall back to `information_schema.columns` as the recovery path — that's a regression marker too.
259
200
 
260
- The table has fewer than 20 rows; client-side filtering after a full fetch is free. **Do not push name-pattern WHERE clauses into the SQL** — the agent has guessed `WHERE is_active = TRUE` and `WHERE name ILIKE ANY(...)` in past runs and burnt round-trips on hallucinated columns.
261
-
262
- | Column | Type | Description |
263
- |--------|------|-------------|
264
- | `id` | int | Primary key |
265
- | `name` | varchar | Topic display name (e.g. "Artificial Intelligence", "PC Games") |
266
- | `description` | varchar | One-paragraph human-readable description; the matcher uses it for tie-breaks |
267
- | `keywords` | jsonb | Array of curated keyword strings — the matcher's primary signal. Mixes head terms (`"cooking"`) with long-tail (`"5-ingredient meals"`); long-tail matches are still strong signals, don't downgrade them. |
268
- | `created_at` | timestamptz | Rarely needed |
269
- | `updated_at` | timestamptz | Rarely needed |
270
- | `source` | varchar | Provenance, rarely needed |
271
-
272
- #### Columns that DO NOT exist on `thoughtleaders_topics`
273
-
274
- Common hallucinations the agent has tried in real runs (each wasted a round-trip). All return *"column '\<name\>' does not exist"*:
275
-
276
- - ❌ `is_active`
277
- - ❌ `type` (topics are not subtyped at the schema level)
278
- - ❌ `parent_id` (topics are flat, not hierarchical)
279
- - ❌ `slug`, `topic_id` (the PK is `id`), `archived`, `is_published`
280
-
281
- Cited regression markers from real runs:
282
- - AI/marketing channels run: tried `thoughtleaders_topic` (singular — table doesn't exist), then `WHERE is_active = TRUE`. Three round-trips before consulting `information_schema`.
283
- - Travel/digital-nomad run: tried `SELECT id, name, type, parent_id FROM thoughtleaders_topics WHERE name ILIKE ANY(...)`.
284
- - **Name-pattern WHERE-clause loop (general pattern)**: when the user's niche has no obvious curated topic, agents have run progressively broader name-pattern `WHERE` queries against this table — typically two or three rounds of `WHERE name ILIKE '%<term1>%' OR name ILIKE '%<term2>%' OR ...`, sometimes interleaved with an `information_schema.columns` inspection between them — each returning zero rows. The correct path is one canonical fetch (above) + the matcher's `summary.no_match: true` verdict for the off-taxonomy case. **A zero-row canonical fetch (no WHERE clause) is a data-plane failure, NOT off-taxonomy** — surface the failure rather than silently falling through to keyword_research.
285
-
286
- If a query against this table errors with *"column '\<X\>' does not exist"*, that's the regression marker — go back to the verbatim fetch above.
287
-
288
- ### `auth_user` (Django Users)
201
+ ### Key columns for the `auth_user` table (Django Users)
289
202
 
290
203
  Standard Django user table. Used for owner lookups.
291
204
 
@@ -328,6 +241,10 @@ thoughtleaders_adlink
328
241
  ⚠️ thoughtleaders_brand has NO organization_id column — org lives on profile.
329
242
  ```
330
243
 
244
+ ## Finding a single channel or brand ID
245
+
246
+ As a special case, the `tl channels find` and `tl brands find` commands accept a name of the channel / brand (be sure to properly quote them for the shell) and will return the respective ID. Use this instead of constructing SQL for this particular case. The commands will return a list of possible choices
247
+
331
248
  ### Common Join Paths
332
249
 
333
250
  **Adlink → Channel name:**
@@ -408,7 +325,7 @@ ORDER BY media_selling_network_join_date DESC
408
325
  LIMIT 500 OFFSET 0
409
326
  ```
410
327
 
411
- **Deal with brand and channel name:**
328
+ **A specific sponsorship info with brand and channel name:**
412
329
  ```sql
413
330
  SELECT a.id, a.price, a.publish_status, b.name AS brand, ch.channel_name
414
331
  FROM thoughtleaders_adlink a
@@ -3,8 +3,6 @@ name: tl-report-builder
3
3
  description: |
4
4
  Build TL reports from natural-language requests. Produces an in-chat preview (sample-rows table + filter summary + takeaways) by default, or auto-saves a TL report when the user's wording is explicit about it ("save", "create the report", "make a campaign for me to come back to"). Covers the four report types: content/videos (1), brands (2), channels (3), sponsorships/deals (8).
5
5
 
6
- **Use this skill — NOT `tl-cli:tl` — for ANY request that returns a list of channels, videos, brands, or sponsorships with filters applied**, even when the user doesn't say "report" or "campaign". This is the canonical path for list-shaped requests.
7
-
8
6
  Triggers on every variant of "list me / find me / show me / give me / pull me / build me / make me X with filters Y", including:
9
7
  - **Channels**: "Find me gaming channels with 100K+ subs", "show me TPP fintech creators in MSN", "channels we haven't pitched to <brand>", "look-alike channels to X", "non-MSN travel channels", "build me a list of <niche> creators", "channels matching <criteria>".
10
8
  - **Brands**: "all brands flagged as Managed Services", "brand activity report for these specific brands: ...", "brands sponsoring <channel> in the past 6 months", "competitor brands of X".
@@ -331,10 +329,10 @@ USER_QUERY → Phase 1 → Phase 2 → Phase 3 → Phase 4 → deliverable
331
329
  > Couldn't save the report — the temp directory at <resolved-path>
332
330
  > isn't writable, so I couldn't stage the config for the CLI. This
333
331
  > is a bug in the skill / environment, not something you need to do.
334
- >
332
+ >
335
333
  > The validated config is below as a recovery artifact in case you
336
334
  > want to retry from a different machine. I haven't sent it to TL.
337
- >
335
+ >
338
336
  > <inline JSON in a code block, fenced>
339
337
  > ```
340
338
  >
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.6.45"
3
+ __version__ = "0.6.46"
@@ -211,7 +211,19 @@ def find_cmd(
211
211
  )
212
212
  except ApiError as e:
213
213
  if e.status_code == 400 and isinstance(e.raw, dict) and e.raw.get("candidates"):
214
- _print_brand_find_candidates(e.detail, e.raw["candidates"])
214
+ if fmt == "table":
215
+ _print_brand_find_candidates(e.detail, e.raw["candidates"])
216
+ else:
217
+ # Machine-readable output: emit candidates through the
218
+ # standard formatter so --json / --csv / --md / --toon all
219
+ # produce the same structural surface the success path uses.
220
+ Console(stderr=True).print(f"[yellow]{e.detail}[/yellow]")
221
+ output(
222
+ {"detail": e.detail, "results": e.raw["candidates"]},
223
+ fmt,
224
+ columns=["id", "name", "website"],
225
+ title="Ambiguous match — candidates",
226
+ )
215
227
  raise typer.Exit(1)
216
228
  handle_api_error(e)
217
229
  finally:
@@ -336,15 +336,39 @@ def find_cmd(
336
336
  )
337
337
  except ApiError as e:
338
338
  if e.status_code == 400 and isinstance(e.raw, dict) and e.raw.get("candidates"):
339
- _print_channel_candidates(e.detail, e.raw["candidates"])
339
+ if fmt == "table":
340
+ _print_channel_candidates(e.detail, e.raw["candidates"])
341
+ else:
342
+ # Machine-readable output: emit candidates through the
343
+ # standard formatter so --json / --csv / --md / --toon all
344
+ # produce the same structural surface the success path uses.
345
+ Console(stderr=True).print(f"[yellow]{e.detail}[/yellow]")
346
+ output(
347
+ {"detail": e.detail, "results": e.raw["candidates"]},
348
+ fmt,
349
+ columns=["id", "name", "subscribers"],
350
+ title="Ambiguous match — candidates",
351
+ )
340
352
  raise typer.Exit(1)
341
353
  if e.status_code == 404 and isinstance(e.raw, dict) and e.raw.get("queued"):
342
- err = Console(stderr=True)
343
- err.print(f"[yellow]{e.detail}[/yellow]")
344
- err.print(
345
- f" [dim]queued_channel_id={e.raw.get('queued_channel_id')}"
346
- f" queued_url={e.raw.get('queued_url')}[/dim]"
347
- )
354
+ if fmt == "table":
355
+ err = Console(stderr=True)
356
+ err.print(f"[yellow]{e.detail}[/yellow]")
357
+ err.print(
358
+ f" [dim]queued_channel_id={e.raw.get('queued_channel_id')}"
359
+ f" queued_url={e.raw.get('queued_url')}[/dim]"
360
+ )
361
+ else:
362
+ Console(stderr=True).print(f"[yellow]{e.detail}[/yellow]")
363
+ output_single(
364
+ {
365
+ "detail": e.detail,
366
+ "queued": True,
367
+ "queued_channel_id": e.raw.get("queued_channel_id"),
368
+ "queued_url": e.raw.get("queued_url"),
369
+ },
370
+ fmt,
371
+ )
348
372
  raise typer.Exit(1)
349
373
  handle_api_error(e)
350
374
  finally: