diff --git a/.gitignore b/.gitignore
index 1d71b1410..5db3c7af8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,7 @@ spec/build/
conformance/runner/go/conformance-runner
conformance/runner/go/go
conformance/runner/ruby/Gemfile.lock
-conformance/runner/typescript/node_modules/
+conformance/runner/typescript/node_modules
# Claude Code session files (except skills, which are committed)
.claude/*
diff --git a/go/pkg/basecamp/search.go b/go/pkg/basecamp/search.go
index 120394fd7..3d19bea6f 100644
--- a/go/pkg/basecamp/search.go
+++ b/go/pkg/basecamp/search.go
@@ -25,8 +25,23 @@ type SearchResult struct {
Parent *Parent `json:"parent,omitempty"`
Bucket *Bucket `json:"bucket,omitempty"`
Creator *Person `json:"creator,omitempty"`
- Content string `json:"content,omitempty"`
- Description string `json:"description,omitempty"`
+ // Content and Description are always present on the wire and always null:
+ // api/searches/show.json.jbuilder renders the recording's own partial and
+ // then unconditionally overwrites both with nil to keep the large HTML body
+ // out of the search payload. They are modeled as pointers so the guaranteed
+ // null round-trips faithfully rather than collapsing to "". Read
+ // PlainTextContent and PlainTextDescription instead.
+ Content *string `json:"content"`
+ Description *string `json:"description"`
+ // PlainTextContent and PlainTextDescription are highlighted, truncated
+ // excerpts — NOT plain text despite the name. BC3 converts the rich text
+ // with to_plain_text, escapes it with html_escape_once, wraps each query
+ // match in …, and truncates
+ // to 300 characters. Treat them as HTML fragments. Optional and
+ // non-nullable: a result whose recordable has no such attribute omits the
+ // key rather than sending null.
+ PlainTextContent string `json:"plain_text_content,omitempty"`
+ PlainTextDescription string `json:"plain_text_description,omitempty"`
// ContentAttachments and DescriptionAttachments are the rich text companion
// arrays carried through the polymorphic search projection. A given result
// is one recording type, so it carries only the array matching its rich text
@@ -316,19 +331,21 @@ func searchTypesFromGenerated(gts []generated.SearchType) []SearchType {
// searchResultFromGenerated converts a generated SearchResult to our clean SearchResult type.
func searchResultFromGenerated(gsr generated.SearchResult) SearchResult {
sr := SearchResult{
- Status: gsr.Status,
- VisibleToClients: gsr.VisibleToClients,
- CreatedAt: gsr.CreatedAt,
- UpdatedAt: gsr.UpdatedAt,
- Title: gsr.Title,
- InheritsStatus: gsr.InheritsStatus,
- Type: gsr.Type,
- URL: gsr.Url,
- AppURL: gsr.AppUrl,
- BookmarkURL: gsr.BookmarkUrl,
- Content: gsr.Content,
- Description: gsr.Description,
- Subject: gsr.Subject,
+ Status: gsr.Status,
+ VisibleToClients: gsr.VisibleToClients,
+ CreatedAt: gsr.CreatedAt,
+ UpdatedAt: gsr.UpdatedAt,
+ Title: gsr.Title,
+ InheritsStatus: gsr.InheritsStatus,
+ Type: gsr.Type,
+ URL: gsr.Url,
+ AppURL: gsr.AppUrl,
+ BookmarkURL: gsr.BookmarkUrl,
+ Content: gsr.Content,
+ Description: gsr.Description,
+ PlainTextContent: gsr.PlainTextContent,
+ PlainTextDescription: gsr.PlainTextDescription,
+ Subject: gsr.Subject,
}
if gsr.Id != 0 {
diff --git a/go/pkg/basecamp/search_test.go b/go/pkg/basecamp/search_test.go
index b763e50cd..7a2ad6769 100644
--- a/go/pkg/basecamp/search_test.go
+++ b/go/pkg/basecamp/search_test.go
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"reflect"
+ "strings"
"testing"
"github.com/basecamp/basecamp-sdk/go/pkg/generated"
@@ -56,8 +57,23 @@ func TestSearchResult_UnmarshalResults(t *testing.T) {
if r1.Subject != "We won Leto!" {
t.Errorf("expected subject 'We won Leto!', got %q", r1.Subject)
}
- if r1.Content != "
Hello everyone! We got the Leto Laptop project! Time to get started.
" {
- t.Errorf("unexpected content: %q", r1.Content)
+ // Content is always present on the wire and always null — the search
+ // projection strips the HTML body. A non-nil pointer would mean the
+ // projection changed.
+ if r1.Content != nil {
+ t.Errorf("expected nil Content on a search result, got %q", *r1.Content)
+ }
+ if r1.Description != nil {
+ t.Errorf("expected nil Description on a search result, got %q", *r1.Description)
+ }
+ if r1.PlainTextContent == "" {
+ t.Error("expected PlainTextContent to carry the highlighted excerpt")
+ }
+ if !strings.Contains(r1.PlainTextContent, ``) {
+ t.Errorf("PlainTextContent is an HTML fragment with highlighted matches, got %q", r1.PlainTextContent)
+ }
+ if r1.PlainTextDescription != "" {
+ t.Errorf("a Message has no description attribute, so PlainTextDescription should be absent, got %q", r1.PlainTextDescription)
}
if r1.URL != "https://3.basecampapi.com/195539477/buckets/2085958499/messages/1069479351.json" {
t.Errorf("unexpected URL: %q", r1.URL)
@@ -116,8 +132,17 @@ func TestSearchResult_UnmarshalResults(t *testing.T) {
if r2.Title != "Design specs for Leto display" {
t.Errorf("expected title 'Design specs for Leto display', got %q", r2.Title)
}
- if r2.Description != "Create detailed specifications for the Leto laptop display panel" {
- t.Errorf("unexpected description: %q", r2.Description)
+ if r2.Content != nil {
+ t.Errorf("expected nil Content on a search result, got %q", *r2.Content)
+ }
+ if r2.Description != nil {
+ t.Errorf("expected nil Description on a search result, got %q", *r2.Description)
+ }
+ if !strings.Contains(r2.PlainTextDescription, ``) {
+ t.Errorf("expected a highlighted excerpt in PlainTextDescription, got %q", r2.PlainTextDescription)
+ }
+ if r2.PlainTextContent != "" {
+ t.Errorf("a Todo has no content attribute, so PlainTextContent should be absent, got %q", r2.PlainTextContent)
}
if r2.Parent == nil {
t.Fatal("expected Parent to be non-nil for second result")
@@ -140,8 +165,11 @@ func TestSearchResult_UnmarshalResults(t *testing.T) {
if r3.Type != "Comment" {
t.Errorf("expected type 'Comment', got %q", r3.Type)
}
- if r3.Content != "The Leto keyboard layout looks great. Let's finalize it.
" {
- t.Errorf("unexpected content for comment: %q", r3.Content)
+ if r3.Content != nil {
+ t.Errorf("expected nil Content on a search result, got %q", *r3.Content)
+ }
+ if !strings.Contains(r3.PlainTextContent, ``) {
+ t.Errorf("expected a highlighted excerpt in PlainTextContent, got %q", r3.PlainTextContent)
}
// Verify timestamps are parsed
diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go
index 290065d48..069d10028 100644
--- a/go/pkg/generated/client.gen.go
+++ b/go/pkg/generated/client.gen.go
@@ -2224,7 +2224,13 @@ type SearchResult struct {
AppUrl string `json:"app_url"`
BookmarkUrl string `json:"bookmark_url,omitempty"`
Bucket RecordingBucket `json:"bucket,omitempty"`
- Content string `json:"content,omitempty"`
+
+ // Content Always present, always null. `api/searches/show.json.jbuilder` renders the
+ // recording's own partial and then unconditionally overwrites `content` with
+ // `nil` to strip the large HTML body out of the search payload. The key is
+ // therefore guaranteed present on every result — required — and its value is
+ // guaranteed null. Read `plain_text_content` instead.
+ Content *string `json:"content"`
// ContentAttachments Rich-text companion arrays carried through the polymorphic search
// projection. A given result is one recording type, so it carries only
@@ -2245,20 +2251,39 @@ type SearchResult struct {
ContentAttachments []RichTextAttachment `json:"content_attachments,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
Creator Person `json:"creator,omitempty"`
- Description string `json:"description,omitempty"`
+
+ // Description Always present, always null — the description-attribute counterpart to
+ // `content`. Read `plain_text_description` instead.
+ Description *string `json:"description"`
// DescriptionAttachments See `content_attachments` — the description-attribute companion array.
DescriptionAttachments []RichTextAttachment `json:"description_attachments,omitempty"`
Id int64 `json:"id"`
InheritsStatus bool `json:"inherits_status,omitempty"`
Parent RecordingParent `json:"parent,omitempty"`
- Status string `json:"status,omitempty"`
- Subject string `json:"subject,omitempty"`
- Title string `json:"title"`
- Type string `json:"type"`
- UpdatedAt time.Time `json:"updated_at,omitempty"`
- Url string `json:"url"`
- VisibleToClients bool `json:"visible_to_clients,omitempty"`
+
+ // PlainTextContent A highlighted, truncated excerpt of the recording's content — **not** plain
+ // text despite the name. `excerpt_and_highlight_matches` converts the rich
+ // text with `to_plain_text`, escapes it with `html_escape_once`, then wraps
+ // each query match in `…` and
+ // truncates the result to 300 characters. Treat it as an HTML fragment.
+ //
+ // Optional and non-nullable: emitted only when the underlying recordable
+ // responds to `content`, so a result whose type has no content attribute
+ // omits the key entirely rather than sending null.
+ PlainTextContent string `json:"plain_text_content,omitempty"`
+
+ // PlainTextDescription The description-attribute counterpart to `plain_text_content`, with the
+ // same highlighting, escaping, and 300-character truncation. Optional and
+ // non-nullable — omitted when the recordable has no description attribute.
+ PlainTextDescription string `json:"plain_text_description,omitempty"`
+ Status string `json:"status,omitempty"`
+ Subject string `json:"subject,omitempty"`
+ Title string `json:"title"`
+ Type string `json:"type"`
+ UpdatedAt time.Time `json:"updated_at,omitempty"`
+ Url string `json:"url"`
+ VisibleToClients bool `json:"visible_to_clients,omitempty"`
}
// SearchType A selectable search filter option. `key` is the value passed back as a
diff --git a/openapi.json b/openapi.json
index e0e5fad31..fac3028e6 100644
--- a/openapi.json
+++ b/openapi.json
@@ -29388,10 +29388,28 @@
"$ref": "#/components/schemas/Person"
},
"content": {
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Always present, always null. `api/searches/show.json.jbuilder` renders the\nrecording's own partial and then unconditionally overwrites `content` with\n`nil` to strip the large HTML body out of the search payload. The key is\ntherefore guaranteed present on every result — required — and its value is\nguaranteed null. Read `plain_text_content` instead.",
+ "x-go-type": "*string"
},
"description": {
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Always present, always null — the description-attribute counterpart to\n`content`. Read `plain_text_description` instead.",
+ "x-go-type": "*string"
+ },
+ "plain_text_content": {
+ "type": "string",
+ "description": "A highlighted, truncated excerpt of the recording's content — **not** plain\ntext despite the name. `excerpt_and_highlight_matches` converts the rich\ntext with `to_plain_text`, escapes it with `html_escape_once`, then wraps\neach query match in `…` and\ntruncates the result to 300 characters. Treat it as an HTML fragment.\n\nOptional and non-nullable: emitted only when the underlying recordable\nresponds to `content`, so a result whose type has no content attribute\nomits the key entirely rather than sending null."
+ },
+ "plain_text_description": {
+ "type": "string",
+ "description": "The description-attribute counterpart to `plain_text_content`, with the\nsame highlighting, escaping, and 300-character truncation. Optional and\nnon-nullable — omitted when the recordable has no description attribute."
},
"content_attachments": {
"type": "array",
@@ -29413,6 +29431,8 @@
},
"required": [
"app_url",
+ "content",
+ "description",
"id",
"title",
"type",
diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py
index 7e3260c2d..c18269c6f 100644
--- a/python/src/basecamp/generated/types.py
+++ b/python/src/basecamp/generated/types.py
@@ -1406,15 +1406,17 @@ class SearchResult(TypedDict):
app_url: str
bookmark_url: NotRequired[str]
bucket: NotRequired[RecordingBucket]
- content: NotRequired[str]
+ content: str | None
content_attachments: NotRequired[list[RichTextAttachment]]
created_at: NotRequired[str]
creator: NotRequired[Person]
- description: NotRequired[str]
+ description: str | None
description_attachments: NotRequired[list[RichTextAttachment]]
id: int
inherits_status: NotRequired[bool]
parent: NotRequired[RecordingParent]
+ plain_text_content: NotRequired[str]
+ plain_text_description: NotRequired[str]
status: NotRequired[str]
subject: NotRequired[str]
title: str
diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json
index 810171877..7d1d23650 100644
--- a/ruby/lib/basecamp/generated/metadata.json
+++ b/ruby/lib/basecamp/generated/metadata.json
@@ -1,7 +1,7 @@
{
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
"version": "1.0.0",
- "generated": "2026-07-27T18:27:05Z",
+ "generated": "2026-07-28T06:50:08Z",
"operations": {
"GetAccount": {
"retry": {
diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb
index dad5e81b3..27539b763 100644
--- a/ruby/lib/basecamp/generated/types.rb
+++ b/ruby/lib/basecamp/generated/types.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Auto-generated from OpenAPI spec. Do not edit manually.
-# Generated: 2026-07-27T18:27:05Z
+# Generated: 2026-07-28T06:50:08Z
require "json"
require "time"
@@ -3511,29 +3511,31 @@ def to_json(*args)
# SearchResult
class SearchResult
include TypeHelpers
- attr_accessor :app_url, :id, :title, :type, :url, :bookmark_url, :bucket, :content, :content_attachments, :created_at, :creator, :description, :description_attachments, :inherits_status, :parent, :status, :subject, :updated_at, :visible_to_clients
+ attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients
# @return [Array]
def self.required_fields
- %i[app_url id title type url].freeze
+ %i[app_url content description id title type url].freeze
end
def initialize(data = {})
@app_url = data["app_url"]
+ @content = data["content"]
+ @description = data["description"]
@id = parse_integer(data["id"])
@title = data["title"]
@type = data["type"]
@url = data["url"]
@bookmark_url = data["bookmark_url"]
@bucket = parse_type(data["bucket"], "RecordingBucket")
- @content = data["content"]
@content_attachments = parse_array(data["content_attachments"], "RichTextAttachment")
@created_at = parse_datetime(data["created_at"])
@creator = parse_type(data["creator"], "Person")
- @description = data["description"]
@description_attachments = parse_array(data["description_attachments"], "RichTextAttachment")
@inherits_status = parse_boolean(data["inherits_status"])
@parent = parse_type(data["parent"], "RecordingParent")
+ @plain_text_content = data["plain_text_content"]
+ @plain_text_description = data["plain_text_description"]
@status = data["status"]
@subject = data["subject"]
@updated_at = parse_datetime(data["updated_at"])
@@ -3543,25 +3545,27 @@ def initialize(data = {})
def to_h
{
"app_url" => @app_url,
+ "content" => @content,
+ "description" => @description,
"id" => @id,
"title" => @title,
"type" => @type,
"url" => @url,
"bookmark_url" => @bookmark_url,
"bucket" => @bucket,
- "content" => @content,
"content_attachments" => @content_attachments,
"created_at" => @created_at,
"creator" => @creator,
- "description" => @description,
"description_attachments" => @description_attachments,
"inherits_status" => @inherits_status,
"parent" => @parent,
+ "plain_text_content" => @plain_text_content,
+ "plain_text_description" => @plain_text_description,
"status" => @status,
"subject" => @subject,
"updated_at" => @updated_at,
"visible_to_clients" => @visible_to_clients,
- }.compact
+ }.reject { |k, v| v.nil? && !["content", "description"].include?(k) }
end
def to_json(*args)
diff --git a/ruby/test/basecamp/generated_types_test.rb b/ruby/test/basecamp/generated_types_test.rb
index 933252d0d..934b9cefb 100644
--- a/ruby/test/basecamp/generated_types_test.rb
+++ b/ruby/test/basecamp/generated_types_test.rb
@@ -127,6 +127,32 @@ def test_search_type_preserves_null_key
assert_equal "Message", real_option.to_h["key"]
end
+ # SearchResult.content and SearchResult.description are required-and-nullable:
+ # api/searches/show.json.jbuilder renders the recording's own partial and then
+ # unconditionally overwrites both with nil to keep the large HTML body out of
+ # the search payload, so the keys are always present and always null. to_h must
+ # preserve those explicit nulls rather than compacting them away — a consumer
+ # has to be able to tell "the projection stripped this" from "absent".
+ def test_search_result_preserves_null_content_and_description
+ result = Basecamp::Types::SearchResult.new(
+ "id" => 1, "title" => "Quarterly Report", "type" => "Message",
+ "url" => "https://3.basecampapi.com/12345/buckets/1/messages/1.json",
+ "app_url" => "https://3.basecamp.com/12345/buckets/1/messages/1",
+ "content" => nil, "description" => nil,
+ "plain_text_content" => "Q1 Report summary."
+ )
+ hash = result.to_h
+
+ assert hash.key?("content"), "required-nullable content must stay present"
+ assert_nil hash["content"]
+ assert hash.key?("description"), "required-nullable description must stay present"
+ assert_nil hash["description"]
+ # The excerpt is the opposite contract: optional and non-nullable.
+ assert_includes hash["plain_text_content"], "circled-text"
+ assert_equal %i[app_url content description id title type url],
+ Basecamp::Types::SearchResult.required_fields
+ end
+
# Wormhole.color and Wormhole.destination_url are required-and-nullable: the bc3
# jbuilder always emits them, null when unset/unlinked. to_h must preserve those
# explicit nulls (the destination_url is the only field identifying the target),
diff --git a/ruby/test/basecamp/services/search_service_test.rb b/ruby/test/basecamp/services/search_service_test.rb
index b05ed96a6..9007cb9d6 100644
--- a/ruby/test/basecamp/services/search_service_test.rb
+++ b/ruby/test/basecamp/services/search_service_test.rb
@@ -13,8 +13,18 @@ def test_search
results = [
# The search projection carries the matching type's rich-text companion
# array; a Message result surfaces content_attachments.
- { "id" => 1, "title" => "Quarterly Report", "type" => "Message", "content_attachments" => [] },
- { "id" => 2, "title" => "Q1 Report Draft", "type" => "Document", "content_attachments" => [] }
+ # BC3 emits `json.content nil` / `json.description nil` unconditionally on
+ # every search result, so both keys are always present and always null. A
+ # stub that omits them is a payload the API cannot produce.
+ { "id" => 1, "title" => "Quarterly Report", "type" => "Message", "content_attachments" => [],
+ "url" => "https://3.basecampapi.com/12345/buckets/1/messages/1.json",
+ "app_url" => "https://3.basecamp.com/12345/buckets/1/messages/1",
+ "content" => nil, "description" => nil,
+ "plain_text_content" => "Q1 Report summary." },
+ { "id" => 2, "title" => "Q1 Report Draft", "type" => "Document", "content_attachments" => [],
+ "url" => "https://3.basecampapi.com/12345/buckets/1/documents/2.json",
+ "app_url" => "https://3.basecamp.com/12345/buckets/1/documents/2",
+ "content" => nil, "description" => nil }
]
stub_request(:get, "https://3.basecampapi.com/12345/search.json")
.with(query: { q: "quarterly report" })
@@ -27,6 +37,16 @@ def test_search
# The optional projection array surfaces on each matching-type result.
assert_equal [], result[0]["content_attachments"]
assert_equal [], result[1]["content_attachments"]
+
+ # Present and null, never absent.
+ result.each do |r|
+ assert r.key?("content"), "content must be present on every search result"
+ assert_nil r["content"]
+ assert r.key?("description"), "description must be present on every search result"
+ assert_nil r["description"]
+ end
+ assert_includes result[0]["plain_text_content"], "circled-text"
+ assert_nil result[1]["plain_text_content"]
end
def test_search_with_sort
diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy
index 279fedbc3..e7524b747 100644
--- a/spec/basecamp.smithy
+++ b/spec/basecamp.smithy
@@ -7976,8 +7976,31 @@ structure SearchResult {
parent: RecordingParent
bucket: RecordingBucket
creator: Person
+ /// Always present, always null. `api/searches/show.json.jbuilder` renders the
+ /// recording's own partial and then unconditionally overwrites `content` with
+ /// `nil` to strip the large HTML body out of the search payload. The key is
+ /// therefore guaranteed present on every result — required — and its value is
+ /// guaranteed null. Read `plain_text_content` instead.
+ @required
content: String
+ /// Always present, always null — the description-attribute counterpart to
+ /// `content`. Read `plain_text_description` instead.
+ @required
description: String
+ /// A highlighted, truncated excerpt of the recording's content — **not** plain
+ /// text despite the name. `excerpt_and_highlight_matches` converts the rich
+ /// text with `to_plain_text`, escapes it with `html_escape_once`, then wraps
+ /// each query match in `…` and
+ /// truncates the result to 300 characters. Treat it as an HTML fragment.
+ ///
+ /// Optional and non-nullable: emitted only when the underlying recordable
+ /// responds to `content`, so a result whose type has no content attribute
+ /// omits the key entirely rather than sending null.
+ plain_text_content: String
+ /// The description-attribute counterpart to `plain_text_content`, with the
+ /// same highlighting, escaping, and 300-character truncation. Optional and
+ /// non-nullable — omitted when the recordable has no description attribute.
+ plain_text_description: String
/// Rich-text companion arrays carried through the polymorphic search
/// projection. A given result is one recording type, so it carries only
/// the array matching its rich-text attribute (`content_attachments` for a
diff --git a/spec/fixtures/search/results.json b/spec/fixtures/search/results.json
index 48a05921a..f69b67ce3 100644
--- a/spec/fixtures/search/results.json
+++ b/spec/fixtures/search/results.json
@@ -11,8 +11,10 @@
"url": "https://3.basecampapi.com/195539477/buckets/2085958499/messages/1069479351.json",
"app_url": "https://3.basecamp.com/195539477/buckets/2085958499/messages/1069479351",
"bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5MzUxP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--aabbccdd.json",
+ "content": null,
+ "description": null,
+ "plain_text_content": "Hello everyone! We got the Leto Laptop project! Time to get started.",
"subject": "We won Leto!",
- "content": "Hello everyone! We got the Leto Laptop project! Time to get started.
",
"parent": {
"id": 1069479338,
"title": "Message Board",
@@ -73,7 +75,9 @@
"url": "https://3.basecampapi.com/195539477/buckets/2085958499/todos/1069479400.json",
"app_url": "https://3.basecamp.com/195539477/buckets/2085958499/todos/1069479400",
"bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--eeffgghh.json",
- "description": "Create detailed specifications for the Leto laptop display panel",
+ "content": null,
+ "description": null,
+ "plain_text_description": "Create detailed specifications for the Leto laptop keyboard layout, including key spacing and travel distance.",
"parent": {
"id": 1069479390,
"title": "Hardware Design",
@@ -134,7 +138,9 @@
"url": "https://3.basecampapi.com/195539477/buckets/2085958499/comments/1069479450.json",
"app_url": "https://3.basecamp.com/195539477/buckets/2085958499/comments/1069479450",
"bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDUwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--iijjkkll.json",
- "content": "The Leto keyboard layout looks great. Let's finalize it.
",
+ "content": null,
+ "description": null,
+ "plain_text_content": "The Leto keyboard layout looks great. Let's finalize it this week.",
"parent": {
"id": 1069479400,
"title": "Design specs for Leto display",
diff --git a/spec/smithy-build.json b/spec/smithy-build.json
index ce6c17a88..5c16781c0 100644
--- a/spec/smithy-build.json
+++ b/spec/smithy-build.json
@@ -35,6 +35,10 @@
},
"/components/schemas/SearchType/properties/key/type": ["string", "null"],
"/components/schemas/SearchType/properties/key/x-go-type": "*string",
+ "/components/schemas/SearchResult/properties/content/type": ["string", "null"],
+ "/components/schemas/SearchResult/properties/content/x-go-type": "*string",
+ "/components/schemas/SearchResult/properties/description/type": ["string", "null"],
+ "/components/schemas/SearchResult/properties/description/x-go-type": "*string",
"/components/schemas/Wormhole/properties/destination_url/type": ["string", "null"],
"/components/schemas/Wormhole/properties/destination_url/x-go-type": "*string",
"/components/schemas/Wormhole/properties/color/type": ["string", "null"],
diff --git a/swift/Sources/Basecamp/Generated/Models/SearchResult.swift b/swift/Sources/Basecamp/Generated/Models/SearchResult.swift
index 64ccb72f2..00e7f155b 100644
--- a/swift/Sources/Basecamp/Generated/Models/SearchResult.swift
+++ b/swift/Sources/Basecamp/Generated/Models/SearchResult.swift
@@ -3,20 +3,22 @@ import Foundation
public struct SearchResult: Codable, Sendable {
public let appUrl: String
+ public let content: String?
+ public let description: String?
public let id: Int
public let title: String
public let type: String
public let url: String
public var bookmarkUrl: String?
public var bucket: RecordingBucket?
- public var content: String?
public var contentAttachments: [RichTextAttachment]?
public var createdAt: String?
public var creator: Person?
- public var description: String?
public var descriptionAttachments: [RichTextAttachment]?
public var inheritsStatus: Bool?
public var parent: RecordingParent?
+ public var plainTextContent: String?
+ public var plainTextDescription: String?
public var status: String?
public var subject: String?
public var updatedAt: String?
@@ -24,43 +26,121 @@ public struct SearchResult: Codable, Sendable {
public init(
appUrl: String,
+ content: String?,
+ description: String?,
id: Int,
title: String,
type: String,
url: String,
bookmarkUrl: String? = nil,
bucket: RecordingBucket? = nil,
- content: String? = nil,
contentAttachments: [RichTextAttachment]? = nil,
createdAt: String? = nil,
creator: Person? = nil,
- description: String? = nil,
descriptionAttachments: [RichTextAttachment]? = nil,
inheritsStatus: Bool? = nil,
parent: RecordingParent? = nil,
+ plainTextContent: String? = nil,
+ plainTextDescription: String? = nil,
status: String? = nil,
subject: String? = nil,
updatedAt: String? = nil,
visibleToClients: Bool? = nil
) {
self.appUrl = appUrl
+ self.content = content
+ self.description = description
self.id = id
self.title = title
self.type = type
self.url = url
self.bookmarkUrl = bookmarkUrl
self.bucket = bucket
- self.content = content
self.contentAttachments = contentAttachments
self.createdAt = createdAt
self.creator = creator
- self.description = description
self.descriptionAttachments = descriptionAttachments
self.inheritsStatus = inheritsStatus
self.parent = parent
+ self.plainTextContent = plainTextContent
+ self.plainTextDescription = plainTextDescription
self.status = status
self.subject = subject
self.updatedAt = updatedAt
self.visibleToClients = visibleToClients
}
+
+ enum CodingKeys: String, CodingKey {
+ case appUrl
+ case content
+ case description
+ case id
+ case title
+ case type
+ case url
+ case bookmarkUrl
+ case bucket
+ case contentAttachments
+ case createdAt
+ case creator
+ case descriptionAttachments
+ case inheritsStatus
+ case parent
+ case plainTextContent
+ case plainTextDescription
+ case status
+ case subject
+ case updatedAt
+ case visibleToClients
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.appUrl = try container.decode(String.self, forKey: .appUrl)
+ self.content = try container.decode(String?.self, forKey: .content)
+ self.description = try container.decode(String?.self, forKey: .description)
+ self.id = try container.decode(Int.self, forKey: .id)
+ self.title = try container.decode(String.self, forKey: .title)
+ self.type = try container.decode(String.self, forKey: .type)
+ self.url = try container.decode(String.self, forKey: .url)
+ self.bookmarkUrl = try container.decodeIfPresent(String.self, forKey: .bookmarkUrl)
+ self.bucket = try container.decodeIfPresent(RecordingBucket.self, forKey: .bucket)
+ self.contentAttachments = try container.decodeIfPresent([RichTextAttachment].self, forKey: .contentAttachments)
+ self.createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
+ self.creator = try container.decodeIfPresent(Person.self, forKey: .creator)
+ self.descriptionAttachments = try container.decodeIfPresent([RichTextAttachment].self, forKey: .descriptionAttachments)
+ self.inheritsStatus = try container.decodeIfPresent(Bool.self, forKey: .inheritsStatus)
+ self.parent = try container.decodeIfPresent(RecordingParent.self, forKey: .parent)
+ self.plainTextContent = try container.decodeIfPresent(String.self, forKey: .plainTextContent)
+ self.plainTextDescription = try container.decodeIfPresent(String.self, forKey: .plainTextDescription)
+ self.status = try container.decodeIfPresent(String.self, forKey: .status)
+ self.subject = try container.decodeIfPresent(String.self, forKey: .subject)
+ self.updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
+ self.visibleToClients = try container.decodeIfPresent(Bool.self, forKey: .visibleToClients)
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(self.appUrl, forKey: .appUrl)
+ try container.encode(self.content, forKey: .content)
+ try container.encode(self.description, forKey: .description)
+ try container.encode(self.id, forKey: .id)
+ try container.encode(self.title, forKey: .title)
+ try container.encode(self.type, forKey: .type)
+ try container.encode(self.url, forKey: .url)
+ try container.encodeIfPresent(self.bookmarkUrl, forKey: .bookmarkUrl)
+ try container.encodeIfPresent(self.bucket, forKey: .bucket)
+ try container.encodeIfPresent(self.contentAttachments, forKey: .contentAttachments)
+ try container.encodeIfPresent(self.createdAt, forKey: .createdAt)
+ try container.encodeIfPresent(self.creator, forKey: .creator)
+ try container.encodeIfPresent(self.descriptionAttachments, forKey: .descriptionAttachments)
+ try container.encodeIfPresent(self.inheritsStatus, forKey: .inheritsStatus)
+ try container.encodeIfPresent(self.parent, forKey: .parent)
+ try container.encodeIfPresent(self.plainTextContent, forKey: .plainTextContent)
+ try container.encodeIfPresent(self.plainTextDescription, forKey: .plainTextDescription)
+ try container.encodeIfPresent(self.status, forKey: .status)
+ try container.encodeIfPresent(self.subject, forKey: .subject)
+ try container.encodeIfPresent(self.updatedAt, forKey: .updatedAt)
+ try container.encodeIfPresent(self.visibleToClients, forKey: .visibleToClients)
+ }
}
diff --git a/swift/Tests/BasecampTests/SearchResultDecodeTests.swift b/swift/Tests/BasecampTests/SearchResultDecodeTests.swift
new file mode 100644
index 000000000..d07897433
--- /dev/null
+++ b/swift/Tests/BasecampTests/SearchResultDecodeTests.swift
@@ -0,0 +1,115 @@
+import XCTest
+@testable import Basecamp
+
+/// `SearchResult.content` and `.description` are **required and nullable**: the
+/// search projection in `api/searches/show.json.jbuilder` renders the
+/// recording's own partial and then unconditionally overwrites both with `nil`
+/// to keep the large HTML body out of the payload. So the keys are guaranteed
+/// present and their values guaranteed null.
+///
+/// That is a different contract from optional-and-nullable, and the difference
+/// is observable: a payload that *omits* the keys must fail to decode. These
+/// tests pin both halves, so a regeneration that relaxed `@required` — or that
+/// made the fields optional — would fail here rather than silently widening the
+/// type.
+final class SearchResultDecodeTests: XCTestCase {
+ /// Mirrors the key strategies `BaseService` configures, so these tests
+ /// exercise the same decode path the SDK actually uses.
+ private func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ return decoder
+ }
+
+ private func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ return encoder
+ }
+
+ /// The minimum required key set, less content/description, so each test can
+ /// vary only the fields under test.
+ private func payload(extraKeys: String) -> Data {
+ """
+ {
+ "id": 1069479351,
+ "title": "We won Leto!",
+ "type": "Message",
+ "url": "https://3.basecampapi.com/195539477/buckets/1/messages/1069479351.json",
+ "app_url": "https://3.basecamp.com/195539477/buckets/1/messages/1069479351"
+ \(extraKeys)
+ }
+ """.data(using: .utf8)!
+ }
+
+ func testDecodesNullContentAndDescription() throws {
+ let result = try makeDecoder().decode(
+ SearchResult.self,
+ from: payload(extraKeys: #", "content": null, "description": null"#)
+ )
+
+ XCTAssertNil(result.content)
+ XCTAssertNil(result.description)
+ }
+
+ func testRejectsOmittedContent() {
+ // Optional-and-nullable would accept this. Required-and-nullable must not.
+ XCTAssertThrowsError(
+ try makeDecoder().decode(SearchResult.self, from: payload(extraKeys: #", "description": null"#))
+ ) { error in
+ guard case DecodingError.keyNotFound(let key, _) = error else {
+ return XCTFail("expected a keyNotFound error for `content`, got \(error)")
+ }
+ XCTAssertEqual(key.stringValue, "content")
+ }
+ }
+
+ func testRejectsOmittedDescription() {
+ XCTAssertThrowsError(
+ try makeDecoder().decode(SearchResult.self, from: payload(extraKeys: #", "content": null"#))
+ ) { error in
+ guard case DecodingError.keyNotFound(let key, _) = error else {
+ return XCTFail("expected a keyNotFound error for `description`, got \(error)")
+ }
+ XCTAssertEqual(key.stringValue, "description")
+ }
+ }
+
+ /// The plain-text excerpts are the opposite contract: optional and
+ /// non-nullable. A Message carries `plain_text_content` and omits
+ /// `plain_text_description` entirely rather than sending null.
+ func testPlainTextFieldsAreOptionalAndOmittedWhenAbsent() throws {
+ let excerpt = #"Hello everyone! We got the Leto Laptop project!"#
+ let result = try makeDecoder().decode(
+ SearchResult.self,
+ from: payload(extraKeys: #", "content": null, "description": null, "plain_text_content": "\#(excerpt)""#)
+ )
+
+ XCTAssertNotNil(result.plainTextContent)
+ // Despite the name, this is an HTML fragment: matches are wrapped in
+ // and the whole thing is truncated to 300
+ // characters by BC3.
+ XCTAssertTrue(result.plainTextContent?.contains(#""#) ?? false)
+ XCTAssertNil(result.plainTextDescription)
+ }
+
+ /// Re-encoding must keep the nulls on the wire. Dropping them would turn a
+ /// required key into an absent one and break round-tripping.
+ func testReencodesNullsRatherThanOmittingThem() throws {
+ let result = try makeDecoder().decode(
+ SearchResult.self,
+ from: payload(extraKeys: #", "content": null, "description": null"#)
+ )
+
+ let encoded = try makeEncoder().encode(result)
+ let object = try XCTUnwrap(
+ try JSONSerialization.jsonObject(with: encoded) as? [String: Any]
+ )
+
+ XCTAssertTrue(object.keys.contains("content"))
+ XCTAssertTrue(object.keys.contains("description"))
+ XCTAssertTrue(object["content"] is NSNull)
+ XCTAssertTrue(object["description"] is NSNull)
+ XCTAssertFalse(object.keys.contains("plain_text_content"))
+ }
+}
diff --git a/typescript/.gitignore b/typescript/.gitignore
index b94707787..44d646d58 100644
--- a/typescript/.gitignore
+++ b/typescript/.gitignore
@@ -1,2 +1,2 @@
-node_modules/
+node_modules
dist/
diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts
index ded8c07bb..73c364b33 100644
--- a/typescript/src/generated/metadata.ts
+++ b/typescript/src/generated/metadata.ts
@@ -37,7 +37,7 @@ export interface MetadataOutput {
const metadata: MetadataOutput = {
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
"version": "1.0.0",
- "generated": "2026-07-27T18:27:04.758Z",
+ "generated": "2026-07-28T06:50:06.535Z",
"operations": {
"GetAccount": {
"retry": {
diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json
index 92245c107..616adad5f 100644
--- a/typescript/src/generated/openapi-stripped.json
+++ b/typescript/src/generated/openapi-stripped.json
@@ -26812,10 +26812,28 @@
"$ref": "#/components/schemas/Person"
},
"content": {
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Always present, always null. `api/searches/show.json.jbuilder` renders the\nrecording's own partial and then unconditionally overwrites `content` with\n`nil` to strip the large HTML body out of the search payload. The key is\ntherefore guaranteed present on every result — required — and its value is\nguaranteed null. Read `plain_text_content` instead.",
+ "x-go-type": "*string"
},
"description": {
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Always present, always null — the description-attribute counterpart to\n`content`. Read `plain_text_description` instead.",
+ "x-go-type": "*string"
+ },
+ "plain_text_content": {
+ "type": "string",
+ "description": "A highlighted, truncated excerpt of the recording's content — **not** plain\ntext despite the name. `excerpt_and_highlight_matches` converts the rich\ntext with `to_plain_text`, escapes it with `html_escape_once`, then wraps\neach query match in `…` and\ntruncates the result to 300 characters. Treat it as an HTML fragment.\n\nOptional and non-nullable: emitted only when the underlying recordable\nresponds to `content`, so a result whose type has no content attribute\nomits the key entirely rather than sending null."
+ },
+ "plain_text_description": {
+ "type": "string",
+ "description": "The description-attribute counterpart to `plain_text_content`, with the\nsame highlighting, escaping, and 300-character truncation. Optional and\nnon-nullable — omitted when the recordable has no description attribute."
},
"content_attachments": {
"type": "array",
@@ -26837,6 +26855,8 @@
},
"required": [
"app_url",
+ "content",
+ "description",
"id",
"title",
"type",
diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts
index c9598b3c7..8775fb936 100644
--- a/typescript/src/generated/schema.d.ts
+++ b/typescript/src/generated/schema.d.ts
@@ -4753,8 +4753,37 @@ export interface components {
parent?: components["schemas"]["RecordingParent"];
bucket?: components["schemas"]["RecordingBucket"];
creator?: components["schemas"]["Person"];
- content?: string;
- description?: string;
+ /**
+ * @description Always present, always null. `api/searches/show.json.jbuilder` renders the
+ * recording's own partial and then unconditionally overwrites `content` with
+ * `nil` to strip the large HTML body out of the search payload. The key is
+ * therefore guaranteed present on every result — required — and its value is
+ * guaranteed null. Read `plain_text_content` instead.
+ */
+ content: string | null;
+ /**
+ * @description Always present, always null — the description-attribute counterpart to
+ * `content`. Read `plain_text_description` instead.
+ */
+ description: string | null;
+ /**
+ * @description A highlighted, truncated excerpt of the recording's content — **not** plain
+ * text despite the name. `excerpt_and_highlight_matches` converts the rich
+ * text with `to_plain_text`, escapes it with `html_escape_once`, then wraps
+ * each query match in `…` and
+ * truncates the result to 300 characters. Treat it as an HTML fragment.
+ *
+ * Optional and non-nullable: emitted only when the underlying recordable
+ * responds to `content`, so a result whose type has no content attribute
+ * omits the key entirely rather than sending null.
+ */
+ plain_text_content?: string;
+ /**
+ * @description The description-attribute counterpart to `plain_text_content`, with the
+ * same highlighting, escaping, and 300-character truncation. Optional and
+ * non-nullable — omitted when the recordable has no description attribute.
+ */
+ plain_text_description?: string;
/**
* @description Rich-text companion arrays carried through the polymorphic search
* projection. A given result is one recording type, so it carries only
diff --git a/typescript/tests/services/search.test.ts b/typescript/tests/services/search.test.ts
index 6686dd268..6e43aebf4 100644
--- a/typescript/tests/services/search.test.ts
+++ b/typescript/tests/services/search.test.ts
@@ -33,6 +33,13 @@ describe("SearchService", () => {
status: "active",
url: "https://example.com/1",
app_url: "https://basecamp.com/1",
+ // BC3 emits `json.content nil` / `json.description nil`
+ // unconditionally on every search result, so the keys are always
+ // present and always null. A stub that omits them is a payload the
+ // API cannot produce, and would let the required-and-nullable
+ // contract regress unnoticed.
+ content: null,
+ description: null,
},
{
id: 2,
@@ -41,6 +48,12 @@ describe("SearchService", () => {
status: "active",
url: "https://example.com/2",
app_url: "https://basecamp.com/2",
+ content: null,
+ description: null,
+ // The searchable text lives here instead — a highlighted, truncated
+ // excerpt, not plain text despite the name.
+ plain_text_content:
+ 'Notes from the Leto kickoff.',
// The search projection carries the matching type's rich-text
// companion array; a Message result surfaces content_attachments.
content_attachments: [
@@ -75,6 +88,16 @@ describe("SearchService", () => {
expect(results).toHaveLength(2);
// The optional projection array surfaces on the matching-type result.
expect(results[1]!.content_attachments).toHaveLength(1);
+
+ // The contract: present and null, never absent.
+ for (const r of results) {
+ expect(r.content).toBeNull();
+ expect(r.description).toBeNull();
+ }
+ expect(results[1]!.plain_text_content).toContain(
+ 'mark class="circled-text"',
+ );
+ expect(results[0]!.plain_text_content).toBeUndefined();
expect(results[1]!.content_attachments![0]!.width).toBe(1024);
expect(results[0]!.title).toBe("Project Plan");
expect(results[1]!.type).toBe("Message");