Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions go/pkg/basecamp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions go/pkg/basecamp/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions go/pkg/generated/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tool>(body)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
5 changes: 5 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
12 changes: 8 additions & 4 deletions python/src/basecamp/generated/services/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Expand Down Expand Up @@ -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",
)

Expand Down
1 change: 1 addition & 0 deletions python/src/basecamp/generated/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ class CreateTodolistRequestContent(TypedDict):
class CreateToolRequestContent(TypedDict):
title: NotRequired[str]
tool_type: str
visible_to_clients: NotRequired[bool]


class CreateUploadRequestContent(TypedDict):
Expand Down
42 changes: 42 additions & 0 deletions python/tests/services/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
# 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(
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion ruby/lib/basecamp/generated/metadata.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 3 additions & 2 deletions ruby/lib/basecamp/generated/services/tools_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion ruby/lib/basecamp/generated/types.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
37 changes: 37 additions & 0 deletions ruby/test/basecamp/services/tools_service_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion spec/api-gaps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading