coolhand 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48857d802183f91a163c82c3e1dd89ce34ac7a8f1f3d1079c5ae6d5d594ad300
4
- data.tar.gz: a852465d5b9dced73580eff8c81f45093a97c48c79a66579cff27c2dbedc7cbe
3
+ metadata.gz: 822e11c64b550d4fda8ddd5488b4fe69a85d31dc36b52e71f2949823000b663a
4
+ data.tar.gz: 9edf58347f126a0a3211a426999b78f74a08bd48420a3aef4403798c5d74bb66
5
5
  SHA512:
6
- metadata.gz: 0d4e6bc5e9ef29365afaba5b9c4a4edbadb552f77268adfe27ac6276f23cd44e4c75fe6df85b1ddef8bdf25d99d88204a46a127dcce7c3a2183edc09e5019508
7
- data.tar.gz: ee192c55d280b47abc3f6a9af7f2ddd48d4966493955cee5614557feeea2d87d71a28c16f488fee4dc9fddd916b88fc9ee98621e058e3ad0de80431fc6bd871c
6
+ metadata.gz: e4e6d24e991a4fe5886acd1f525b62d8af122b06f048e637b0918086575f063a10a3fe3d4e8d69147c2ca37635993673787c39f8cff4a7b41897d8b0aaef9c1e
7
+ data.tar.gz: 93515c9f280274c9fecbf6152b135c069efc6246b15b8a73e91181a756c0c8d73e2ef26713763665b363a74340babb1dabe685e62ef36167c1ee1443018baa1e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.1] - 2026-10-26
11
+
12
+ ### Added
13
+ - **Amazon Bedrock FIPS, dual-stack and China-partition endpoints are now captured** ([#168](https://github.com/Coolhand-Labs/coolhand-ruby/pull/168)). The default intercept list held only `bedrock-runtime.*.amazonaws.com`, so calls to the FIPS hosts (`bedrock-runtime-fips.<region>.amazonaws.com`, the common case for GovCloud callers), the dual-stack IPv6 hosts (`bedrock-runtime.<region>.api.aws` and the FIPS variant) and the China partition (`bedrock-runtime.<region>.amazonaws.com.cn`) were silently not captured. A wildcard entry is an exact host match with no implicit subdomains, so each variant needs its own entry.
14
+ - Credentials in request and response bodies are redacted ([#178](https://github.com/Coolhand-Labs/coolhand-ruby/pull/178)). String values under `authorization`, `authorization_token`, `access_token`, `refresh_token`, `id_token`, `api_key`, `client_secret`, `private_key`, `secret_key` and `password` (any depth, matched by exact name) become `[REDACTED]`, as do the string values in the `headers` of an MCP tool and the ephemeral key returned by a Realtime `client_secret` response. `response_body` is now redacted too, not just `request_body`. **Migration note:** logged bodies that carry such fields now show `[REDACTED]` in Coolhand. See [Body Redaction](docs/configuration.md).
15
+ - `max_captured_body_bytes` now also caps **response** bodies ([#177](https://github.com/Coolhand-Labs/coolhand-ruby/pull/177)). A response larger than the cap (default 1 MB) is logged as a `{"_coolhand_capture_skipped": "body_too_large", ...}` marker instead of in full; your app always receives the complete response. A `body_stream` request that declares a `Content-Length` over the cap is no longer read for capture, and a non-Integer or negative cap falls back to the default instead of raising. See [Body Capture Limits](docs/configuration.md).
16
+ - `WebhookIdStore#release!(id)` and `WebhookValidator#release_claim!` ([#176](https://github.com/Coolhand-Labs/coolhand-ruby/pull/176)). A custom `webhook_id_store` may optionally implement `release!(id)`; stores that only implement `claim!` keep working unchanged.
17
+ - Batch results logged by `OpenAi::BatchResultProcessor` now carry `source_api: "openai"` and the request's `model` ([#171](https://github.com/Coolhand-Labs/coolhand-ruby/pull/171)).
18
+
19
+ - Redaction now also covers streamed and raw string bodies ([#197](https://github.com/Coolhand-Labs/coolhand-ruby/pull/197)). The JSON object or array on each `data:` line of a server-sent-event response, JSON text passed as a string, and the `request_body` / `response_body` given to `Coolhand.logger_service.forward_webhook` are parsed, redacted and re-serialized (compact output, a repeated key keeps its last value, numbers go to double precision). `[DONE]`, event and comment lines and plain text are left as received; a payload that cannot be redacted is replaced by a `{"_coolhand_capture_skipped": "redaction_failed"}` marker. **Migration note:** logged SSE and webhook bodies that carry credential-named fields now show `[REDACTED]`. See [Body Redaction](docs/configuration.md).
20
+ - Credentials in URL-valued body fields and more credential keys are redacted ([#193](https://github.com/Coolhand-Labs/coolhand-ruby/pull/193)). String values under `url`, `server_url`, `image_url`, `file_url` and `endpoint` that are a single absolute URL go through the URL sanitizer, and string values under `x-api-key`, `secret`, `aws_secret_access_key`, `session_token` and `bearer_token` are redacted. A secret in a URL path cannot be detected and is sent as is.
21
+ - Batch item bodies and chunked request bodies respect `max_captured_body_bytes` ([#198](https://github.com/Coolhand-Labs/coolhand-ruby/pull/198)). The OpenAI and Vertex batch processors cap each item's request and response body with the `body_too_large` marker and read OpenAI result files line by line, skipping malformed lines instead of dropping the file. A chunked `body_stream` request is no longer read ahead of the send: a read-only tap copies the bytes Net::HTTP reads, and the log entry is built after the send. New markers: `body_stream_incomplete`, `unsupported_body_stream`, `nesting_too_deep`, `unserializable_body`.
22
+ ### Changed
23
+ - **Breaking:** `Coolhand.public_content_service` is now `Coolhand.help_center_service`, and `Coolhand::PublicContentService` is now `Coolhand::HelpCenterService` ([#167](https://github.com/Coolhand-Labs/coolhand-ruby/pull/167)). The old names are removed with no deprecated alias. **Migration note:** anyone calling `public_content_service` on v0.10.0 must switch to `help_center_service`; method signatures (`search_help_articles`, `get_help_article`, `list_help_categories`) and the requests sent are unchanged.
24
+ - OpenAI webhook ids are remembered for twice `webhook_replay_tolerance_seconds` ([#176](https://github.com/Coolhand-Labs/coolhand-ruby/pull/176)), and the claim is released when your `process_event` raises, so OpenAI's retry of the same `webhook-id` is processed instead of being rejected as a replay. The response is still 401 on failure. **Migration note:** a custom `webhook_id_store` now receives `2 * webhook_replay_tolerance_seconds` as `ttl_seconds`.
25
+ - Rate-limit headers (`x-ratelimit-remaining-tokens`, `anthropic-ratelimit-input-tokens-reset`, ...) are no longer redacted in captured logs ([#174](https://github.com/Coolhand-Labs/coolhand-ruby/pull/174)). The allow-list is closed-vocabulary: `x-ratelimit-api-key` and similar are still redacted.
26
+ - Relative batch input URLs (`/v1/chat/completions`) are logged as `https://api.openai.com/v1/chat/completions` ([#171](https://github.com/Coolhand-Labs/coolhand-ruby/pull/171)).
27
+ - Docs: `docs/anthropic.md` now describes the unified `NetHttpInterceptor` instead of the removed `FaradayInterceptor` ([#169](https://github.com/Coolhand-Labs/coolhand-ruby/pull/169)). CI: `ruby/setup-ruby` bumped to 1.335.0 for the Ruby 3.3.13 / 3.4.12 / 4.0.9 regexp security fixes ([#175](https://github.com/Coolhand-Labs/coolhand-ruby/pull/175)). Test coverage gaps closed (webhook secret handling, sanitizer negatives, batch wire payloads) ([#172](https://github.com/Coolhand-Labs/coolhand-ruby/pull/172)).
28
+
29
+ - Request paths are normalized before `exclude_api_patterns` and path-anchored `intercept_addresses` matching ([#195](https://github.com/Coolhand-Labs/coolhand-ruby/pull/195)): repeated `/` are collapsed, `.` and `..` segments resolved and unreserved percent-escapes decoded, so `/openai//files` or `/openai/%66iles` can no longer slip past an exclusion. An exclusion matches if the raw or the normalized path matches. Matching stays case-sensitive. The URL that is sent and logged is unchanged. **Migration note:** a custom anchor or pattern containing `//`, `..` or an unreserved escape no longer matches the normalized form.
30
+ - URL fragments are dropped by the URL sanitizer ([#191](https://github.com/Coolhand-Labs/coolhand-ruby/pull/191)), so `#access_token=...` no longer reaches Coolhand. **Migration note:** logged and forwarded URLs no longer include a `#fragment`.
31
+ ### Security
32
+ - **`OpenAi::WebhookValidator` no longer accepts unsigned webhooks in `development` and `test` when a secret is set** ([#170](https://github.com/Coolhand-Labs/coolhand-ruby/pull/170)). Previously a request missing the `webhook-signature` / `webhook-timestamp` / `webhook-id` headers passed in those environments, so an app running with `RAILS_ENV=development` on a reachable host accepted forged `batch.completed` events. **Migration note:** unsigned test payloads posted to a local app that has a secret configured now get a 401. Sign the payload (snippet in [docs/openai.md](docs/openai.md#local-development-and-testing)), leave the secret unset in development, or replay a real delivery. With no secret configured, development and test still skip verification with a warning.
33
+ - `OpenAi::BatchResultProcessor` validates the batch id and the input/output file ids before using them in OpenAI API calls made with your `OPENAI_API_KEY` ([#171](https://github.com/Coolhand-Labs/coolhand-ruby/pull/171)), and skips a batch with missing or invalid `in_progress_at` / `completed_at` instead of logging epoch-0 timestamps. **Migration note:** events whose ids don't match `batch_...` / `file-...` are now ignored with an error in `Rails.logger`.
34
+ - `LoggerService#forward_webhook` no longer forwards the whole Rack env ([#173](https://github.com/Coolhand-Labs/coolhand-ruby/pull/173)). `headers: request.headers` iterates every Rack env entry, so session and request data (`rack.session`, `action_dispatch.*`) and CGI variables such as `QUERY_STRING` were shipped to Coolhand. Dotted keys and CGI/server variables are now skipped, while `HTTP_*`, `CONTENT_TYPE` and `CONTENT_LENGTH` are kept, and a caller-supplied `url:` is sanitized. **Migration note:** forwarded headers no longer include `remote-addr`, `request-method`, `query-string`, `path-info` and similar.
35
+ - URL and header redaction gaps closed ([#174](https://github.com/Coolhand-Labs/coolhand-ruby/pull/174)): the query params `code`, `sas`, `pwd` and `passwd` (exact name, case-insensitive), `;name=value` matrix params in paths and query values, and bare query segments with no `=` are redacted, and URL-form string values under `data_sources` (e.g. an `endpoint` with embedded credentials) are sanitized. Percent-escaped invalid UTF-8 in a param name no longer raises.
36
+ - A host streaming block that raises no longer leaks its message into the log ([#179](https://github.com/Coolhand-Labs/coolhand-ruby/pull/179)): `response_body.error.message` is now the fixed placeholder `"[redacted: raised in host streaming block]"` (transport errors keep their message). A non-intercepted request made inside an outer streaming block no longer has its chunks appended to the outer request's logged `response_body`.
37
+ - Release red-team hardening. The URL sanitizer now also redacts the query params `jwt`, `bearer`, `session`, `cookie`, `passphrase`, `ticket`, `assertion` (by substring) and `pass`, `sid`, `otp` (exact name). A JSON body nested deeper than 100 levels is logged as a `nesting_too_deep` marker instead of the raw string, which skipped body redaction. `BaseInterceptor.sanitize_headers` returns `{}` for an input shape it can't enumerate instead of logging its `to_s`. A failure while recording a response (or a streamed chunk) is now logged and swallowed instead of raising into the host app's already-successful request. **Migration note:** logged URLs with such params now show `[REDACTED]`.
38
+ - `OpenAi::WebhookValidator` rejects a nil payload in every `Rails.env` when a secret is set ([#192](https://github.com/Coolhand-Labs/coolhand-ruby/pull/192)), matching the missing-header rule above. An empty-string body still goes to the signature check. **Migration note:** a nil body posted to a local app that has a secret configured now gets a 401.
39
+ - `LoggerService#forward_webhook` forwards only `HTTP_*`, `CONTENT_TYPE` and `CONTENT_LENGTH` when the input looks like a whole Rack env ([#196](https://github.com/Coolhand-Labs/coolhand-ruby/pull/196)) (it has a `REQUEST_METHOD` key, a lowercase dotted key like `rack.input`, or only `UPPER_CASE` keys), so process env vars such as `DATABASE_URL` or `REDIS_URL` on CGI-style servers are no longer shipped. **Migration note:** a plain hash whose keys are all uppercase (`{"ETAG" => ...}`) is treated as an env and its non-`HTTP_*` entries are dropped; `HTTP_PROXY` is always dropped.
40
+ - `WebhookInterceptor#intercept_batch_request`, `#webhook_secret` and `#process_event` are now private and excluded from the controller's `action_methods` ([#194](https://github.com/Coolhand-Labs/coolhand-ruby/pull/194)), so they cannot be routed to as actions. **Migration note:** calling them from outside the controller raises `NoMethodError`, and the three names are reserved; define `webhook_secret` under `private` in your controller (see [docs/openai.md](docs/openai.md)).
41
+
42
+ ## [0.10.0] - 2026-10-21
43
+
44
+ ### Added
45
+ - **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).
46
+
47
+ ### Security
48
+ - `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.
49
+
10
50
  ## [0.9.0] - 2026-10-18
11
51
 
12
52
  ### 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.help_center_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.help_center_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).
@@ -233,7 +251,7 @@ end
233
251
  | `intercept_addresses` | Array | All [supported providers](#supported-libraries) (`DEFAULT_INTERCEPT_ADDRESSES`) | Array of API hosts to monitor (matched by host, optionally pinned to a port and/or path prefix). This is a required allow-list — `[]` is ignored (a warning is logged) rather than disabling capture; use `enabled` or `capture` for that. See [Configuration](docs/configuration.md) |
234
252
  | `intercept_path_patterns` | Array | `[":generateContent", ":streamGenerateContent"]` | Path patterns to additionally monitor on Google API hosts — see [Configuration](docs/configuration.md) |
235
253
  | `exclude_api_patterns` | Array | `["/batchPredictionJobs/"]` | Deny-list checked after `intercept_addresses`; matching paths are skipped. Unlike `intercept_addresses`, `exclude_api_patterns = []` genuinely disables exclusion. See [Configuration](docs/configuration.md) |
236
- | `max_captured_body_bytes` | Integer | `1_000_000` | Maximum size of a captured JSON request body — oversized bodies are replaced with a placeholder. Non-JSON bodies (e.g. file/audio uploads) are always skipped regardless of size — see [Advanced Configuration](docs/configuration.md) |
254
+ | `max_captured_body_bytes` | Integer | `1_000_000` | Maximum size of a captured JSON request body or response body — oversized bodies are replaced with a placeholder (`nil` disables the cap). Non-JSON bodies (e.g. file/audio uploads) are always skipped regardless of size — see [Advanced Configuration](docs/configuration.md) |
237
255
  | `async_logging` | Boolean | `true` | Send logs to Coolhand from a background thread so a slow Coolhand API never delays your LLM calls. Set to `false` to send inline — see [Advanced Configuration](docs/configuration.md#asynchronous-logging) |
238
256
  | `auto_declare` | Array | `[]` | Rails only: `:active_job` and/or `:controller` to declare a workload and template for LLM calls made from jobs and controller actions — see [Declared Workloads](docs/declared_workloads.md#rails-auto-declare) |
239
257
 
@@ -255,7 +273,7 @@ client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
255
273
 
256
274
  response = client.chat(
257
275
  parameters: {
258
- model: "gpt-3.5-turbo",
276
+ model: "gpt-4o-mini",
259
277
  messages: [{ role: "user", content: "These pretzels are making me thirsty!"}],
260
278
  temperature: 0.7
261
279
  }
@@ -332,7 +350,8 @@ end
332
350
 
333
351
  **Optional Parameters:**
334
352
  - `event_type` - Event type to append to URL (e.g., "post_call_transcription" → `webhook://elevenlabs/post_call_transcription`)
335
- - `headers` - Request headers (automatically sanitized)
353
+ - `headers` - Request headers (automatically sanitized). Passing Rails `request.headers` is safe: when the input looks like a Rack env (it has a `REQUEST_METHOD` key or a lowercase dotted key such as `rack.input`, or every key is `UPPER_CASE`; in a plain hash use hyphenated or mixed-case names), only `HTTP_*` headers, `CONTENT_TYPE` and `CONTENT_LENGTH` are forwarded, so entries such as `rack.*`, `REQUEST_METHOD`, `QUERY_STRING`, or process env vars (`DATABASE_URL`, ...) are dropped
354
+ - `url` - Optional URL override; credentials in userinfo and sensitive query params are redacted, and any `#fragment` is dropped
336
355
  - `conversation_id`, `agent_id`, `metadata` - Custom fields for your tracking needs
337
356
 
338
357
  **Error Handling:**
@@ -392,7 +411,7 @@ The monitor works with multiple transport layers and Ruby libraries:
392
411
  - Google Gemini (`generativelanguage.googleapis.com`)
393
412
  - Google Vertex AI (`aiplatform.googleapis.com`) — see the [Vertex AI batch result logging guide](docs/vertex.md) for async batch jobs
394
413
  - Azure OpenAI Service, Azure AI Foundry, and Azure Machine Learning managed online endpoints (dedicated hosts, multi-service Azure AI Services hosts, and serverless/MaaS deployments) — see [Configuration](docs/configuration.md) for the full list of covered hosts
395
- - AWS Bedrock (OpenAI-compatible endpoint)
414
+ - AWS Bedrock (`bedrock-runtime.<region>.amazonaws.com`, plus the `.amazonaws.com.cn`, `.api.aws` dual-stack and `bedrock-runtime-fips.<region>` variants — model invoke, Converse and the OpenAI-compatible endpoint)
396
415
  - Cloudflare AI Gateway
397
416
  - OpenRouter
398
417
  - OpenCode Zen (`opencode.ai`)
@@ -488,7 +507,7 @@ The monitor handles errors gracefully:
488
507
  - Failed API logging attempts are logged to console but don't interrupt your application
489
508
  - Invalid API keys will be reported but won't crash your app
490
509
  - 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)
510
+ - 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
511
 
493
512
  ## Documentation
494
513
 
@@ -497,9 +516,10 @@ The monitor handles errors gracefully:
497
516
  - **[Reading Templates](docs/template-search.md)** — Search LLM request templates and fetch a single one, prompt patterns included, using the private API key
498
517
  - **[Reading Workloads](docs/workload-search.md)** — List workloads with optional cost and performance metrics over a rolling or explicit time window
499
518
  - **[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
519
+ - **[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
520
  - **[Linking Feedback](docs/feedback-links.md)** — Link feedback to an optimization as evidence, singly or in bulk, using the private API key
501
521
  - **[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
- - **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
522
+ - **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, using both gems, and troubleshooting
503
523
  - **[ElevenLabs Integration](docs/elevenlabs.md)** — Webhook capture, feedback submission, and Rails integration
504
524
  - **[OpenAI Batch Webhook Handler](docs/openai.md)** — Handle OpenAI batch job completion events via webhook interception
505
525
  - **[Google Vertex AI Batch Result Logging](docs/vertex.md)** — Log completed Vertex AI batch prediction job results
@@ -507,7 +527,8 @@ The monitor handles errors gracefully:
507
527
 
508
528
  ## Security
509
529
 
510
- - API keys in request headers are automatically redacted
530
+ - API keys in request headers and credential-like URL query params are automatically redacted (provider rate-limit headers such as `x-ratelimit-remaining-tokens` are kept)
531
+ - Credentials in request and response bodies (MCP `authorization`/`authorization_token`, Realtime ephemeral client secrets, `api_key`, and similar) are redacted; see [Body redaction](docs/configuration.md#body-redaction)
511
532
  - No sensitive data is exposed in logs
512
533
  - All data is sent via HTTPS to Coolhand servers
513
534
 
data/docs/anthropic.md CHANGED
@@ -4,11 +4,13 @@ Coolhand provides comprehensive monitoring for Anthropic's Claude API through su
4
4
 
5
5
  ## Overview
6
6
 
7
- Coolhand automatically detects which Anthropic gem you're using and applies the appropriate monitoring strategy:
7
+ Coolhand monitors Anthropic API calls (`api.anthropic.com`) with a single `NetHttpInterceptor` that patches `Net::HTTP`. Both Ruby gems are covered with no gem-specific setup:
8
8
 
9
- - **Official Anthropic Gem** (`anthropic`): Uses Net::HTTP transport, monitored via AnthropicInterceptor
10
- - **Community Ruby-Anthropic Gem** (`ruby-anthropic`): Uses Faraday transport, monitored via FaradayInterceptor
11
- - **Dual Installation**: Automatically handles conflicts and prevents duplicate logging
9
+ - **Official Anthropic Gem** (`anthropic`): Uses Net::HTTP directly, monitored via `NetHttpInterceptor`
10
+ - **Community Ruby-Anthropic Gem** (`ruby-anthropic`): Uses Faraday. Faraday's default `net_http` adapter delegates to Net::HTTP, so these calls are monitored by the same `NetHttpInterceptor`
11
+ - **Both gems installed**: Coolhand itself adds no conflict warnings or duplicate logging (see [Using Both Gems](#using-both-gems))
12
+
13
+ **Caveat:** A Faraday connection configured with a non-Net::HTTP adapter (such as `faraday-typhoeus`) bypasses `Net::HTTP` and is not covered.
12
14
 
13
15
  ## Quick Start
14
16
 
@@ -131,7 +133,7 @@ Coolhand.configure do |config|
131
133
  config.api_key = 'your_coolhand_api_key'
132
134
  end
133
135
 
134
- # Use ruby-anthropic gem normally - monitored via Faraday
136
+ # Use ruby-anthropic gem normally - monitored via Faraday's default net_http adapter
135
137
  client = Anthropic::Client.new(access_token: ENV['ANTHROPIC_API_KEY'])
136
138
 
137
139
  response = client.messages(
@@ -141,13 +143,15 @@ response = client.messages(
141
143
  )
142
144
 
143
145
  puts response.dig("content", 0, "text")
144
- # ✅ Automatically logged via FaradayInterceptor
146
+ # ✅ Automatically logged via NetHttpInterceptor
145
147
  ```
146
148
 
149
+ > If your Faraday connection uses a non-Net::HTTP adapter (such as `faraday-typhoeus`), requests are not captured.
150
+
147
151
  ### Streaming with Ruby-Anthropic
148
152
 
149
153
  ```ruby
150
- # Streaming works seamlessly via Faraday monitoring
154
+ # Streaming is captured the same way as any other request
151
155
  response = client.messages(
152
156
  model: "claude-3-sonnet-20240229",
153
157
  max_tokens: 1024,
@@ -163,53 +167,13 @@ end
163
167
  # ✅ Complete conversation logged to Coolhand
164
168
  ```
165
169
 
166
- ## Dual Gem Installation
167
-
168
- ### Automatic Conflict Resolution
169
-
170
- When both gems are installed, Coolhand automatically handles the conflict:
171
-
172
- ```ruby
173
- require 'anthropic' # Official gem
174
- require 'ruby-anthropic' # Community gem
175
- require 'coolhand'
170
+ ## Using Both Gems
176
171
 
177
- Coolhand.configure do |config|
178
- config.api_key = 'your_coolhand_api_key'
179
- end
172
+ Coolhand does not need to choose between the gems. Monitoring happens at the `Net::HTTP` layer through a single `NetHttpInterceptor`, so it does not depend on which gem makes the request. Coolhand does not disable monitoring, print conflict warnings, or log duplicates when both gems are installed.
180
173
 
181
- # ⚠️ Warning displayed:
182
- # "Both 'anthropic' and 'ruby-anthropic' gems are installed.
183
- # Coolhand will only monitor ruby-anthropic (Faraday-based) requests.
184
- # Official anthropic gem monitoring has been disabled."
185
- ```
186
-
187
- **Recommendation**: Use only one gem to avoid confusion. Choose based on your needs:
174
+ Both gems define an `Anthropic` namespace, so loading both in the same app can still conflict inside your own code. That is independent of Coolhand. Pick the gem that fits your needs:
188
175
  - **Official gem**: Latest features, official support, Net::HTTP transport
189
- - **Community gem**: Faraday-based, may have community contributions
190
-
191
- ### Manual Conflict Resolution
192
-
193
- To explicitly use the official gem when both are installed:
194
-
195
- ```ruby
196
- # Temporarily hide the community gem
197
- begin
198
- # Remove ruby-anthropic from the load path
199
- $LOAD_PATH.reject! { |path| path.include?('ruby-anthropic') }
200
-
201
- require 'anthropic'
202
- require 'coolhand'
203
-
204
- Coolhand.configure do |config|
205
- config.api_key = 'your_coolhand_api_key'
206
- end
207
-
208
- # Now only the official gem will be monitored
209
- client = Anthropic::Client.new
210
- # ... use official gem
211
- end
212
- ```
176
+ - **Community gem**: Faraday-based (default `net_http` adapter), may have community contributions
213
177
 
214
178
  ## Rails Integration
215
179
 
@@ -381,43 +345,24 @@ end
381
345
 
382
346
  # Now you'll see console output like:
383
347
  # "✅ Coolhand ready - will log inference calls on monitored URIs"
384
- # "COOLHAND: ⚠️ Warning: Both 'anthropic' and 'ruby-anthropic' gems are installed..."
385
348
  ```
386
349
 
387
350
  ### Common Issues
388
351
 
389
- #### 1. Gem Not Detected
390
-
391
- **Problem**: Coolhand says "Anthropic gem not loaded"
352
+ #### 1. Requests Not Appearing
392
353
 
393
- **Solution**: Ensure you require the gem before configuring Coolhand:
354
+ **Problem**: Anthropic calls are not showing up in Coolhand
394
355
 
395
- ```ruby
396
- require 'anthropic' # Must come before coolhand
397
- require 'coolhand'
356
+ **Solution**: Check the following:
398
357
 
399
- Coolhand.configure do |config|
400
- config.api_key = 'your_api_key'
401
- end
402
- ```
403
-
404
- #### 2. Duplicate Requests
405
-
406
- **Problem**: Seeing requests logged twice in dashboard
407
-
408
- **Cause**: Usually indicates both gems are installed and conflicting
409
-
410
- **Solution**: Choose one gem and uninstall the other:
411
-
412
- ```bash
413
- # Keep official gem
414
- gem uninstall ruby-anthropic
415
-
416
- # OR keep community gem
417
- gem uninstall anthropic
418
- ```
358
+ - `config.silent` is `false`, so Coolhand's debug and warning output is visible while you check the rest.
359
+ - `config.api_key` is set. Intercepted requests are skipped, with a warning log, when the key is missing.
360
+ - `config.enabled` is not `false`.
361
+ - `config.capture` is not `false`, the call is not inside `Coolhand.without_capture`, and the path does not match `exclude_api_patterns`.
362
+ - The request goes to a monitored address (`api.anthropic.com` is monitored by default).
363
+ - If you use `ruby-anthropic`, the Faraday connection uses the default `net_http` adapter. A non-Net::HTTP adapter such as `faraday-typhoeus` is not covered.
419
364
 
420
- #### 3. Missing Streaming Data
365
+ #### 2. Missing Streaming Data
421
366
 
422
367
  **Problem**: Only seeing individual chunks, not complete response
423
368
 
@@ -436,7 +381,7 @@ response = client.messages(parameters: { stream: true })
436
381
  response.each { |chunk| print chunk.delta.text }
437
382
  ```
438
383
 
439
- #### 4. Performance Concerns
384
+ #### 3. Performance Concerns
440
385
 
441
386
  **Problem**: Worried about monitoring overhead
442
387
 
@@ -496,7 +441,7 @@ end
496
441
 
497
442
  ## Best Practices
498
443
 
499
- 1. **Choose One Gem**: Avoid installing both `anthropic` and `ruby-anthropic` gems
444
+ 1. **Pick One Gem**: Both `anthropic` and `ruby-anthropic` define an `Anthropic` namespace, so avoid loading both in the same app. Coolhand monitors either one
500
445
  2. **Secure API Keys**: Use environment variables or Rails credentials, never commit keys
501
446
  3. **Silent in Production**: Set `config.silent = true` in production environments
502
447
  4. **Monitor Performance**: Use fast models (haiku) for high-frequency requests
@@ -515,4 +460,4 @@ For complete API documentation, see:
515
460
 
516
461
  - **Coolhand Issues**: [GitHub Issues](https://github.com/Coolhand-Labs/coolhand-ruby/issues)
517
462
  - **Anthropic API**: [Anthropic Documentation](https://docs.anthropic.com/)
518
- - **Gem Conflicts**: Check this guide's troubleshooting section
463
+ - **Missing Requests**: Check this guide's troubleshooting section
@@ -35,10 +35,30 @@ end
35
35
  **What changes when `debug_mode` is `true`:**
36
36
  - No HTTP request is made — `create_log`, `create_feedback`, and `send_llm_request_log` all return `nil` and print the payload via `JSON.pretty_generate` instead.
37
37
  - Capture is forced on for every request, including inside a `Coolhand.without_capture` block and when `config.capture = false`. This is intentional — debug mode is meant to show you everything, not respect capture suppression — so don't leave it enabled in an environment where you rely on `without_capture` to keep specific calls out of the logs.
38
- - Header and URL sanitization (`[REDACTED]` API keys/tokens, redacted `key=`/`token=` query params) still applies to the printed payload, exactly as it would to a real request.
38
+ - Header and URL sanitization (`[REDACTED]` API keys/tokens, redacted `key=`/`token=`/`code=` query params, `;key=` matrix params and bare `?value` query segments; URL fragments are dropped; rate-limit headers such as `x-ratelimit-remaining-tokens` are kept) still applies to the printed payload, exactly as it would to a real request.
39
39
 
40
40
  Because it forces capture unconditionally and prints full request/response bodies to the console, only enable `debug_mode` in development — never in production or in an environment handling real user data.
41
41
 
42
+ ## Body Redaction
43
+
44
+ Besides headers and URLs, Coolhand redacts credentials in the JSON body of both the request and the response before anything is sent. A redacted value becomes `[REDACTED]`; the rest of the body is untouched.
45
+
46
+ - **String values under these keys, at any depth:** `authorization`, `authorization_token`, `access_token`, `refresh_token`, `id_token`, `api_key`, `client_secret`, `private_key`, `secret_key`, `password`, `x-api-key`, `secret`, `secret_access_key`, `aws_secret_access_key`, `session_token`, `bearer_token`. Names match exactly, ignoring case and any `_`, `-` or space (so `apiKey` and `API-Key` match, `total_tokens` does not). Only string values are redacted, so a tool JSON Schema with a property named `api_key` or `token` is left as is.
47
+ - **URL-valued fields:** a string under the key `url`, `server_url`, `image_url`, `file_url` or `endpoint` (same name matching, any depth) that is a single absolute URL has embedded credentials scrubbed: userinfo (`https://user:pw@host`) and query parameters whose name looks secret (matched as a case-insensitive substring, so `key=`, `X-Amz-Signature=` and `X-Amz-Credential=` match but so do `keyword=` and `author=`). A URL that cannot be parsed loses its userinfo, query and fragment. The URL is otherwise kept. Prose that merely contains a URL, `data:` URIs and relative paths are left as is. A bare `token` key is never redacted, since logprobs use `{"token": "Hello"}`.
48
+ - **Remote MCP servers:** OpenAI `tools[].authorization` and Anthropic `mcp_servers[].authorization_token`. Every string value in the `headers` map of an OpenAI MCP tool (`type: "mcp"` or a `server_url` is present) is redacted too.
49
+ - **Realtime ephemeral keys:** the `value` of a `client_secret` object (`expires_at` is kept), and the `value` of a client-secret response that has a numeric `expires_at` beside it.
50
+ - **Azure OpenAI On Your Data:** under a `data_sources` (or `dataSources`) key at any depth, key/token/secret/password-style fields are redacted, and credentials embedded in URL values (userinfo, `key=`/`sas=` query params) are scrubbed.
51
+
52
+ Limits:
53
+ - A secret that is part of a URL's path (for example `https://actions.zapier.com/mcp/SECRET/sse`) cannot be detected generically and is sent as is.
54
+ - Parsed JSON objects and arrays are walked as described above. Subtrees nested more than 100 levels deep are replaced with `[REDACTED]`.
55
+ - A raw String body is checked in two cases: JSON text (e.g. a webhook body that is a JSON string) is parsed, redacted and re-serialized, and the JSON object/array payload on each `data:` line of a streamed (server-sent event) response is redacted the same way. A JSON payload is always re-serialized (compact, a repeated key keeps its last value), even when nothing in it was redacted, so its whitespace and string escapes may differ from the original and numbers are normalized to double precision (integers of any size are kept). Provider streams are compact JSON already, so they usually come through identical.
56
+ - Everything else in a String is sent as received: plain text, `[DONE]`, `event:`/`id:`/`retry:` and comment lines, a `data:` line that is not a JSON object or array or that Ruby's JSON parser rejects (even if it still looks like JSON, e.g. an unescaped control character or trailing text), a JSON payload split across several `data:` lines, and newline-delimited JSON streams (no `data:` prefix). Secrets inside free text such as model output are never detected.
57
+ - A String JSON payload nested more than 100 levels deep is replaced with `{"_coolhand_capture_skipped": "nesting_too_deep"}` (as JSON text, since it replaces text). If redacting a payload (or a whole String body) fails for any reason, it is replaced with `{"_coolhand_capture_skipped": "redaction_failed"}` rather than logged unchecked.
58
+ - Streamed bodies are capped by `max_captured_body_bytes` (see [Body Capture Limits](#body-capture-limits)) before redaction, so an over-limit stream is logged as the `body_too_large` placeholder and never scanned.
59
+
60
+ Webhooks sent with `Coolhand.logger_service.forward_webhook` get the same redaction on `webhook_body` and `response_body`, after the existing binary-field filtering. Webhook bodies are not subject to the capture cap.
61
+
42
62
  ## Asynchronous Logging
43
63
 
44
64
  By default Coolhand ships each captured request to its API from background worker threads (one for per-request logs, a separate one for batch jobs), so a slow or unreachable Coolhand backend never adds latency to your own LLM calls. The request-path work is limited to capturing and sanitizing the payload.
@@ -58,9 +78,9 @@ end
58
78
  - `config.debug_mode` always runs inline so printed payloads appear in order.
59
79
  - Logs queued but not yet sent are lost if the process is killed hard (`kill -9`, OOM). Set `async_logging = false` if you need delivery before each call returns.
60
80
 
61
- ## Request Body Capture Limits
81
+ ## Body Capture Limits
62
82
 
63
- To avoid holding large uploads (batch JSONL files, fine-tune corpora, audio for transcription) in memory and shipping them to Coolhand in full, `config.max_captured_body_bytes` caps how much of a request body gets captured:
83
+ To avoid holding large uploads (batch JSONL files, fine-tune corpora, audio for transcription) and large responses in memory and shipping them to Coolhand in full, `config.max_captured_body_bytes` caps how much of a request body and of a response body gets captured. Set it to `nil` to disable the cap (a non-Integer or negative value is ignored and the default is used):
64
84
 
65
85
  ```ruby
66
86
  Coolhand.configure do |config|
@@ -71,8 +91,12 @@ end
71
91
  **What happens at the limits:**
72
92
  - A request body whose `Content-Type` doesn't look like JSON (e.g. `multipart/form-data`, `audio/mpeg`) is never read into memory for capture — the log entry gets a placeholder (`{"_coolhand_capture_skipped" => "non_json_content_type", ...}`) instead of the body. This is intentional: an opaque binary blob isn't human/LLM-readable in Coolhand's UI anyway.
73
93
  - A JSON (or content-type-unset) body larger than the configured limit is replaced with a placeholder (`{"_coolhand_capture_skipped" => "body_too_large", ...}`) instead of being logged in full.
94
+ - A request body streamed via `body_stream` whose `Content-Length` is already over the limit is skipped without being read into memory at all.
95
+ - A chunked `body_stream` request (no `Content-Length`) is never read ahead for capture. Coolhand sits in front of the stream and copies the bytes Net::HTTP reads for the wire, keeping at most `max_captured_body_bytes` of them; once the limit is crossed it keeps only a byte count, so memory stays bounded however large the upload is. If the body is over the limit the log gets the `body_too_large` placeholder (`size_bytes` is the bytes sent). If the stream was not read to its end (for example the connection was refused, or the server rejected the request before the body was sent), the log gets `{"_coolhand_capture_skipped" => "body_stream_incomplete", "size_bytes" => ...}`, since there is no complete body to log. A chunked source that `IO.copy_stream` reads by path (a `String` path or a `Pathname`) is not tapped; its log gets `{"_coolhand_capture_skipped" => "unsupported_body_stream"}`. Your stream is read once, by Net::HTTP, exactly as without Coolhand, and is back on the request object afterwards. While the request is being sent, `req.body_stream` is a read-only proxy offering only `read` (and `readpartial` when your stream has it); anything else called on it during the send (such as `rewind` or `size`, from other HTTP instrumentation running inside Coolhand's `request`) raises `NoMethodError`.
96
+ - OpenAI and Vertex batch results ([OpenAI](openai.md), [Vertex](vertex.md)) are capped per item: an item's request body and its response body are each replaced by the same `body_too_large` placeholder when larger than the limit, so one huge item doesn't inflate the others or the memory held while a batch is logged. A body that can't be measured is replaced by `{"_coolhand_capture_skipped" => "nesting_too_deep"}` (nested more than 100 levels) or `{"_coolhand_capture_skipped" => "unserializable_body"}` (for example `NaN`/`Infinity` values) instead, and the rest of the batch is still logged.
97
+ - A response body larger than the limit is replaced with the same kind of placeholder (`{"_coolhand_capture_skipped" => "body_too_large", "size_bytes" => ..., "max_bytes" => ..., "content_type" => ...}`). For a streamed response (`read_body` with a block) Coolhand stops buffering once the limit is crossed, so memory use stays bounded; `size_bytes` is the number of bytes seen while streaming.
74
98
 
75
- In both cases, only the *captured log entry* is affected — the real request to the LLM provider always sends the complete, untruncated body.
99
+ In every case, only the *captured log entry* is affected — the real request to the LLM provider always sends the complete, untruncated body, and your code always receives the complete response (every streamed chunk is passed to your block untouched).
76
100
 
77
101
  ## Custom Intercept Addresses
78
102
 
@@ -93,7 +117,7 @@ Setting `intercept_addresses` **replaces** the default list entirely, so include
93
117
 
94
118
  The default list can be found in `Coolhand::Configuration::DEFAULT_INTERCEPT_ADDRESSES`.
95
119
 
96
- Each entry is matched against the request's parsed host (optionally with a port and path prefix, see below) — an exact match, or a dot-boundary suffix match (`my-llm-proxy.internal` also matches `eu.my-llm-proxy.internal`) — case-insensitively. A single `*` in an entry matches exactly one host label, e.g. `bedrock-runtime.*.amazonaws.com`. Entries are never matched against the full URL, so query strings that merely mention a host can't trigger a false match.
120
+ Each entry is matched against the request's parsed host (optionally with a port and path prefix, see below) — an exact match, or a dot-boundary suffix match (`my-llm-proxy.internal` also matches `eu.my-llm-proxy.internal`) — case-insensitively. A single `*` in an entry matches exactly one host label, e.g. `bedrock-runtime.*.amazonaws.com`. A wildcard entry is an exact host match with no implicit subdomains, so each Bedrock endpoint variant is listed separately (`bedrock-runtime.*.amazonaws.com`, `.amazonaws.com.cn`, `.api.aws`, plus the `bedrock-runtime-fips.*` hosts on `amazonaws.com` and `api.aws`); a VPC interface endpoint host (`vpce-….bedrock-runtime.…`) needs its own entry. Entries are never matched against the full URL, so query strings that merely mention a host can't trigger a false match.
97
121
 
98
122
  An entry can also pin a port (`host:port`) and/or a path (`host/path`, or `host:port/path`), and then a request must match all of them. Cohere, TypeSafe and Ollama are defaulted this way: `api.cohere.com/v2/chat`, `api.typesafe.ai/v1/systemone`, and `*:11434/api/chat` (plus `/api/generate`, `/api/embed`, `/api/embeddings`, also for `127.0.0.1`). Ollama has no fixed host, so its defaults require Ollama's default port *and* an Ollama path — an app's own unrelated `/api/chat` on another host or port is not captured. To capture an Ollama server on a dotted host or IP, or a non-default port, add entries such as `192.168.1.5:11434/api/chat` or `ollama.internal:8080/api/chat`. An IPv6 loopback server (`[::1]:11434`) is already covered by the defaults.
99
123
 
@@ -117,6 +141,8 @@ Coolhand.configure do |config|
117
141
  end
118
142
  ```
119
143
 
144
+ Before `exclude_api_patterns`, path-anchored `intercept_addresses` entries (like `api.cohere.com/v2/chat`, see above) and `intercept_path_patterns` are compared, the request path is normalized for matching only (the URL that is sent and logged is unchanged): repeated `/` are collapsed, `.` and `..` segments are resolved (never above the root), and percent-escapes of unreserved characters (`A-Z a-z 0-9 - . _ ~`) are decoded, so `/openai//files`, `/openai/./files` and `/openai/%66iles` are excluded just like `/openai/files`. Reserved escapes such as `%2F` stay encoded, and path matching remains case-sensitive. Your own patterns are compared literally, so write them in normalized form; an exclude pattern also fails closed, matching if either the raw or the normalized path contains it.
145
+
120
146
  Unlike `intercept_addresses`, `exclude_api_patterns` genuinely supports `= []` — since it only narrows an allow-list that already passed, an empty deny-list just means "exclude nothing," which is safe.
121
147
 
122
148
  ## Capture Control
data/docs/elevenlabs.md CHANGED
@@ -35,7 +35,7 @@ class WebhooksController < ApplicationController
35
35
  webhook_body: request.raw_post || request.body.read,
36
36
  source: "elevenlabs",
37
37
  event_type: request.headers["X-ElevenLabs-Event"] || "conversation",
38
- headers: request.headers
38
+ headers: request.headers # only HTTP_* headers, CONTENT_TYPE and CONTENT_LENGTH are forwarded from a Rack env
39
39
  )
40
40
 
41
41
  if result
@@ -0,0 +1,120 @@
1
+ # Reading the Help Center
2
+
3
+ `Coolhand::HelpCenterService` 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.help_center_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_help_center_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_help_center_live_spec.rb
118
+ ```
119
+
120
+ It makes about nine anonymous requests, so running it repeatedly within a minute can hit the `429`.