coolhand 0.9.0 → 0.10.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48857d802183f91a163c82c3e1dd89ce34ac7a8f1f3d1079c5ae6d5d594ad300
4
- data.tar.gz: a852465d5b9dced73580eff8c81f45093a97c48c79a66579cff27c2dbedc7cbe
3
+ metadata.gz: 301fa9e603334f3c6b5349755239cdf06418b9b78f1fae3de44abc352b8f1828
4
+ data.tar.gz: 0b687769a058ff206f50deaf46ad1e28909e7f4e4229d217ad5b36b7350ff9ce
5
5
  SHA512:
6
- metadata.gz: 0d4e6bc5e9ef29365afaba5b9c4a4edbadb552f77268adfe27ac6276f23cd44e4c75fe6df85b1ddef8bdf25d99d88204a46a127dcce7c3a2183edc09e5019508
7
- data.tar.gz: ee192c55d280b47abc3f6a9af7f2ddd48d4966493955cee5614557feeea2d87d71a28c16f488fee4dc9fddd916b88fc9ee98621e058e3ad0de80431fc6bd871c
6
+ metadata.gz: 9a4653638e2362853b90baf55bc1df97f73ee1144357524c61be21798b58fcffe383d9bbde1b9cb33ac7c9d07064377eff681667be8ed2a341103cdb06ab42ff
7
+ data.tar.gz: 464a1d38d383ebf98f3f42b9f60fccfe65d13fcc3a1ea45d5df0c7fc1f26aad721a9b2650f1c41277a09dbf5a92a24f32f2223f961c9940de592fecf54d1057d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-10-21
11
+
12
+ ### Added
13
+ - **Read the Coolhand help center** ([#164](https://github.com/Coolhand-Labs/coolhand-ruby/pull/164)) — `Coolhand.public_content_service` wraps the read-only help center endpoints: `search_help_articles(query:, category:, page:, per_page:)` returns a `Coolhand::HelpArticleSearchResult` (`articles`, `pagination`), `get_help_article(slug)` returns one article with its markdown `body` and `toc`, and `list_help_categories` lists the sections. Unlike every other read service it needs **no API key**: with none configured no `X-API-Key` header is sent (anonymous callers are limited to 30 requests per minute per IP), and a configured `config.api_key` is sent and lifts the limit. Like the other read methods, these raise `Coolhand::Error` / `Coolhand::HttpError` rather than logging and returning `nil`. See [Reading the Help Center](docs/help-center.md).
14
+
15
+ ### Security
16
+ - `LoggerService#forward_webhook` now redacts credentials (userinfo and sensitive query parameters such as `token=`) in a caller-supplied `url:` before logging it, the same way intercepted request URLs are. **Migration note:** if you pass a `url:` containing such values, they now appear as `[REDACTED]` in Coolhand.
17
+
10
18
  ## [0.9.0] - 2026-10-18
11
19
 
12
20
  ### Added
data/README.md CHANGED
@@ -118,6 +118,24 @@ Coolhand.log_service.search_logs(order: 'cost_desc', min_cost: 1.0, per: 10, **w
118
118
 
119
119
  See [Reading Workloads →](docs/workload-search.md) and [Reading Logs →](docs/log-search.md).
120
120
 
121
+ ## Reading the Help Center
122
+
123
+ `Coolhand.public_content_service` reads the Coolhand help center (the same pages as coolhandlabs.com/help and /updates) with **no API key required**:
124
+
125
+ ```ruby
126
+ require 'coolhand'
127
+
128
+ help_center = Coolhand.public_content_service
129
+
130
+ result = help_center.search_help_articles(query: 'match rate', per_page: 5)
131
+ article = help_center.get_help_article(result.articles.first[:slug]) # full markdown :body
132
+ categories = help_center.list_help_categories
133
+ ```
134
+
135
+ Anonymous callers are limited to 30 requests per minute per IP. If `config.api_key` is set it is sent and lifts the limit; a key the server rejects raises a 401 `Coolhand::HttpError`. These are read methods, so they **raise** where the write methods log and return `nil`.
136
+
137
+ See [Reading the Help Center →](docs/help-center.md) for parameters, the category filter and errors.
138
+
121
139
  ## Linking Feedback to an Optimization
122
140
 
123
141
  Attach feedback to an optimization as evidence, one at a time or in bulk. These require your **private** API key and **raise** `Coolhand::Error` on failure (`Coolhand::HttpError` for non-2xx responses).
@@ -488,7 +506,7 @@ The monitor handles errors gracefully:
488
506
  - Failed API logging attempts are logged to console but don't interrupt your application
489
507
  - Invalid API keys will be reported but won't crash your app
490
508
  - Network issues are handled with appropriate error messages
491
- - The read methods (`search_templates`, `get_template`, `search_workloads`, `search_logs`, `get_log`), the feedback-link methods and the `upsert` methods are the exception — they raise `Coolhand::Error` (`Coolhand::HttpError` for non-2xx responses); see [Reading Templates →](docs/template-search.md), [Reading Workloads →](docs/workload-search.md), [Reading Logs →](docs/log-search.md), [Linking Feedback →](docs/feedback-links.md) and [Declared Workloads →](docs/declared_workloads.md)
509
+ - The read methods (`search_templates`, `get_template`, `search_workloads`, `search_logs`, `get_log`, `search_help_articles`, `get_help_article`, `list_help_categories`), the feedback-link methods and the `upsert` methods are the exception — they raise `Coolhand::Error` (`Coolhand::HttpError` for non-2xx responses); see [Reading Templates →](docs/template-search.md), [Reading Workloads →](docs/workload-search.md), [Reading Logs →](docs/log-search.md), [Linking Feedback →](docs/feedback-links.md) and [Declared Workloads →](docs/declared_workloads.md)
492
510
 
493
511
  ## Documentation
494
512
 
@@ -497,6 +515,7 @@ The monitor handles errors gracefully:
497
515
  - **[Reading Templates](docs/template-search.md)** — Search LLM request templates and fetch a single one, prompt patterns included, using the private API key
498
516
  - **[Reading Workloads](docs/workload-search.md)** — List workloads with optional cost and performance metrics over a rolling or explicit time window
499
517
  - **[Reading Logs](docs/log-search.md)** — Search logs with per-log cost, filter and sort by cost, and fetch one log with its cost breakdown
518
+ - **[Reading the Help Center](docs/help-center.md)** — Search and read Coolhand's help center articles and list its sections, with no API key required
500
519
  - **[Linking Feedback](docs/feedback-links.md)** — Link feedback to an optimization as evidence, singly or in bulk, using the private API key
501
520
  - **[Declared Workloads](docs/declared_workloads.md)** — Declare the workload and template of captured LLM calls with `Coolhand.with_context`, and create or rename them with `upsert`
502
521
  - **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
@@ -0,0 +1,120 @@
1
+ # Reading the Help Center
2
+
3
+ `Coolhand::PublicContentService` reads Coolhand's help center: the articles served at
4
+ `coolhandlabs.com/help/:slug` and the posts at `/updates/:slug`. "Help center" is the user-facing name;
5
+ on the wire the resource is `public_content` (`GET /api/v2/public_content`, `/:slug` and `/categories`).
6
+ It is read-only. Creating, updating and archiving articles is Coolhand-internal and not wrapped.
7
+
8
+ **Auth:** none required. Unlike every other service, no API key is needed:
9
+
10
+ ```ruby
11
+ require "coolhand"
12
+
13
+ help_center = Coolhand.public_content_service # works with Coolhand.configure never called
14
+ ```
15
+
16
+ - No key configured: no `X-API-Key` header is sent, and the calls work. Anonymous callers are limited to
17
+ **30 requests per minute per IP**; beyond that the server answers `429`.
18
+ - A configured `config.api_key` is sent as `X-API-Key` and callers with a valid key are not rate limited.
19
+ This gem reads the key from configuration only, not from the environment.
20
+ - A key the server does not recognise is a `401` (`{"error":"Invalid API key."}`) even though the endpoint is
21
+ public. It is raised, not retried anonymously.
22
+
23
+ ## `search_help_articles`
24
+
25
+ ```ruby
26
+ result = help_center.search_help_articles(query: "match rate", category: "feedback-and-prompts", per_page: 10)
27
+
28
+ result.articles.each { |a| puts "#{a[:title]}: #{a[:search_headline]}" }
29
+ result.pagination.total_count
30
+ ```
31
+
32
+ ### Keywords
33
+
34
+ All optional.
35
+
36
+ | keyword | type | notes |
37
+ |---|---|---|
38
+ | `query` | String | Relevance search (Postgres full text, the same as the website's). Supports quoted phrases, `-exclusions` and `or`. Omit for a full listing. A blank string counts as omitted. |
39
+ | `category` | String | Restrict to one section by slug (see `list_help_categories`). A blank string counts as omitted. |
40
+ | `page` | Integer | 1-based. |
41
+ | `per_page` | Integer | Default 25, max 100, both enforced server-side. |
42
+
43
+ The endpoint has no `category` parameter. `category` is sent in its structured form instead, which also
44
+ moves the relevance search to a different key; the plain `q=...` string and the `q[...]` form cannot be
45
+ mixed:
46
+
47
+ | `query` | `category` | Sent |
48
+ |---|---|---|
49
+ | set | unset | `q=<query>` |
50
+ | unset | set | `q[help_category_slug_eq]=<category>` |
51
+ | set | set | `q[help_category_slug_eq]=<category>&q[full_text]=<query>` |
52
+
53
+ ### Return value
54
+
55
+ A `Coolhand::HelpArticleSearchResult` with `articles` (an Array of Symbol-keyed Hashes) and `pagination`.
56
+ Each article is a summary; the markdown `:body` is omitted from lists:
57
+
58
+ ```ruby
59
+ {
60
+ slug: "what-is-feedback-match-rate",
61
+ title: "What is feedback match rate?",
62
+ answer: "Match rate is the share of ...", # String or nil
63
+ article_kind: "concept", # reference | concept | how_to | troubleshooting | faq_answer | release_note
64
+ tags: ["feedback", "matching"],
65
+ category: "feedback-and-prompts", # section slug
66
+ url: "https://coolhandlabs.com/help/what-is-feedback-match-rate",
67
+ markdown_url: "https://coolhandlabs.com/help/what-is-feedback-match-rate.md",
68
+ reading_time_minutes: 10, # Integer or nil
69
+ search_headline: "<mark>Match</mark> rate ...", # nil on a plain listing
70
+ published_at: "2026-08-30T13:57:52Z", # String or nil
71
+ updated_content_at: "2026-08-30T13:57:52Z" # String or nil
72
+ }
73
+ ```
74
+
75
+ `:search_headline` is the matching passage with `<mark>` tags around the matched terms, so you can see
76
+ why something matched without fetching the body.
77
+
78
+ `pagination` is a `Coolhand::Pagination`, the same as the other read methods, built from the `X-Page`,
79
+ `X-Per-Page`, `X-Total-Count` and `X-Total-Pages` response headers (`current_page`, `per_page`,
80
+ `total_count`, `total_pages`, `has_next_page`, `has_prev_page`).
81
+
82
+ ## `get_help_article(slug)`
83
+
84
+ ```ruby
85
+ article = help_center.get_help_article("what-is-feedback-match-rate")
86
+ article[:body] # full markdown
87
+ article[:toc] # [{ id:, text:, level:, children: }]
88
+ ```
89
+
90
+ Returns a Hash: the summary fields minus `:search_headline`, plus `:body` (full markdown), `:noindex`
91
+ (Boolean) and `:toc` (the heading structure, h3s nested under their h2). The slug is escaped as a single
92
+ path segment. Raises `Coolhand::Error` before any request if `slug` is blank or a bare `.`/`..`. An unknown
93
+ or unpublished slug raises `Coolhand::HttpError` with `status` 404.
94
+
95
+ ## `list_help_categories`
96
+
97
+ ```ruby
98
+ help_center.list_help_categories
99
+ # [{ slug: "providers", name: "Provider guides", description: "...", url_prefix: "/help", allowed_tags: nil }, ...]
100
+ ```
101
+
102
+ Returns an Array of Hashes: `:slug` (the `category` filter above), `:name`, `:description`, `:url_prefix`
103
+ (`/help` or `/updates`) and `:allowed_tags` (the only tags the section accepts; `nil` means any tag).
104
+
105
+ ## Errors
106
+
107
+ These are read methods, so they raise where the logging writes log and return `nil`. A network failure or
108
+ a non-JSON body raises `Coolhand::Error`. A non-2xx response raises `Coolhand::HttpError`, whose `status`
109
+ holds the HTTP status code and `body` the (truncated) response body: `401` for an invalid key, `404` for an
110
+ unknown slug, `429` when the anonymous rate limit is exceeded.
111
+
112
+ ## Verifying against a live server
113
+
114
+ `spec/live/coolhand_public_content_live_spec.rb` runs against a real server with no key:
115
+
116
+ ```bash
117
+ COOLHAND_SPEC_LIVE_BASE_URL=http://127.0.0.1:3000/api bundle exec rspec spec/live/coolhand_public_content_live_spec.rb
118
+ ```
119
+
120
+ It makes about nine anonymous requests, so running it repeatedly within a minute can hit the `429`.
@@ -24,7 +24,7 @@ module Coolhand
24
24
  id: options[:id] || SecureRandom.uuid,
25
25
  timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
26
26
  method: options[:method] || "POST",
27
- url: options[:url] || build_webhook_url(source, event_type),
27
+ url: BaseInterceptor.sanitize_url(options[:url] || build_webhook_url(source, event_type)),
28
28
  headers: sanitize_headers(headers),
29
29
  request_body: clean_webhook_body(webhook_body, source),
30
30
  response_body: options[:response_body],
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+ require_relative "pagination"
5
+ require_relative "read_query"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ HelpArticleSearchResult = Struct.new(:articles, :pagination, keyword_init: true)
10
+
11
+ # Read-only access to the Coolhand help center: the `public_content` endpoints (`GET /api/v2/public_content`,
12
+ # `/:slug` and `/categories`), the same pages served at `/help/:slug` and `/updates/:slug`. Create, update and
13
+ # archive are Coolhand-internal and not wrapped.
14
+ #
15
+ # Unlike every other service this one needs no API key. A configured `api_key` is sent as `X-API-Key` and lifts
16
+ # the anonymous limit of 30 requests per minute per IP (429 beyond); with none, no key header is sent. A key the
17
+ # server rejects raises a 401 {Coolhand::HttpError}; it is not retried anonymously. See docs/help-center.md.
18
+ class PublicContentService < ApiService
19
+ include ReadQuery
20
+
21
+ ERROR_NOUN = "Help center"
22
+ BLANK_SLUG_MESSAGE = "get_help_article: slug must be a non-empty string"
23
+
24
+ def initialize
25
+ super("v2/public_content")
26
+ end
27
+
28
+ # Relevance search with `query`, a full listing without. `category` filters to one section slug; the
29
+ # endpoint has no such parameter, so it is sent as `q[help_category_slug_eq]` and `query` moves to
30
+ # `q[full_text]` (the plain `q` string and the `q[...]` form cannot be mixed). A blank `query` or
31
+ # `category` counts as omitted. Returns a {HelpArticleSearchResult}; raises {Coolhand::HttpError} on a
32
+ # non-2xx response.
33
+ def search_help_articles(query: nil, category: nil, page: nil, per_page: nil)
34
+ params = { **search_params(query, category), page: page, per_page: per_page }.compact
35
+
36
+ fetch_list(params, HelpArticleSearchResult, :articles, page: page, per: per_page)
37
+ end
38
+
39
+ # One published article with its markdown `body`, `noindex` and `toc`. Raises {Coolhand::Error} before any
40
+ # request for a blank slug, and {Coolhand::HttpError} (404) for an unknown or unpublished one.
41
+ def get_help_article(slug)
42
+ get_json(resource_url(slug, BLANK_SLUG_MESSAGE), ERROR_NOUN)
43
+ end
44
+
45
+ # Every section an article can be filed under, with its `url_prefix` and `allowed_tags`.
46
+ def list_help_categories
47
+ get_json(URI.parse("#{api_endpoint}/categories"), ERROR_NOUN)
48
+ end
49
+
50
+ protected
51
+
52
+ def api_key_required?
53
+ false
54
+ end
55
+
56
+ private
57
+
58
+ def search_params(query, category)
59
+ query = nil if omitted?(query)
60
+ return { q: query } if omitted?(category)
61
+
62
+ { "q[help_category_slug_eq]": category, "q[full_text]": query }
63
+ end
64
+
65
+ def omitted?(value)
66
+ value.nil? || value.to_s.strip.empty?
67
+ end
68
+ end
69
+ end
@@ -29,7 +29,9 @@ module Coolhand
29
29
 
30
30
  # Also returns the response, for endpoints that carry pagination in headers rather than the body.
31
31
  def get_json_with_headers(url, noun)
32
- raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
32
+ if api_key_required? && !Coolhand.required_field?(api_key)
33
+ raise Error, "#{noun} request failed: an API key is required"
34
+ end
33
35
 
34
36
  response = perform_get(url, noun)
35
37
 
@@ -38,6 +40,11 @@ module Coolhand
38
40
  [parse_json_body(response.body, noun), response]
39
41
  end
40
42
 
43
+ # Overridden by services over public endpoints, which work anonymously and send the key only if set.
44
+ def api_key_required?
45
+ true
46
+ end
47
+
41
48
  private
42
49
 
43
50
  def perform_get(url, noun)
@@ -45,7 +52,9 @@ module Coolhand
45
52
  http = build_http(uri)
46
53
 
47
54
  request = Net::HTTP::Get.new(uri.request_uri)
48
- apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
55
+ headers = { "Accept" => "application/json" }
56
+ headers["X-API-Key"] = api_key if Coolhand.required_field?(api_key)
57
+ apply_headers(request, headers)
49
58
 
50
59
  # Net::HTTP does not follow redirects, so a 3xx raises rather than replaying the API key at
51
60
  # an unapproved host. without_capture is the same recursion guard send_request uses.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/coolhand.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "coolhand/feedback_service"
22
22
  require_relative "coolhand/template_service"
23
23
  require_relative "coolhand/optimization_feedback_link_service"
24
24
  require_relative "coolhand/workload_service"
25
+ require_relative "coolhand/public_content_service"
25
26
  require_relative "coolhand/log_service"
26
27
  require_relative "coolhand/webhook_interceptor"
27
28
  # Only when Rails is already loaded; the gem has no Rails dependency.
@@ -147,6 +148,12 @@ module Coolhand
147
148
  LogService.new
148
149
  end
149
150
 
151
+ # Creates a new PublicContentService, for reading the Coolhand help center. Needs no API key; a
152
+ # configured one is sent and lifts the anonymous rate limit.
153
+ def public_content_service
154
+ PublicContentService.new
155
+ end
156
+
150
157
  # Creates a new OptimizationFeedbackLinkService instance, for linking feedback to an
151
158
  # optimization as evidence. Needs the private API key.
152
159
  def optimization_feedback_link_service
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-10-18 00:00:00.000000000 Z
12
+ date: 2026-10-21 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -51,6 +51,7 @@ files:
51
51
  - docs/elevenlabs.md
52
52
  - docs/feedback-links.md
53
53
  - docs/feedback.md
54
+ - docs/help-center.md
54
55
  - docs/log-search.md
55
56
  - docs/openai.md
56
57
  - docs/template-search.md
@@ -77,6 +78,7 @@ files:
77
78
  - lib/coolhand/open_ai/webhook_validator.rb
78
79
  - lib/coolhand/optimization_feedback_link_service.rb
79
80
  - lib/coolhand/pagination.rb
81
+ - lib/coolhand/public_content_service.rb
80
82
  - lib/coolhand/rails_auto_declare.rb
81
83
  - lib/coolhand/railtie.rb
82
84
  - lib/coolhand/read_query.rb