thoughtleaders-cli 0.6.53__tar.gz → 0.6.54__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 (106) hide show
  1. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/PKG-INFO +1 -1
  3. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/pyproject.toml +1 -1
  4. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-import/SKILL.md +11 -11
  5. thoughtleaders_cli-0.6.54/skills/tl-save-report/SKILL.md +501 -0
  6. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/columns_brands.md +82 -0
  7. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/columns_channels.md +99 -0
  8. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/columns_content.md +78 -0
  9. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/columns_sponsorships.md +96 -0
  10. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/intelligence_filterset_schema.json +407 -0
  11. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/intelligence_widget_schema.json +193 -0
  12. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/report_glossary.md +145 -0
  13. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/sortable_columns.json +64 -0
  14. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/sponsorship_filterset_schema.json +217 -0
  15. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/sponsorship_widget_schema.json +165 -0
  16. thoughtleaders_cli-0.6.54/skills/tl-save-report/references/widgets.md +184 -0
  17. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/__init__.py +1 -1
  18. thoughtleaders_cli-0.6.53/skills/tl-save-report/SKILL.md +0 -262
  19. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/.claude-plugin/marketplace.json +0 -0
  20. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/.github/workflows/python-publish.yml +0 -0
  21. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/.gitignore +0 -0
  22. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/AGENTS.md +0 -0
  23. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/API.md +0 -0
  24. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/CLAUDE.md +0 -0
  25. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/LICENSE +0 -0
  26. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/README.md +0 -0
  27. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/agents/tl-analyst.md +0 -0
  28. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/hooks/hooks.json +0 -0
  29. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/hooks/scripts/load-tl-skill.mjs +0 -0
  30. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/hooks/scripts/post-usage.sh +0 -0
  31. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/hooks/scripts/pre-check.sh +0 -0
  32. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl/SKILL.md +0 -0
  33. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl/references/business-glossary.md +0 -0
  34. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl/references/elasticsearch-schema.md +0 -0
  35. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl/references/firebolt-schema.md +0 -0
  36. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl/references/postgres-schema.md +0 -0
  37. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-keyword-research/SKILL.md +0 -0
  38. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-keyword-research/scripts/probe.py +0 -0
  39. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/SKILL.md +0 -0
  40. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/examples/e2e_findings.md +0 -0
  41. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/examples/golden_queries.md +0 -0
  42. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/columns_brands.md +0 -0
  43. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/columns_channels.md +0 -0
  44. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/columns_content.md +0 -0
  45. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/columns_sponsorships.md +0 -0
  46. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/intelligence_filterset_schema.json +0 -0
  47. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/intelligence_widget_schema.json +0 -0
  48. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/report_glossary.md +0 -0
  49. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/sortable_columns.json +0 -0
  50. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/sponsorship_filterset_schema.json +0 -0
  51. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/sponsorship_widget_schema.json +0 -0
  52. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/references/widgets.md +0 -0
  53. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/tools/column_builder.md +0 -0
  54. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/tools/database_query.md +0 -0
  55. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/tools/name_resolver.md +0 -0
  56. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/tools/sample_judge.md +0 -0
  57. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/tools/similar_channels.md +0 -0
  58. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/tools/topic_matcher.md +0 -0
  59. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/skills/tl-report-builder/tools/widget_builder.md +0 -0
  60. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/_completions.py +0 -0
  61. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/auth/__init__.py +0 -0
  62. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/auth/commands.py +0 -0
  63. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/auth/finalize.py +0 -0
  64. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/auth/login.py +0 -0
  65. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/auth/pkce.py +0 -0
  66. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/auth/token_store.py +0 -0
  67. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/client/__init__.py +0 -0
  68. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/client/errors.py +0 -0
  69. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/client/http.py +0 -0
  70. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/__init__.py +0 -0
  71. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/_comments_common.py +0 -0
  72. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/balance.py +0 -0
  73. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/brands.py +0 -0
  74. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/bulk_import.py +0 -0
  75. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/changelog.py +0 -0
  76. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/channels.py +0 -0
  77. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/credits.py +0 -0
  78. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/db.py +0 -0
  79. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/deals.py +0 -0
  80. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/describe.py +0 -0
  81. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/doctor.py +0 -0
  82. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/matches.py +0 -0
  83. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/proposals.py +0 -0
  84. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/recommender.py +0 -0
  85. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/reports.py +0 -0
  86. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/schema.py +0 -0
  87. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/setup.py +0 -0
  88. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/snapshots.py +0 -0
  89. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/sponsorships.py +0 -0
  90. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/uploads.py +0 -0
  91. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/commands/whoami.py +0 -0
  92. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/config.py +0 -0
  93. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/filters.py +0 -0
  94. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/hints.py +0 -0
  95. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/main.py +0 -0
  96. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/output/__init__.py +0 -0
  97. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/output/formatter.py +0 -0
  98. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/src/tl_cli/self_update.py +0 -0
  99. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/tests/__init__.py +0 -0
  100. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/tests/test_auth.py +0 -0
  101. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/tests/test_filters.py +0 -0
  102. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/tests/test_http_auth.py +0 -0
  103. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/tests/test_output.py +0 -0
  104. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/tests/test_reports.py +0 -0
  105. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/tests/test_sponsorships.py +0 -0
  106. {thoughtleaders_cli-0.6.53 → thoughtleaders_cli-0.6.54}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.6.53",
3
+ "version": "0.6.54",
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.53
3
+ Version: 0.6.54
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.53"
7
+ version = "0.6.54"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: tl-import
3
- description: Import a list of channels, brands, uploads (videos), or sponsorships into a ThoughtLeaders report — either an existing report (caller supplies `campaign_id` or a TL report URL) or a fresh new one (skill creates a minimal container, then populates). Superuser-only. **Trigger on explicit intent to import the listed entities into a report**, NOT on the mere presence of a list (a user can paste a list and want analysis, comparison, or similar-channel discovery — those go to `tl-cli:tl-report-builder` or `tl-cli:tl`). The deciding question is: *would the user be satisfied if those exact entities ended up as the report's contents, no transformation?* If yes, this is the skill. Phrasings: "import these channels into report 1234", "add brands to campaign 5678", "exclude these channels from report Z", "bulk-add these videos to report X", "create a new report with these channels: <list>", "make a campaign containing these brands: <list>".
3
+ description: Import a list of channels, brands, uploads (videos), or sponsorships into a ThoughtLeaders report — either an existing report (caller supplies `campaign_id` or a TL report URL) or a fresh new one (skill creates a minimal container, then populates). Superuser-only. **Trigger on explicit intent to import the listed entities into a report**, NOT on the mere presence of a list (a user can paste a list and want analysis, comparison, or similar-channel discovery — those go to `tl-cli:tl`). The deciding question is: *would the user be satisfied if those exact entities ended up as the report's contents, no transformation?* If yes, this is the skill. Phrasings: "import these channels into report 1234", "add brands to campaign 5678", "exclude these channels from report Z", "bulk-add these videos to report X", "create a new report with these channels: <list>", "make a campaign containing these brands: <list>".
4
4
  ---
5
5
 
6
6
  # tl-import
@@ -21,7 +21,7 @@ Trigger on:
21
21
  - "Make a campaign containing these brands: \<list\>" → **new-report flow**
22
22
  - "Build me a report from these adlinks: \<list\>" → **new-report flow** *(the verb "build" doesn't matter — what matters is that the user wants exactly those adlinks in the report.)*
23
23
 
24
- **Do NOT trigger** when the user pastes a list but wants something other than direct import — those belong to `tl-cli:tl-report-builder` or `tl-cli:tl`:
24
+ **Do NOT trigger** when the user pastes a list but wants something other than direct import — those belong to `tl-cli:tl` (analysis / discovery) or `tl-cli:tl-save-report` (persist a session's result set):
25
25
 
26
26
  - *"Find me channels similar to these: \<list\>"* — discovery using the list as a seed, not as the answer.
27
27
  - *"Build a report of TPP channels in the same niche as these: \<list\>"* — discovery with filters and similarity expansion.
@@ -46,7 +46,7 @@ Never silently create a new report when the destination is ambiguous; never sile
46
46
 
47
47
  ## Create a fresh container first (new-report flow only)
48
48
 
49
- The user wants the report to contain exactly the identifiers they're about to import — nothing else. No keyword research, no discovery query, no review pipeline. Just a minimal container that holds the list. **The persistence step uses the same primitive `tl-cli:tl-report-builder` calls at the end of its workflow** (`tl reports create --config-file`), but with a tiny config and none of the upstream phases.
49
+ The user wants the report to contain exactly the identifiers they're about to import — nothing else. No keyword research, no discovery query, no review pipeline. Just a minimal container that holds the list. **The persistence step is `tl reports create --config-file`** with a tiny config no upstream discovery / review phases.
50
50
 
51
51
  Steps:
52
52
 
@@ -57,11 +57,11 @@ Steps:
57
57
  - `brands` → **2** (BRANDS)
58
58
  - `articles` (uploads/videos) → **1** (CONTENT)
59
59
  - `sponsorships` (adlinks/deals) → **8** (CAMPAIGN_MANAGEMENT)
60
- 4. **Pick default columns.** Read the matching columns reference file in the sibling `tl-report-builder` skill and use its **"Defaults — always include"** section — that's where the canonical column list lives per type; do NOT restate it here. The four files:
61
- - channels → `../tl-report-builder/references/columns_channels.md`
62
- - brands → `../tl-report-builder/references/columns_brands.md`
63
- - articles → `../tl-report-builder/references/columns_content.md`
64
- - sponsorships → `../tl-report-builder/references/columns_sponsorships.md`
60
+ 4. **Pick default columns.** Read the matching columns reference file in the sibling `tl-save-report` skill and use its **"Defaults — always include"** section — that's where the canonical column list lives per type; do NOT restate it here. The four files:
61
+ - channels → `../tl-save-report/references/columns_channels.md`
62
+ - brands → `../tl-save-report/references/columns_brands.md`
63
+ - articles → `../tl-save-report/references/columns_content.md`
64
+ - sponsorships → `../tl-save-report/references/columns_sponsorships.md`
65
65
 
66
66
  Convert each display name from the "Defaults — always include" list into a column entry shape **`{"display": true, "width": "default"}`** — the `width` field is required by the dashboard's column renderer; without it, columns sometimes resolve but cells render empty. Use `"wide"` for narrative columns (e.g. `TL Channel Summary`, `Channel Description`, `Topic Descriptions`); use `"narrow"` for short numeric columns (e.g. `Status`, `Country`); `"default"` everywhere else is safe.
67
67
 
@@ -75,7 +75,7 @@ Steps:
75
75
  }
76
76
  ```
77
77
 
78
- Per-type default sort. **Critical invariant:** the `sort` field must reference a `backend_code` whose display-name column is in the column set you emitted in step 4. The dashboard's renderer rejects sorts pointing at columns that aren't present in the report. So pick the intersection of (a) the type's "Defaults — always include" columns from `columns_<type>.md` and (b) sortable columns from `../tl-report-builder/references/sortable_columns.json`:
78
+ Per-type default sort. **Critical invariant:** the `sort` field must reference a `backend_code` whose display-name column is in the column set you emitted in step 4. The dashboard's renderer rejects sorts pointing at columns that aren't present in the report. So pick the intersection of (a) the type's "Defaults — always include" columns from `columns_<type>.md` and (b) sortable columns from `../tl-save-report/references/sortable_columns.json`:
79
79
 
80
80
  | report_type | entity | default `sort` | maps to (must be in column set) |
81
81
  |---|---|---|---|
@@ -101,7 +101,7 @@ Steps:
101
101
  ```
102
102
 
103
103
  `type: 2` is DYNAMIC (the only valid campaign type for save). `filterset: {}` is intentional — no keyword/topic/demographic filters; the report's contents will come entirely from the include list bulk-import populates next. **`dataset_structure` is what makes the rows render with actual values** — leave it out and the dashboard shows row numbers but blank cells.
104
- 7. **Persist via the same primitive `tl-report-builder` uses.** Write the config dict to a temp file using your file-writing tool — **do not use shell `echo` or heredocs**, those break on titles containing apostrophes, dollar signs, backticks, etc. The whole point of `--config-file` is to bypass shell quoting entirely. Pick any temp path the agent's filesystem tool can write to (e.g. `/tmp/tl-import-container.json` on Unix, the OS temp dir on Windows).
104
+ 7. **Persist with `tl reports create --config-file`.** Write the config dict to a temp file using your file-writing tool — **do not use shell `echo` or heredocs**, those break on titles containing apostrophes, dollar signs, backticks, etc. The whole point of `--config-file` is to bypass shell quoting entirely. Pick any temp path the agent's filesystem tool can write to (e.g. `/tmp/tl-import-container.json` on Unix, the OS temp dir on Windows).
105
105
 
106
106
  Then run:
107
107
 
@@ -282,7 +282,7 @@ These are envelope-level failures, distinct from per-row `reason` values:
282
282
 
283
283
  ## What this skill does NOT do
284
284
 
285
- - Doesn't run `tl-report-builder`'s discovery pipeline (keyword research, topic matching, validation cycles, review). When a user gives a fixed list of identifiers, they've already done the discovery themselves — the report is a container for their list, not a query result. Use `tl-report-builder` only when the user wants you to *find* channels/brands/etc. by criteria.
285
+ - Doesn't run a discovery pipeline (keyword research, topic matching, validation cycles, review). When a user gives a fixed list of identifiers, they've already done the discovery themselves — the report is a container for their list, not a query result. Use the `tl` skill to *find* channels/brands/etc. by criteria first; if the user then wants to save those criteria as a live report, use `tl-save-report`.
286
286
  - Doesn't change existing report metadata (title, description, columns, filters) after creation. For that, use the platform UI or a dedicated edit flow. The new-report flow in this skill sets minimum-required metadata once at creation and never revisits it.
287
287
  - Doesn't validate identifiers ahead of time — submit and let the per-row `reason` tell the user which ones failed. Pre-checking with `tl channels show` / etc. is wasteful (metered) and adds latency.
288
288
  - Doesn't sweep duplicates from the user's input list — submit them as-is. The response will mark the second occurrence as `Duplicate`, which is more informative than silently deduping.
@@ -0,0 +1,501 @@
1
+ ---
2
+ name: tl-save-report
3
+ description: |
4
+ Save the results of an in-chat data-exploration session as a TL report. Triggers when the user wants to persist a channels / brands / videos (uploads) / sponsorships list or filtered set they've been working with — phrases like "save this as a report", "save the list", "turn this into a campaign", "persist this", "make a report from what you found", "save the result", "I want to come back to this".
5
+ ---
6
+
7
+ # tl-save-report
8
+
9
+ Persist what the user has been exploring as a saved TL report. The skill assumes the data-exploration phase already happened — it does not re-run queries, re-validate the result set, or ask the user what they were looking for. Its single job is **config-from-session**.
10
+
11
+ ## The two paths
12
+
13
+ Every save goes through exactly one of these:
14
+
15
+ - **[Path A — List-style](#path-a--list-style)** uses `tl reports save-list`. Snapshot a curated set of entity IDs into a frozen list (no filter re-evaluation). One command; the platform applies sensible defaults for columns / widgets / sort, and the user refines via `tl reports update` afterwards if needed. Use when the user curated the set or when the session's filters can't be expressed as FilterSet fields.
16
+ - **[Path B — Filter-style](#path-b--filter-style)** uses `tl reports create --config-file`. Translate the session's criteria into a live FilterSet that re-evaluates against current data every time someone re-runs the report. Builds a full config (columns + widgets + sort). Use when the session was driven by criteria the FilterSet can express directly.
17
+
18
+ The only discovery-side work this skill performs is **name → ID resolution** (`tl brands find` / `tl channels find`) — required by the schema, not a re-evaluation of the result set. If the user has no prior session, run the relevant `tl db pg|fb|es` queries to produce a result set first, then invoke this skill on the result.
19
+
20
+ ## Reference files (what each is for)
21
+
22
+ This skill is self-contained. Every reference it needs is in [`references/`](references/):
23
+
24
+ | File | Use when |
25
+ | --- | --- |
26
+ | [`intelligence_filterset_schema.json`](references/intelligence_filterset_schema.json) | Path B for **CONTENT / BRANDS / CHANNELS** FilterSets (report_type 1 / 2 / 3). Authoritative field catalogue; unknown keys are rejected by the platform with 400. |
27
+ | [`sponsorship_filterset_schema.json`](references/sponsorship_filterset_schema.json) | Path B for **SPONSORSHIPS** FilterSets (report_type 8). Disjoint field set from the intelligence schema — date axes, publish_status, no keyword fields. |
28
+ | [`columns_content.md`](references/columns_content.md) / [`columns_brands.md`](references/columns_brands.md) / [`columns_channels.md`](references/columns_channels.md) / [`columns_sponsorships.md`](references/columns_sponsorships.md) | Path B column choices per report type. Defaults, intent-driven additions, custom-formula guidance. |
29
+ | [`intelligence_widget_schema.json`](references/intelligence_widget_schema.json) / [`sponsorship_widget_schema.json`](references/sponsorship_widget_schema.json) | Path B widget choices. Each schema lists the aggregator catalogue, default widget sets per report type, intent overrides, and (for type 8) the date-axis branching rules. |
30
+ | [`widgets.md`](references/widgets.md) | Readable index of the widget catalogue. Equivalent content to the JSON schemas but easier to skim — start here, drill into the schema for the canonical shape. |
31
+ | [`sortable_columns.json`](references/sortable_columns.json) | Per-column sort metadata (asc-only / desc-only / both). The `sort` value on a report must reference a column listed here with an allowed direction. |
32
+ | [`report_glossary.md`](references/report_glossary.md) | Disambiguation: report-type synonyms, TL terminology (MSN / TPP / MBN / VG / Net revenue / TL profit), deal-stage jargon (numeric publish_status ↔ user phrasing), field-pair choices, common pitfalls. |
33
+
34
+ ## Report types
35
+
36
+ | `report_type` | User-facing name | Row | Schema family |
37
+ | --- | --- | --- | --- |
38
+ | **1** | CONTENT / Uploads | one video / article / podcast episode | intelligence |
39
+ | **2** | BRANDS | one brand (aggregated across matching content) | intelligence |
40
+ | **3** | CHANNELS | one YouTube channel (or podcast) | intelligence |
41
+ | **8** | SPONSORSHIPS / Deals | one sponsorship record (AdLink — brand × channel × dates × status × price) | sponsorship |
42
+
43
+ Types 1 / 2 / 3 share the intelligence FilterSet and widget schemas (different rows, same predicate fields). Type 8 has its own schemas (disjoint fields, different aggregators, different data plane — Postgres against `v_adspot_brand_profiles` rather than Elasticsearch).
44
+
45
+ ## When to invoke
46
+
47
+ **Invoke when** the user has been exploring data in the current session (running `tl db pg|fb|es` queries, structured `tl` commands, or both) and now wants to **save the result** as a report they can come back to. Trigger phrases include:
48
+
49
+ - "save this as a report" / "save the list" / "save the result"
50
+ - "turn this into a campaign" / "persist this"
51
+ - "make a report from what you found"
52
+ - "I want to come back to this" / "set up a report for these"
53
+
54
+ The entity being saved must be one of: **channels**, **brands**, **videos / uploads / articles**, or **sponsorships / deals**.
55
+
56
+ **Skip when**:
57
+
58
+ - The user wants to **add to an existing report** (`"add these channels to report 1234"`) → hand off to `tl-import`.
59
+ - The user only wants the data **shown / counted / analysed in chat** without saving → stay in `tl`; don't invoke this skill.
60
+ - The user wants to build a report **from scratch** with no prior session exploration to capture — that's a different shape of request (the user has a goal, not a result set). Run the appropriate `tl db pg|fb|es` queries to produce a result set first; then this skill takes over for the save.
61
+
62
+ ## Step 1 — Detect the report type
63
+
64
+ Match the session's primary entity to one of four report types:
65
+
66
+ | Session entity | Report type | `report_type` code |
67
+ | --- | --- | --- |
68
+ | Channels | CHANNELS | `3` |
69
+ | Brands | BRANDS | `2` |
70
+ | Videos / uploads / articles | CONTENT | `1` |
71
+ | Sponsorships / deals / adlinks | SPONSORSHIPS | `8` |
72
+
73
+ If the session joined entities (e.g. channels with their recent sponsorships), pick the **one the user actually wants to save** and ask if unclear. The other side becomes either a column or a filter, not the report subject.
74
+
75
+ ## Step 2 — Choose the path: list-style or filter-style?
76
+
77
+ This branch determines everything downstream. **Style is decided by intent, not entity** — both styles work for all four report types.
78
+
79
+ | Style | Populates | Re-evaluates? | When it's the right answer |
80
+ | --- | --- | --- | --- |
81
+ | **List-style** | M2M field (`channels` / `brands` / `articles` / `sponsorships`) | No — frozen list | Curated set, manual review, custom-SQL filters that don't map to FilterSet fields |
82
+ | **Filter-style** | Predicate fields (`keywords`, `reach_from`, dates, demographics, etc.) | Yes — every run | Criteria-driven discovery the user wants to keep refreshing |
83
+
84
+ ### Pick without asking when intent is clear
85
+
86
+ Pick **list-style** when:
87
+
88
+ - The session used custom-SQL joins, multi-source aggregation, or filter logic that doesn't map to any FilterSet field — the honest move is to snapshot the IDs.
89
+ - The user said *"snapshot"*, *"freeze"*, *"this exact list"*, *"don't re-evaluate"*, *"the ones we picked"*, *"these N channels"*.
90
+ - The session pulled IDs through a manual review pass (user accepted/rejected candidates one by one).
91
+
92
+ Pick **filter-style** when:
93
+
94
+ - The session's full filter logic maps cleanly to FilterSet fields (keyword + subscriber floor + country + date range — nothing exotic).
95
+ - The user said *"refreshable"*, *"keep updating"*, *"any new channels that match"*, *"a saved search"*, *"channels in the X niche with >Y subs, all-time"*.
96
+
97
+ ### When to ask
98
+
99
+ If both styles are plausible, ask before assembling anything:
100
+
101
+ > Two ways to save this:
102
+ >
103
+ > • **Filter-style** — I map the criteria from this session (subscriber floor, content categories, keywords, date range, etc.) into the report's filters. The report stays live: every time someone re-runs it, the filters re-evaluate against current data and the result set refreshes.
104
+ >
105
+ > • **List-style** — I snapshot the exact entity IDs we found in this session. The list is frozen — it always shows these IDs, no filter logic. Useful when you've curated the set and don't want re-evaluation.
106
+ >
107
+ > Which do you want?
108
+
109
+ ### Hybrid (rare; confirm first)
110
+
111
+ Populating both predicate and M2M fields on the same FilterSet is *legal* but rarely intended. The result set becomes "IDs in the M2M that ALSO pass the predicate," which is almost never what the user said they wanted. The one common legit case is the `exclude_*` variants (e.g., *"channels matching X, except these specific IDs"*) — both halves get populated by design. Otherwise, confirm before mixing.
112
+
113
+ Once you've picked the path, follow it linearly to the end. **Don't mix steps between paths.**
114
+
115
+ ---
116
+
117
+ # Path A — List-style
118
+
119
+ The simple path: one command, no columns / widgets / sort to assemble. The platform applies defaults; the user refines via `tl reports update` afterwards if needed.
120
+
121
+ ## A1. Collect / resolve the entity IDs
122
+
123
+ | Entity | ID shape | Exclude variant |
124
+ | --- | --- | --- |
125
+ | Channels | integer IDs | `exclude_channels` |
126
+ | Brands | integer IDs | `exclude_brands` |
127
+ | Videos / uploads / articles | composite string `<channel_id>:<youtube_id>` (matches ES `_id`) | `exclude_articles` |
128
+ | Sponsorships | integer IDs (AdLink IDs) | `exclude_sponsorships` |
129
+
130
+ **Article IDs are the composite string form**, not bare YouTube video IDs. If the session has YouTube IDs (`dQw4w9WgXcQ`) without channel prefixes, fetch `channel.id` for each via `tl db es` and rebuild the composite form before saving.
131
+
132
+ If any IDs are still names (e.g., the session resolved channel names but not their numeric IDs), resolve before writing the IDs file:
133
+
134
+ ```bash
135
+ tl brands find "NordVPN" --json | jq -r '.results[0].id' # → 21416
136
+ tl channels find "MrBeast" --json | jq -r '.results[0].id' # → 11169
137
+ ```
138
+
139
+ ## A2. Title and description
140
+
141
+ Both are mandatory; `tl reports save-list` rejects blank values with HTTP 400.
142
+
143
+ - **Title** — ≤ 60 chars. Capture the niche or intent: *"TPP fintech — May 2026 curated"*, *"Speedcubing top videos"*, *"Q1 2026 sold sponsorships — beauty brands"*.
144
+ - **Description** — 1–3 sentences. **State explicitly "List-style"** so future readers know what they're looking at (the dashboard renders list-style and filter-style reports identically).
145
+
146
+ Propose values and let the user edit. Don't ship blank strings.
147
+
148
+ ## A3. Save with `tl reports save-list`
149
+
150
+ ```bash
151
+ # Write the IDs to a temp file, one per line —
152
+ # integers for channels/brands/sponsorships;
153
+ # composite `<channel_id>:<youtube_id>` strings for articles.
154
+ IDS=$(mktemp -t tl-save-list-XXXX.txt)
155
+ printf '5607\n12345\n67890\n' > "$IDS"
156
+
157
+ tl reports save-list channels --ids-file "$IDS" \
158
+ --title "TPP fintech — May 2026 curated" \
159
+ --description "List-style: 3 channels hand-picked after the May 2026 review pass." \
160
+ --yes --json
161
+ ```
162
+
163
+ - Entity must be one of: `channels`, `brands`, `articles`, `sponsorships`.
164
+ - `--yes` skips the confirmation prompt (the user already chose the path).
165
+ - `--json` makes the response parseable so you can extract `report_url` and `campaign_id` cleanly.
166
+
167
+ The command builds the minimal config (M2M field populated, no predicate fields, platform defaults for columns/widgets/sort) and POSTs in one call. Skip directly to [Step 3 — Report back](#step-3--report-back) when done.
168
+
169
+ ## A4. List-style self-check (before posting)
170
+
171
+ 1. `--title` is non-empty and ≤ 60 chars; `--description` is 1–3 sentences and explicitly says "list-style".
172
+ 2. The entity argument matches the session's primary entity (`channels` / `brands` / `articles` / `sponsorships`).
173
+ 3. Every line in the IDs file is the right shape — integers for channels/brands/sponsorships; composite `<channel_id>:<youtube_id>` strings for articles.
174
+ 4. **No FilterSet predicate fields** to populate — list-style is the M2M IDs and nothing else. (If the user actually wants a predicate overlay, that's the hybrid case in Step 2; confirm and switch to Path B with a populated M2M.)
175
+
176
+ ---
177
+
178
+ # Path B — Filter-style
179
+
180
+ Assemble FilterSet + columns + widgets + sort, then POST via `tl reports create --config-file`.
181
+
182
+ ## B1. Map session criteria into the FilterSet
183
+
184
+ The authoritative field catalogues are in [`references/intelligence_filterset_schema.json`](references/intelligence_filterset_schema.json) (types 1 / 2 / 3) and [`references/sponsorship_filterset_schema.json`](references/sponsorship_filterset_schema.json) (type 8). **Don't invent fields.** The schema's keys are the only ones the platform accepts; unknown keys come back as a 400 with the offending field named in the error detail. Read the schema file for the field you're about to emit if you're not sure of its exact name or type.
185
+
186
+ ### Resolve names → IDs BEFORE emitting
187
+
188
+ The platform rejects names in any field that expects an integer ID. Every brand name and channel name the user mentioned in the session must be resolved to an integer ID before it lands in the FilterSet:
189
+
190
+ ```bash
191
+ tl brands find "NordVPN" --json | jq -r '.results[0].id' # → 21416
192
+ tl channels find "MrBeast" --json | jq -r '.results[0].id' # → 11169
193
+ ```
194
+
195
+ Fields that need integer IDs (not names):
196
+
197
+ - `channels`, `exclude_channels`, `brands`, `exclude_brands`, `sponsorships`, `exclude_sponsorships`, `topics`, `content_categories` (those last two take taxonomy IDs)
198
+ - `filters_json.sponsored_brand_mentions[]` — brand IDs as strings or ints depending on shape (check schema)
199
+
200
+ For type 8 specifically: a SPONSORSHIPS report with unresolved names is a hard failure — the saved report returns zero rows because the M2M write silently skipped the bad entries.
201
+
202
+ ### `keyword_operator` — AND vs OR
203
+
204
+ Default `OR` (the platform defaults to OR when `keyword_operator` is null). Set `AND` only when the user's phrasing has clear intersection semantics:
205
+
206
+ - Composite-noun phrases: `"AI cooking"`, `"Roman naval warfare"`, `"vegan keto"`.
207
+ - Explicit conjunctions: `"both X and Y"`, `"covering both X and Y"`.
208
+
209
+ When in doubt, OR. Under AND, expand the keyword set conservatively — every keyword must match, so adding broad terms shrinks the result to near zero. If the session used `tl-keyword-research --operator AND`, mirror it; the skill emits the right operator already.
210
+
211
+ ### `content_fields` per report type — narrow-first for type 3
212
+
213
+ `content_fields` is the field set the keyword search runs against. **Pick by report type, and for type 3 specifically use the narrow-first rule** — broader `content_fields` means more matches but more noise:
214
+
215
+ | `report_type` | Default `content_fields` | When to expand |
216
+ | --- | --- | --- |
217
+ | 1 (CONTENT) | `["title", "summary", "content"]` (video-level text) | Add `["transcript"]` only if the user explicitly mentioned "transcript" / "spoken-word" / "creators saying". |
218
+ | 2 (BRANDS) | `["title", "summary"]` (brand-mention surfaces) | Rarely expanded; brand reports aggregate over mentions, not deep text. |
219
+ | 3 (CHANNELS) | **`["channel.channel_name", "channel_description"]` ONLY** on the first save | Add `channel_description_ai` + `channel_topic_description` only if the narrow set obviously misses channels the session matched. The AI-summarised fields catalogue every topic a channel has *ever* touched — they answer *"has this channel ever mentioned X"* (too broad for discovery) rather than *"is this channel ABOUT X"* (what `channel_name` + `channel_description` answer). Field selection is the bigger dial; keyword pruning is the fine-tune. |
220
+ | 8 (SPONSORSHIPS) | n/a — keyword fields are inert for type 8 | Sponsorships filter by relations, not content text. Don't emit `keywords` / `keyword_operator` / `content_fields` at all for type 8. |
221
+
222
+ ### Date scoping by report type
223
+
224
+ | `report_type` | Date fields | Notes |
225
+ | --- | --- | --- |
226
+ | 1 / 2 / 3 | `start_date`, `end_date`, `days_ago`, `days_ago_to` | Apply to `publication_date` of the underlying content. Prefer `days_ago` for rolling intent ("last 90 days") and `start_date`/`end_date` for absolute ("Q1 2026"). |
227
+ | 8 | **Send axis**: `start_date`, `end_date`, `days_ago`, `days_ago_to`. **Created axis**: `createdat_from`, `createdat_to`. | **Type 8 ALWAYS needs a date scope.** Unscoped type-8 reports return the entire AdLink table — almost never what the user wanted. Pick one axis based on intent: send axis = "deals scheduled / live / sold in this window"; created axis = "deals created in this window regardless of when they ship". Mix both axes only if the user named both explicitly. |
228
+
229
+ Date upper bounds: `start_date` / `end_date` are date-typed and use `< next_day` semantics internally, not `<=`. *"Through Feb 28"* → `end_date: "2026-02-28"`; don't add a day.
230
+
231
+ ### `publish_status` (type 8 only) — numeric IDs, not strings
232
+
233
+ Sponsorship `publish_status` values are numeric IDs (0–9), **never string labels**. Don't emit `["sold"]` or `["live"]`. The canonical user-phrase → ID mapping is in [`references/report_glossary.md`](references/report_glossary.md) under "Deal-stage jargon". Quick anchors:
234
+
235
+ - `[3]` = sold
236
+ - `[0, 2, 6, 7, 8]` = pipeline / pre-sale (proposed / pending / matched / outreach / proposal-approved)
237
+ - `[3]` + `filters_json.ad_publish_status: "0"` = sold + currently live on the channel
238
+
239
+ The `publish_status` field lives inside `filters_json`, not as a top-level FilterSet field.
240
+
241
+ ### Working defaults (override only on user signal)
242
+
243
+ Unless the user explicitly contradicts them, default these on the FilterSet:
244
+
245
+ - `languages: ["en"]` — most reports are English-content scoped.
246
+ - `channel_formats: [4]` — YouTube Video. Other formats: `1`=podcast, `2`=long-form audio, `3`=other, `4`=YouTube Video (default), `5`=Shorts.
247
+
248
+ If the user said *"any language"* or *"Spanish creators"* / *"podcasts"*, override accordingly.
249
+
250
+ ### Cross-references and similar-to-channels
251
+
252
+ These compose with the rest of the FilterSet rather than replacing it:
253
+
254
+ - **`cross_references[]`** — named cross-cuts that resolve to channel ID include / exclude lists at save time. Catalog: `exclude_proposed_to_brand`, `include_proposed_to_brand`, `include_sponsored_by_mbn`. Each item is `{"type": "<name>", "brand_id": <int>, "since_days_ago": <int?>}`. Use for *"channels we haven't pitched to brand X"* / *"channels sponsored by MBN brands"*. The platform's `/reports/confirm` endpoint resolves these into `channels` / `exclude_channels` M2M arrays during the save.
255
+ - **`filters_json.similar_to_channels: [<id>, …]`** — vector-similarity expansion against seed channel IDs. Pair with **no `keywords` / `topics`** (similarity replaces topical filtering). Useful for *"channels like X and Y"* once you've resolved X/Y to IDs.
256
+
257
+ ### Complete mapping (common session criteria → FilterSet field)
258
+
259
+ | Session criterion | FilterSet field |
260
+ | --- | --- |
261
+ | Topic keywords (`"crypto"`, `"biohacking"`) | `keywords[]` + `keyword_operator` + `content_fields[]` |
262
+ | Curated topic the user named by ID or exact name | `topics: [<id>]` (still expand the topic's curated `keywords[]` per the schema's `_tl_intent_hints`) |
263
+ | Subscriber floor | `reach_from` (or `min_reach` — check schema) |
264
+ | Views / impression floor | `views_from`, `impression_from`, etc. |
265
+ | Content category (when user explicitly named a TL category) | `content_categories: [<id>]` |
266
+ | Country / language | `creator_countries: [...]`, `languages: [...]` |
267
+ | MSN-only | `msn_channels_only: true` |
268
+ | TPP-only | resolve `SELECT id FROM thoughtleaders_channel WHERE is_tl_channel = TRUE AND is_active = TRUE` and pin into `channels: [...]` (no first-class TPP boolean on FilterSet) |
269
+ | Demographics (age / gender / geo / device) | `demographic_male_share`, `demographic_usa_share`, `demographic_geo`, `demographic_device`, `demographic_age_median_value`, etc. — see schema |
270
+ | Publication date range (types 1 / 2 / 3) | `start_date`, `end_date`, or `days_ago` / `days_ago_to` |
271
+ | Sponsorship send-date range (type 8) | `start_date` / `end_date` / `days_ago` / `days_ago_to` |
272
+ | Sponsorship created-date range (type 8) | `createdat_from` / `createdat_to` |
273
+ | Deal stage (type 8) | `filters_json.publish_status: [<int>, …]` (numeric IDs) |
274
+ | Currently-live deals (type 8) | `filters_json.publish_status: [3]` + `filters_json.ad_publish_status: "0"` |
275
+ | Cross-reference ("not pitched to brand X") | `cross_references: [{"type": "exclude_proposed_to_brand", "brand_id": <int>, "since_days_ago": 365}]` |
276
+ | Look-alike channels ("similar to X and Y") | `filters_json.similar_to_channels: [<id>, …]` (drop any `keywords` / `topics` on the same FilterSet) |
277
+ | Brand-mention filter | `filters_json.sponsored_brand_mentions: [<id_or_str>, …]` |
278
+ | TL-managed only (type 8) | `tl_sponsorships_only: true` |
279
+ | Brand / channel scoping by entity | `brands: [<int>, …]`, `channels: [<int>, …]` (resolve names FIRST) |
280
+
281
+ If the session used filters that don't map to any field above, tell the user: *"I can't express [the specific predicate] as a FilterSet field — the platform doesn't surface it directly. Want to fall back to list-style for this report?"* That's the honest move; don't fudge it into `filters_json` if a typed field doesn't already exist.
282
+
283
+ ## B2. Title and description
284
+
285
+ Both are mandatory; `tl reports create` rejects with HTTP 400 if either is missing.
286
+
287
+ - **`report_title`** — ≤ 60 chars. Capture the niche or intent: *"TPP fintech channels — May 2026"*, *"Q1 2026 sold sponsorships, beauty brands"*.
288
+ - **`report_description`** — 1–3 sentences. Summarise what's in the report and how it was assembled. **State explicitly "Filter-style"** so future readers know what they're looking at (the dashboard renders list-style and filter-style reports identically).
289
+
290
+ Propose values and let the user edit. Don't ship blank strings.
291
+
292
+ ## B3. Pick columns and sort
293
+
294
+ ### Columns
295
+
296
+ Use the type's default column set; agents shouldn't compose columns from scratch when the session didn't specify any. Per-type catalogues (defaults, intent-driven additions, custom-formula guidance):
297
+
298
+ - Type 1: [`references/columns_content.md`](references/columns_content.md)
299
+ - Type 2: [`references/columns_brands.md`](references/columns_brands.md)
300
+ - Type 3: [`references/columns_channels.md`](references/columns_channels.md)
301
+ - Type 8: [`references/columns_sponsorships.md`](references/columns_sponsorships.md)
302
+
303
+ If the session showed the user specific columns (`"show reach, subscribers, country"`), include those PLUS the type's required defaults. Display names are case-sensitive and preserve spaces — `Subscribers` not `subscribers`, `Avg. Views` not `avg_views`. The platform key-matches exactly; a typo comes back as a 400 with the offending column name in the error detail.
304
+
305
+ Pick **5–10 columns** for most reports; the platform allows up to 13 if intent calls for it (the dashboard's column rail starts to feel crowded past 10).
306
+
307
+ ### Sort
308
+
309
+ `sort` is a FilterSet field referenced by string like `"-reach"` (descending) or `"publication_date"` (ascending). Pick by intent first, then fall back to the type's default:
310
+
311
+ | Intent | Sort | Applies to |
312
+ | --- | --- | --- |
313
+ | User said "top X by [metric]" | the metric, `-` prefix for desc | any type |
314
+ | User said "most recent" / "latest" | `-publication_date` (1/2/3) or `-purchase_date` (8 sold) or `-send_date` (8 pipeline) | by report type |
315
+ | Outreach intent on channels | `-publication_date_max` (channels with recent uploads bubble up) | type 3 |
316
+ | **No explicit intent** — fall back to type default | `-reach` (type 3), `-views` (type 1), `-doc_count` (type 2), `-purchase_date` for sold + `-send_date` for pipeline (type 8) | by report type |
317
+
318
+ **Two hard requirements on the sort value:**
319
+
320
+ 1. The column it references must be **present in the emitted `columns` dict**. If you sort on `-publication_date_max` but `Last Published` isn't in your columns, the report renders blank for that sort. If a mismatch exists, either add the column or pick a different sort.
321
+ 2. The direction must match what `references/sortable_columns.json` allows. Some columns are asc-only (like `Channel`), some desc-only (like `Subscribers`), some both. A direction mismatch is silently downgraded to the column's natural direction — confusing if the user expected the opposite.
322
+
323
+ ### Custom-formula columns
324
+
325
+ When the session showed a computed value the standard columns don't express (e.g. *"engagement = avg views / subscribers"*, *"profit = price − cost"*), emit a custom-formula column. The per-type `columns_<type>.md` files list suggested formulas for common intents (engagement, outreach efficiency, audience-share, profit, renewal-rate proxy).
326
+
327
+ Shape:
328
+
329
+ ```json
330
+ "columns": {
331
+ "Engagement": {"display": true, "custom": true, "formula": "{Avg. Views} / {Subscribers}", "cellType": "percent"}
332
+ }
333
+ ```
334
+
335
+ - `{Variable Name}` references another standard column by display name (case-sensitive, spaces preserved).
336
+ - `cellType` controls dashboard rendering: `regular` / `percent` / `usd`.
337
+ - Use TL-glossary terms in narration ("Net revenue" / "TL profit", not "margin" — see [`references/report_glossary.md`](references/report_glossary.md)).
338
+
339
+ Don't silently activate a custom column. Propose it in the title / description (*"with a custom Engagement column = Avg. Views / Subscribers"*) so the user knows it's there.
340
+
341
+ ## B4. Pick widgets and `histogram_bucket_size`
342
+
343
+ Widgets are the charts / metric boxes above the data table. Pick **4–6** per report. Catalogues live in:
344
+
345
+ - Types 1 / 2 / 3: [`references/intelligence_widget_schema.json`](references/intelligence_widget_schema.json)
346
+ - Type 8: [`references/sponsorship_widget_schema.json`](references/sponsorship_widget_schema.json)
347
+ - Readable index: [`references/widgets.md`](references/widgets.md)
348
+
349
+ ### Default widget sets per report type
350
+
351
+ Each `aggregator` value below is from the matching schema's catalogue. The two catalogues are **disjoint** — never use an intelligence aggregator on a type-8 report or vice versa (server fails 400).
352
+
353
+ | `report_type` | Default widgets (5, indexed 1–5) |
354
+ | --- | --- |
355
+ | 1 (CONTENT) | `total` (M), `views_sum_metric` (M), `views_avg_metric` (M), `uploads_histogram` (H), `views_sum_histogram` (H) |
356
+ | 2 (BRANDS) | `brands_count_metric` (M), `total` (M), `views_sum_metric` (M), `brands_count_histogram` (H), `views_sum_histogram` (H) |
357
+ | 3 (CHANNELS) | `channels_count_metric` (M), `channel_reach_at_scrape_metric` (M), `views_avg_metric` (M), `channel_reach_at_scrape_histogram` (H), `uploads_histogram` (H) |
358
+ | 8 (SPONSORSHIPS) | `count_sponsorships` (M), `sum_price` (M), `count_channels` (M), `count_sponsorships_over_<axis>` (H), `sum_price_over_<axis>` (H) — `<axis>` per branching rule below |
359
+
360
+ M = metrics-box (`width: 2`), H = histogram (`width: 3`). `height: 1` always. Grid is 6 columns. Widget shape:
361
+
362
+ ```json
363
+ {"aggregator": "<from catalogue>", "type": "metrics-box" | "histogram" | "histogram-category",
364
+ "index": <1-based, sequential>, "width": 2 | 3, "height": 1}
365
+ ```
366
+
367
+ Pick by intent when the session implied one — see `_tl_intent_overrides` in the schema (outreach swaps `sponsored_brands_count_metric` in for type 3; engagement focus on type 1 swaps `views_avg_metric` for `likes_sum_metric`; etc.). Don't pad to 6 if the extras don't earn their slot.
368
+
369
+ ### `histogram_bucket_size`
370
+
371
+ One top-level value per report, applies to every histogram in it:
372
+
373
+ | Date scope on the FilterSet | `histogram_bucket_size` |
374
+ | --- | --- |
375
+ | < 90 days | `"week"` |
376
+ | 90 days – 2 years | `"month"` (default) |
377
+ | Multi-year | `"year"` |
378
+
379
+ Match the FilterSet's date scope. If the FilterSet has no date scope (rare for types 1 / 2 / 3, never legal for type 8), default to `"month"`.
380
+
381
+ ### Type-8 axis branching (send_date vs purchase_date)
382
+
383
+ For type 8 only, the `_over_<axis>` histograms (`count_sponsorships_over_send_date` vs `count_sponsorships_over_purchase_date`, and same for `sum_price`) branch on deal stage:
384
+
385
+ | `filters_json.publish_status` includes | Use axis | Aggregator names |
386
+ | --- | --- | --- |
387
+ | Pre-sale (0, 2, 6, 7, 8) | `send_date` (pipeline view) | `count_sponsorships_over_send_date`, `sum_price_over_send_date` |
388
+ | Sold only (3) | `purchase_date` (won-deals view) | `count_sponsorships_over_purchase_date`, `sum_price_over_purchase_date` |
389
+ | Mix of pre-sale + sold | `send_date` (pipeline view dominates) | as pipeline |
390
+ | Performance grades (winners/losers) | `purchase_date` | as won-deals |
391
+
392
+ **Both `_over_<axis>` histograms in the same report must share the same axis.** Don't mix `send_date` and `purchase_date` within one report — the dashboard renders confusingly when the two axes disagree.
393
+
394
+ ## B5. Assemble the config
395
+
396
+ Final config shape (`Campaign` + `FilterSet` + columns + widgets):
397
+
398
+ ```json
399
+ {
400
+ "type": 2,
401
+ "report_type": 1 | 2 | 3 | 8,
402
+ "report_title": "...",
403
+ "report_description": "...",
404
+ "filterset": { ... },
405
+ "columns": { ... },
406
+ "widgets": [ ... ],
407
+ "histogram_bucket_size": "month",
408
+ "sort": "-reach"
409
+ }
410
+ ```
411
+
412
+ `type=2` (DYNAMIC) is the campaign-model contract; don't change it.
413
+
414
+ Write to a portable temp file and verify the file exists before saving:
415
+
416
+ ```bash
417
+ TMP=$(mktemp -t tl-save-report-XXXX.json)
418
+ cat > "$TMP" <<'EOF'
419
+ { ...config... }
420
+ EOF
421
+ ls -la "$TMP" # verify before save
422
+ ```
423
+
424
+ **Don't write the transport file under the user's project directory.** It's a transport, not a deliverable.
425
+
426
+ ## B6. Pre-flight validation
427
+
428
+ Before posting, validate the assembled config against the schemas. The platform's own validation will catch most errors, but a pre-flight pass catches the cheap mistakes without burning a save-side round-trip:
429
+
430
+ 1. **Required fields present**: `type`, `report_type`, `report_title`, `report_description`, `filterset`.
431
+ 2. **`report_title`** is a non-empty string ≤ 60 chars.
432
+ 3. **`report_description`** is a non-empty 1–3 sentence string that explicitly says "filter-style".
433
+ 4. **`report_type`** is `1` | `2` | `3` | `8`; `type` is `2`.
434
+ 5. **Every key in `filterset`** is a property in the matching schema (`intelligence_filterset_schema.json` for types 1/2/3, `sponsorship_filterset_schema.json` for type 8). Unknown keys → 400.
435
+ 6. **For type 8**: a date scope is populated on one of the two axes (send or created). Unscoped type-8 → silent return-all-deals, not what the user asked for.
436
+ 7. If `keywords` has > 1 entry, `keyword_operator` is set explicitly. The platform defaults to OR but explicit is clearer for the saved record.
437
+ 8. **Every entry in `channels`** / `brands` / `sponsorships` is an integer (not a name). For `articles`, every entry matches `<channel_id>:<youtube_id>`.
438
+ 9. **Every column in `columns`** is in the type's `columns_<type>.md` catalogue or is a custom-formula column with `custom: true`.
439
+ 10. **The `sort` value** references a column in the emitted `columns` dict, with a direction allowed by `references/sortable_columns.json`.
440
+ 11. **Every widget's `aggregator`** is in the matching schema (intelligence or sponsorship — they're disjoint).
441
+ 12. **`histogram_bucket_size`** matches the FilterSet's date scope (week / month / year).
442
+ 13. **For type 8** widgets: both `_over_<axis>` histograms in the same report share the same axis (send or purchase, not both).
443
+ 14. **No M2M `channels` / `brands` / `articles` / `sponsorships` populated** unless the user explicitly asked for a narrow-to-these-IDs overlay (the hybrid case from Step 2).
444
+
445
+ If any check fails, fix in the working config before writing to the transport file. Don't post a config you can predict will 400.
446
+
447
+ ## B7. Save with `tl reports create --config-file`
448
+
449
+ ```bash
450
+ tl reports create --config-file "$TMP" --yes --json
451
+ ```
452
+
453
+ - `--yes` skips the confirmation prompt (the user already chose the path).
454
+ - `--json` makes the response parseable so you can extract `report_url` and `campaign_id` cleanly.
455
+ - `--config-file` (not `--config`) sidesteps shell-quoting issues with apostrophes / dollar signs / backticks in titles or keywords.
456
+
457
+ ---
458
+
459
+ ## Step 3 — Report back
460
+
461
+ Both paths return the same envelope on success:
462
+
463
+ ```json
464
+ {
465
+ "results": [{
466
+ "campaign_id": 12345,
467
+ "report_url": "/dashboard/reports/12345/",
468
+ "unresolved_names": []
469
+ }],
470
+ "usage": { "credits_charged": ..., "balance_remaining": ... }
471
+ }
472
+ ```
473
+
474
+ Echo the saved URL + ID, plus a follow-up offer for refinement:
475
+
476
+ > Saved as report **12345**: https://app.thoughtleaders.io/dashboard/reports/12345/
477
+ >
478
+ > Want to refine the columns, widgets, title, or description? Tell me what to change and I'll run `tl reports update`.
479
+
480
+ The follow-up offer matters because **FilterSet changes (keywords, demographics, M2M lists) can't be patched in place** via `tl reports update` — they require saving a new variant. Surface that limitation only if the user actually asks to change FilterSet fields.
481
+
482
+ ### On failure
483
+
484
+ If the command exits non-zero, the CLI prints the error on stderr (shape: `Error (NNN): <detail>` for most codes; specific lines for 401/402/403). **Surface the error verbatim** — do NOT silently report success.
485
+
486
+ Map the visible code + detail to the likely cause:
487
+
488
+ - **`Error (400): …missing… title|description…`** → you skipped A2 / B2; the title or description was empty. Go back and fill it in.
489
+ - **`Error (400): …filterset…`** (Path B only) → the config has a key the platform doesn't recognise in `filterset`. Re-check against the matching schema (`intelligence_filterset_schema.json` or `sponsorship_filterset_schema.json`) and remove invented fields.
490
+ - **`Error (400): …columns…`** (Path B only) → the config references a column display-name the platform doesn't recognise. Re-check against the type's `columns_<type>.md` catalogue; display names are case-sensitive and preserve spaces.
491
+ - **`Error (400): …`** (any other detail) → read the detail; it usually names the offending field or value. Fix and retry.
492
+ - **`Access denied: …`** (HTTP 403) → the user lacks the plan required for this report type (Intelligence for 1/2/3 in some orgs; confirm with `tl whoami`).
493
+ - **`Insufficient credits.`** (HTTP 402) → the org is out of credits; tell the user to top up.
494
+
495
+ The above maps the visible CLI output to the underlying cause — match on a substring of the detail rather than the exact string, since the platform's wording may evolve.
496
+
497
+ ## What this skill does NOT do
498
+
499
+ - **No discovery-side work** — no keyword research, no live-data sample validation, no result-set re-evaluation. The session already produced the data; re-running discovery would be wasted effort. Name resolution (`tl brands find` / `tl channels find` to turn names into IDs before they land in the FilterSet) is the one exception — it's required by the FilterSet schema, not discovery. If the user comes in with no prior session, run the relevant `tl db pg|fb|es` queries first to produce a result set, then invoke this skill on the result.
500
+ - **No editing of existing reports.** If the user wants to refine an already-saved report's columns, widgets, title, or description, run `tl reports update <id>` directly. For FilterSet refinements, the platform requires saving a new variant.
501
+ - **No bulk-importing into an existing report.** That's `tl-import`'s role. Save-report only creates new reports.