diff --git a/go/pkg/basecamp/tools.go b/go/pkg/basecamp/tools.go index 5ccd8259..34b5300f 100644 --- a/go/pkg/basecamp/tools.go +++ b/go/pkg/basecamp/tools.go @@ -27,6 +27,13 @@ type Tool struct { type CreateToolOptions struct { // Title for the new tool. If empty, Basecamp assigns the next available default title for the tool type. Title string + // VisibleToClients sets client visibility at create time (optional, tri-state). + // nil omits the field so the server applies its own default visibility rule; a + // non-nil value is sent verbatim, and an explicit false reaches the wire (the + // pointer distinguishes unset from false). Honored only for tool types that + // manage their own client visibility (Chat::Transcript, Kanban::Board); every + // other tool type ignores it and inherits the project default. + VisibleToClients *bool } // UpdateToolRequest specifies the parameters for updating (renaming) a tool. @@ -106,8 +113,11 @@ func (s *ToolsService) Create(ctx context.Context, bucketID int64, toolType stri body := generated.CreateToolJSONRequestBody{ ToolType: toolType, } - if opts != nil && opts.Title != "" { - body.Title = opts.Title + if opts != nil { + if opts.Title != "" { + body.Title = opts.Title + } + body.VisibleToClients = opts.VisibleToClients } resp, err := s.client.parent.gen.CreateToolWithResponse(ctx, s.client.accountID, bucketID, body) diff --git a/go/pkg/basecamp/tools_test.go b/go/pkg/basecamp/tools_test.go index c8feb014..b0e76df0 100644 --- a/go/pkg/basecamp/tools_test.go +++ b/go/pkg/basecamp/tools_test.go @@ -171,6 +171,62 @@ func TestToolsServiceCreateOmitsTitleWhenNotProvided(t *testing.T) { } } +// TestToolsServiceCreateVisibleToClients verifies the tri-state +// visible_to_clients flag reaches the wire correctly on create: nil omits the +// key, true is sent verbatim, and an explicit false is sent (not dropped). +func TestToolsServiceCreateVisibleToClients(t *testing.T) { + const ( + accountID = "5245563" + bucketID = int64(33861629) + toolType = "Chat::Transcript" + ) + tru, fls := true, false + cases := []struct { + name string + value *bool + present bool + want bool + }{ + {"nil omits the field", nil, false, false}, + {"true is sent", &tru, true, true}, + {"explicit false is sent, not dropped", &fls, true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var receivedBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedBody = decodeRequestBody(t, r) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(loadToolsFixture(t, "create.json")) + })) + defer server.Close() + + cfg := DefaultConfig() + cfg.BaseURL = server.URL + client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}) + + _, err := client.ForAccount(accountID).Tools().Create( + context.Background(), + bucketID, + toolType, + &CreateToolOptions{VisibleToClients: tc.value}, + ) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + val, ok := receivedBody["visible_to_clients"] + if ok != tc.present { + t.Fatalf("visible_to_clients present=%v, want %v (body=%v)", ok, tc.present, receivedBody) + } + if tc.present && val != tc.want { + t.Errorf("visible_to_clients=%v, want %v", val, tc.want) + } + }) + } +} + func TestToolsServiceCreateEmptyToolType(t *testing.T) { var requestCount int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index 500d668d..a9864f39 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -715,6 +715,9 @@ type CreateToolRequestContent struct { // ToolType Tool type to add to the project dock. Values: Chat::Transcript|Inbox|Kanban::Board|Message::Board|Questionnaire|Schedule|Todoset|Vault. ToolType string `json:"tool_type"` + + // VisibleToClients Create the tool already visible to clients. Honored only for tool types that manage their own client visibility (Chat::Transcript, Kanban::Board), which otherwise start hidden; every other tool type ignores it and inherits the project default. + VisibleToClients *bool `json:"visible_to_clients,omitempty"` } // CreateToolResponseContent defines model for CreateToolResponseContent. diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt index b3c76532..50595515 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt @@ -613,7 +613,8 @@ data class RepositionTodoBody( /** Request body for CreateTool. */ data class CreateToolBody( val toolType: String, - val title: String? = null + val title: String? = null, + val visibleToClients: Boolean? = null ) /** Request body for UpdateTool. */ diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt index 4915b49a..9c193c5b 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt @@ -30,6 +30,7 @@ class ToolsService(client: AccountClient) : BaseService(client) { httpPost("/buckets/${bucketId}/dock/tools.json", json.encodeToString(kotlinx.serialization.json.buildJsonObject { put("tool_type", kotlinx.serialization.json.JsonPrimitive(body.toolType)) body.title?.let { put("title", kotlinx.serialization.json.JsonPrimitive(it)) } + body.visibleToClients?.let { put("visible_to_clients", kotlinx.serialization.json.JsonPrimitive(it)) } }), operationName = info.operation) }) { body -> json.decodeFromString(body) diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ToolsServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ToolsServiceTest.kt index c8f91312..a5a711c9 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ToolsServiceTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/ToolsServiceTest.kt @@ -94,4 +94,35 @@ class ToolsServiceTest { client.close() } + + // visibleToClients is tri-state: null omits the key, true/false are sent + // verbatim. An explicit false must reach the wire. Only Chat::Transcript and + // Kanban::Board honor it; all other tool types ignore it. + @Test + fun createToolSendsVisibleToClientsTriState() = runTest { + var capturedBody: String? = null + val client = mockClient { request -> + capturedBody = request.body.toByteArray().decodeToString() + respond( + content = toolJson(802, "Campfire"), + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + val account = client.forAccount("12345") + + account.tools.create(bucketId = 456, body = CreateToolBody(toolType = "Chat::Transcript")) + assertFalse(json.parseToJsonElement(capturedBody!!).jsonObject.containsKey("visible_to_clients")) + + account.tools.create(bucketId = 456, body = CreateToolBody(toolType = "Chat::Transcript", visibleToClients = true)) + val trueObj = json.parseToJsonElement(capturedBody!!).jsonObject + assertEquals(true, trueObj["visible_to_clients"]!!.jsonPrimitive.content.toBoolean()) + + account.tools.create(bucketId = 456, body = CreateToolBody(toolType = "Chat::Transcript", visibleToClients = false)) + val falseObj = json.parseToJsonElement(capturedBody!!).jsonObject + assertTrue(falseObj.containsKey("visible_to_clients")) + assertEquals(false, falseObj["visible_to_clients"]!!.jsonPrimitive.content.toBoolean()) + + client.close() + } } diff --git a/openapi.json b/openapi.json index 34354659..6e6db0db 100644 --- a/openapi.json +++ b/openapi.json @@ -23490,6 +23490,11 @@ "title": { "type": "string", "description": "Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type." + }, + "visible_to_clients": { + "type": "boolean", + "description": "Create the tool already visible to clients. Honored only for tool types that manage their own client visibility (Chat::Transcript, Kanban::Board), which otherwise start hidden; every other tool type ignores it and inherits the project default.", + "x-go-type-skip-optional-pointer": false } }, "required": [ diff --git a/python/src/basecamp/generated/services/tools.py b/python/src/basecamp/generated/services/tools.py index 04377630..b73a0eb2 100644 --- a/python/src/basecamp/generated/services/tools.py +++ b/python/src/basecamp/generated/services/tools.py @@ -11,12 +11,14 @@ class ToolsService(BaseService): - def create(self, *, bucket_id: int, tool_type: str, title: str | None = None) -> dict[str, Any]: + def create( + self, *, bucket_id: int, tool_type: str, title: str | None = None, visible_to_clients: bool | None = None + ) -> dict[str, Any]: return self._request( OperationInfo(service="tools", operation="create", is_mutation=True, resource_id=bucket_id), "POST", f"/buckets/{bucket_id}/dock/tools.json", - json_body=self._compact(tool_type=tool_type, title=title), + json_body=self._compact(tool_type=tool_type, title=title, visible_to_clients=visible_to_clients), operation="CreateTool", ) @@ -71,12 +73,14 @@ def disable(self, *, tool_id: int) -> None: class AsyncToolsService(AsyncBaseService): - async def create(self, *, bucket_id: int, tool_type: str, title: str | None = None) -> dict[str, Any]: + async def create( + self, *, bucket_id: int, tool_type: str, title: str | None = None, visible_to_clients: bool | None = None + ) -> dict[str, Any]: return await self._request( OperationInfo(service="tools", operation="create", is_mutation=True, resource_id=bucket_id), "POST", f"/buckets/{bucket_id}/dock/tools.json", - json_body=self._compact(tool_type=tool_type, title=title), + json_body=self._compact(tool_type=tool_type, title=title, visible_to_clients=visible_to_clients), operation="CreateTool", ) diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index 3ded7446..1bc59baf 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -524,6 +524,7 @@ class CreateTodolistRequestContent(TypedDict): class CreateToolRequestContent(TypedDict): title: NotRequired[str] tool_type: str + visible_to_clients: NotRequired[bool] class CreateUploadRequestContent(TypedDict): diff --git a/python/tests/services/test_tools.py b/python/tests/services/test_tools.py index 46c99f88..7dd77aca 100644 --- a/python/tests/services/test_tools.py +++ b/python/tests/services/test_tools.py @@ -57,6 +57,27 @@ def test_create_omits_title_when_not_provided(self): assert route.called assert json.loads(route.calls[0].request.content) == {"tool_type": "Message::Board"} + @respx.mock + def test_create_visible_to_clients_tristate(self): + # visible_to_clients is tri-state: unset omits the key (_compact drops None), + # true/false are sent verbatim. An explicit false must reach the wire. Only + # Chat::Transcript and Kanban::Board honor it; all other tool types ignore it. + route = respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( + return_value=httpx.Response(201, json=_tool()) + ) + account = Client(access_token="test-token").for_account("12345") + + account.tools.create(bucket_id=456, tool_type="Chat::Transcript") + assert "visible_to_clients" not in json.loads(route.calls[-1].request.content) + + account.tools.create(bucket_id=456, tool_type="Chat::Transcript", visible_to_clients=True) + assert json.loads(route.calls[-1].request.content)["visible_to_clients"] is True + + account.tools.create(bucket_id=456, tool_type="Chat::Transcript", visible_to_clients=False) + body = json.loads(route.calls[-1].request.content) + assert "visible_to_clients" in body + assert body["visible_to_clients"] is False + @respx.mock def test_create_raises_validation_error_on_422(self): route = respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( @@ -107,6 +128,27 @@ async def test_create_omits_title_when_not_provided(self): assert route.called assert json.loads(route.calls[0].request.content) == {"tool_type": "Message::Board"} + @pytest.mark.asyncio + @respx.mock + async def test_create_visible_to_clients_tristate(self): + # Async counterpart of the sync tri-state test: unset omits the key, + # true/false are sent verbatim, and an explicit false reaches the wire. + route = respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( + return_value=httpx.Response(201, json=_tool()) + ) + account = AsyncClient(access_token="test-token").for_account("12345") + + await account.tools.create(bucket_id=456, tool_type="Chat::Transcript") + assert "visible_to_clients" not in json.loads(route.calls[-1].request.content) + + await account.tools.create(bucket_id=456, tool_type="Chat::Transcript", visible_to_clients=True) + assert json.loads(route.calls[-1].request.content)["visible_to_clients"] is True + + await account.tools.create(bucket_id=456, tool_type="Chat::Transcript", visible_to_clients=False) + body = json.loads(route.calls[-1].request.content) + assert "visible_to_clients" in body + assert body["visible_to_clients"] is False + @pytest.mark.asyncio @respx.mock async def test_create_raises_validation_error_on_422(self): diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 95b1fd86..888c22ba 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-25T06:42:38Z", + "generated": "2026-07-25T06:50:19Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/services/tools_service.rb b/ruby/lib/basecamp/generated/services/tools_service.rb index 844f81e9..2212adbc 100644 --- a/ruby/lib/basecamp/generated/services/tools_service.rb +++ b/ruby/lib/basecamp/generated/services/tools_service.rb @@ -11,10 +11,11 @@ class ToolsService < BaseService # @param bucket_id [Integer] bucket id ID # @param tool_type [String] Tool type to add to the project dock. Values: Chat::Transcript|Inbox|Kanban::Board|Message::Board|Questionnaire|Schedule|Todoset|Vault. # @param title [String, nil] Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type. + # @param visible_to_clients [Boolean, nil] Create the tool already visible to clients. Honored only for tool types that manage their own client visibility (Chat::Transcript, Kanban::Board), which otherwise start hidden; every other tool type ignores it and inherits the project default. # @return [Hash] response data - def create(bucket_id:, tool_type:, title: nil) + def create(bucket_id:, tool_type:, title: nil, visible_to_clients: nil) with_operation(service: "tools", operation: "create", is_mutation: true, resource_id: bucket_id) do - http_post("/buckets/#{bucket_id}/dock/tools.json", body: compact_params(tool_type: tool_type, title: title)).json + http_post("/buckets/#{bucket_id}/dock/tools.json", body: compact_params(tool_type: tool_type, title: title, visible_to_clients: visible_to_clients)).json end end diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index ad2616a6..7b8d3641 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-25T06:42:38Z +# Generated: 2026-07-25T06:50:19Z require "json" require "time" diff --git a/ruby/test/basecamp/services/tools_service_test.rb b/ruby/test/basecamp/services/tools_service_test.rb index 599a5340..b737272a 100644 --- a/ruby/test/basecamp/services/tools_service_test.rb +++ b/ruby/test/basecamp/services/tools_service_test.rb @@ -42,6 +42,43 @@ def test_create_omits_title_when_not_provided assert_equal "Message Board", result["name"] end + # visible_to_clients is tri-state: unset omits the key (compact_params drops nil), + # true/false are sent verbatim. An explicit false must reach the wire. Only + # Chat::Transcript and Kanban::Board honor it; all other tool types ignore it. + def test_create_omits_visible_to_clients_when_unset + response = { "id" => 3, "name" => "Campfire" } + stub_request(:post, %r{https://3\.basecampapi\.com/12345/buckets/456/dock/tools\.json}) + .to_return(status: 201, body: response.to_json, headers: { "Content-Type" => "application/json" }) + + @account.tools.create(bucket_id: 456, tool_type: "Chat::Transcript") + + assert_requested(:post, "https://3.basecampapi.com/12345/buckets/456/dock/tools.json") do |req| + !JSON.parse(req.body).key?("visible_to_clients") + end + end + + def test_create_sends_visible_to_clients_true + response = { "id" => 3, "name" => "Campfire" } + stub_request(:post, %r{https://3\.basecampapi\.com/12345/buckets/456/dock/tools\.json}) + .to_return(status: 201, body: response.to_json, headers: { "Content-Type" => "application/json" }) + + @account.tools.create(bucket_id: 456, tool_type: "Chat::Transcript", visible_to_clients: true) + + assert_requested(:post, "https://3.basecampapi.com/12345/buckets/456/dock/tools.json", + body: hash_including("visible_to_clients" => true)) + end + + def test_create_sends_visible_to_clients_false + response = { "id" => 3, "name" => "Campfire" } + stub_request(:post, %r{https://3\.basecampapi\.com/12345/buckets/456/dock/tools\.json}) + .to_return(status: 201, body: response.to_json, headers: { "Content-Type" => "application/json" }) + + @account.tools.create(bucket_id: 456, tool_type: "Chat::Transcript", visible_to_clients: false) + + assert_requested(:post, "https://3.basecampapi.com/12345/buckets/456/dock/tools.json", + body: hash_including("visible_to_clients" => false)) + end + def test_create_raises_validation_error_on_422 stub_request(:post, %r{https://3\.basecampapi\.com/12345/buckets/456/dock/tools\.json}) .to_return( diff --git a/spec/api-gaps/README.md b/spec/api-gaps/README.md index 3ca219ae..0df96514 100644 --- a/spec/api-gaps/README.md +++ b/spec/api-gaps/README.md @@ -57,7 +57,7 @@ making the absorption journey publicly auditable. | [rich-text-attachments-coverage](rich-text-attachments-coverage.md) | absorbed-in-sdk | n/a | medium | | [visible-to-clients-on-creates](visible-to-clients-on-creates.md) | absorbed-in-sdk | post-train | medium | | [external-links-doors](external-links-doors.md) | addressed-in-bc3-pr-12375 | post-train | low | -| [dock-tool-visible-to-clients](dock-tool-visible-to-clients.md) | addressed-in-bc3-pr-12386 | post-train | low | +| [dock-tool-visible-to-clients](dock-tool-visible-to-clients.md) | absorbed-in-sdk | post-train | low | | [card-table-wormholes](card-table-wormholes.md) | absorbed-in-sdk | post-train | medium | > Statuses reflect how BC3's **BC5 API train** actually shipped (8 PRs merged diff --git a/spec/api-gaps/dock-tool-visible-to-clients.md b/spec/api-gaps/dock-tool-visible-to-clients.md index 2ec1fad9..243564c9 100644 --- a/spec/api-gaps/dock-tool-visible-to-clients.md +++ b/spec/api-gaps/dock-tool-visible-to-clients.md @@ -1,9 +1,11 @@ --- gap: dock-tool-visible-to-clients -status: addressed-in-bc3-pr-12386 +status: absorbed-in-sdk detected: 2026-07-23 sdk_demand: low bc3_pr: 12386 +smithy_refs: + - "CreateToolInput.visible_to_clients member (spec/basecamp.smithy:7227)" bc3_refs: introduced_in: master routes: @@ -40,13 +42,14 @@ The contract is narrower than the content creates: `Recording::VisibleToClientsParam` and gates it on `tool.changes_client_visibility?`. -The SDK's `CreateTool` request type does not yet expose `visible_to_clients`, so -consumers can't set a Chat/Kanban tool's client visibility at create time. +The SDK's `CreateTool` request type now exposes `visible_to_clients` (added as a +tri-state optional on `CreateToolInput`), so consumers can set a Chat/Kanban +tool's client visibility at create time. -This entry **registers** the shipped contract; it does **not** absorb it. It is -kept separate from the six-content-create absorption on purpose: `CreateTool` is -a distinct dock-tool operation with a narrower (two-tool-type) honoring rule, so -folding it in would both widen scope and blur the semantics. +This absorption is kept separate from the six-content-create absorption on +purpose: `CreateTool` is a distinct dock-tool operation with a narrower +(two-tool-type) honoring rule, so folding it in would both widen scope and blur +the semantics. ## Why it matters @@ -73,9 +76,11 @@ Nothing further is required of BC3. ## SDK absorption plan when this lands -Deferred to a follow-up. When absorbed, add optional `visible_to_clients` to the -Smithy `CreateToolInput`, regenerate, add the Go hand-wrapper field + pass-through -(CreateTool has a hand-written wrapper like the content creates), and add a -tri-state transport test asserting explicit `false` reaches the wire — then flip -this entry to `absorbed-in-sdk` with the Smithy ref. Until then it stays -`addressed-in-bc3-pr-12386` as a register-only record. +Absorbed (basecamp-sdk PR-1 of the post-#401 follow-up program). Added optional +`visible_to_clients: Boolean` to the Smithy `CreateToolInput`, regenerated, added +the Go hand-wrapper field (`CreateToolOptions.VisibleToClients *bool`) with a +tri-state pass-through in `ToolsService.Create`, and added a Go tri-state +transport test plus one body-encoding test in each non-Go SDK +(TS/Ruby/Python/Kotlin/Swift) asserting an explicit `false` reaches the wire. +The narrower two-tool-type honoring rule is documented on the Smithy member and +the Go option field. diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index bd486df2..8d293fcc 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -7222,6 +7222,9 @@ structure CreateToolInput { /// Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type. title: String + + /// Create the tool already visible to clients. Honored only for tool types that manage their own client visibility (Chat::Transcript, Kanban::Board), which otherwise start hidden; every other tool type ignores it and inherits the project default. + visible_to_clients: Boolean } structure CreateToolOutput { diff --git a/swift/Sources/Basecamp/Generated/Models/CreateToolRequest.swift b/swift/Sources/Basecamp/Generated/Models/CreateToolRequest.swift index 9af46aba..8b7db7e0 100644 --- a/swift/Sources/Basecamp/Generated/Models/CreateToolRequest.swift +++ b/swift/Sources/Basecamp/Generated/Models/CreateToolRequest.swift @@ -4,9 +4,11 @@ import Foundation public struct CreateToolRequest: Codable, Sendable { public var title: String? public let toolType: String + public var visibleToClients: Bool? - public init(title: String? = nil, toolType: String) { + public init(title: String? = nil, toolType: String, visibleToClients: Bool? = nil) { self.title = title self.toolType = toolType + self.visibleToClients = visibleToClients } } diff --git a/swift/Tests/BasecampTests/GeneratedServiceTests.swift b/swift/Tests/BasecampTests/GeneratedServiceTests.swift index 93bbe48d..d227e7cf 100644 --- a/swift/Tests/BasecampTests/GeneratedServiceTests.swift +++ b/swift/Tests/BasecampTests/GeneratedServiceTests.swift @@ -466,6 +466,39 @@ final class GeneratedServiceTests: XCTestCase { XCTAssertNil(sentJSON["title"]) } + // visibleToClients is tri-state: nil omits the key (encodeIfPresent), true/false + // are sent verbatim. An explicit false must reach the wire, not be dropped. Only + // Chat::Transcript and Kanban::Board honor it; all other tool types ignore it. + private func sentToolBody(visibleToClients: Bool?) async throws -> [String: Any] { + let responseJSON: [String: Any] = [ + "id": 802, "name": "chat", "title": "Campfire", "enabled": true, + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z", + ] + let transport = MockTransport(statusCode: 201, data: try JSONSerialization.data(withJSONObject: responseJSON)) + let account = makeTestAccountClient(transport: transport) + _ = try await account.tools.create( + bucketId: 456, + req: CreateToolRequest(toolType: "Chat::Transcript", visibleToClients: visibleToClients) + ) + return try JSONSerialization.jsonObject(with: transport.lastRequest!.request.httpBody!) as! [String: Any] + } + + func testToolsServiceCreateOmitsVisibleToClientsWhenNil() async throws { + let sentJSON = try await sentToolBody(visibleToClients: nil) + XCTAssertNil(sentJSON["visible_to_clients"], "nil must omit the key") + } + + func testToolsServiceCreateSendsVisibleToClientsTrue() async throws { + let sentJSON = try await sentToolBody(visibleToClients: true) + XCTAssertEqual(sentJSON["visible_to_clients"] as? Bool, true) + } + + func testToolsServiceCreateSendsVisibleToClientsFalse() async throws { + let sentJSON = try await sentToolBody(visibleToClients: false) + XCTAssertNotNil(sentJSON["visible_to_clients"], "explicit false must be sent, not dropped") + XCTAssertEqual(sentJSON["visible_to_clients"] as? Bool, false) + } + // MARK: - Campfire line operations private func campfireLineJSON(id: Int, content: String) -> [String: Any] { diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 8e50a2c5..c86eb2bf 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-25T06:42:37.878Z", + "generated": "2026-07-25T06:50:18.441Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 932071c0..1a05eb63 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -21112,6 +21112,11 @@ "title": { "type": "string", "description": "Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type." + }, + "visible_to_clients": { + "type": "boolean", + "description": "Create the tool already visible to clients. Honored only for tool types that manage their own client visibility (Chat::Transcript, Kanban::Board), which otherwise start hidden; every other tool type ignores it and inherits the project default.", + "x-go-type-skip-optional-pointer": false } }, "required": [ diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index f176cf38..1594ec53 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -3148,6 +3148,8 @@ export interface components { tool_type: string; /** @description Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type. */ title?: string; + /** @description Create the tool already visible to clients. Honored only for tool types that manage their own client visibility (Chat::Transcript, Kanban::Board), which otherwise start hidden; every other tool type ignores it and inherits the project default. */ + visible_to_clients?: boolean; }; CreateToolResponseContent: components["schemas"]["Tool"]; CreateUploadRequestContent: { diff --git a/typescript/src/generated/services/tools.ts b/typescript/src/generated/services/tools.ts index 99431dc8..df0d0d15 100644 --- a/typescript/src/generated/services/tools.ts +++ b/typescript/src/generated/services/tools.ts @@ -23,6 +23,8 @@ export interface CreateToolRequest { toolType: string; /** Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type. */ title?: string; + /** Create the tool already visible to clients. Honored only for tool types that manage their own client visibility (Chat::Transcript, Kanban::Board), which otherwise start hidden; every other tool type ignores it and inherits the project default. */ + visibleToClients?: boolean; } /** @@ -83,6 +85,7 @@ export class ToolsService extends BaseService { body: { tool_type: req.toolType, title: req.title, + visible_to_clients: req.visibleToClients, }, }) ); diff --git a/typescript/tests/services/tools.test.ts b/typescript/tests/services/tools.test.ts index d18915cd..7df7a36b 100644 --- a/typescript/tests/services/tools.test.ts +++ b/typescript/tests/services/tools.test.ts @@ -117,6 +117,36 @@ describe("ToolsService", () => { it("requires a tool type", async () => { await expect(client.tools.create(456, { toolType: "" })).rejects.toThrow(BasecampError); }); + + // visibleToClients is tri-state: undefined omits the key, true/false are sent + // verbatim. An explicit false must reach the wire (not be dropped). Only + // Chat::Transcript and Kanban::Board honor it; all other tool types ignore it. + it("should send visible_to_clients tri-state in request body", async () => { + const bucketId = 456; + const toolType = "Chat::Transcript"; + const mockTool = { id: 335, name: "chat", title: "Campfire", enabled: true, position: 5 }; + let capturedBody: Record = {}; + + server.use( + http.post( + `${BASE_URL}/buckets/${bucketId}/dock/tools.json`, + async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json(mockTool, { status: 201 }); + } + ) + ); + + await client.tools.create(bucketId, { toolType }); + expect("visible_to_clients" in capturedBody).toBe(false); + + await client.tools.create(bucketId, { toolType, visibleToClients: true }); + expect(capturedBody.visible_to_clients).toBe(true); + + await client.tools.create(bucketId, { toolType, visibleToClients: false }); + expect("visible_to_clients" in capturedBody).toBe(true); + expect(capturedBody.visible_to_clients).toBe(false); + }); }); describe("update", () => {