Skip to content

Commit a86468f

Browse files
committed
feat(cbinsights): add CB Insights API v2 integration
Covers every non-streaming v2 endpoint across 25 tools: free organization lookup, firmographics search, funding rounds and cap tables, investments, portfolio exits, business relationships, management and board, the Mosaic / Commercial Maturity / Exit Probability outlooks and their histories, funding windows, revenue, strategy maps, Scouting Reports, ChatCBI, and RAG context. CB Insights authorizes by client-credential exchange rather than a static key, so the tools run through directExecution: the shared executor trades the credentials for a bearer token, caches it briefly, and re-authorizes once on a 401 — the token lifetime is undocumented, so expiry is discovered rather than predicted. ChatCBI and RAG declare request.modelInput so an activated Sim secret in the message is projected to its canonical label before reaching a third party's model. directExecution still runs projectToolModelInputParams, so the two are compatible. The two streaming endpoints are deliberately excluded; they deliver incremental JSON chunks and their non-streaming counterparts return the same content in one piece.
1 parent 5bc2955 commit a86468f

43 files changed

Lines changed: 5542 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5695,6 +5695,22 @@ export function Neo4jIcon(props: SVGProps<SVGSVGElement>) {
56955695
)
56965696
}
56975697

5698+
export function CbInsightsIcon(props: SVGProps<SVGSVGElement>) {
5699+
return (
5700+
<svg {...props} viewBox='0 0 64 64' fill='none' xmlns='http://www.w3.org/2000/svg'>
5701+
<path d='M63.858 28.878H44V9.58h11.78A8.24 8.24 0 0 1 64 17.809v11.07z' fill='#006699' />
5702+
<path
5703+
d='M55.628 54.28H43.85V33.703h19.867V46.05c.142 4.54-3.548 8.23-8.1 8.23z'
5704+
fill='#ff6633'
5705+
/>
5706+
<path
5707+
d='M20.718 33.703v-4.967h19.44V9.58H8.1c-4.54 0-8.1 3.7-8.1 8.23V46.2a8.24 8.24 0 0 0 8.231 8.231H40.3V33.703z'
5708+
fill='#003366'
5709+
/>
5710+
</svg>
5711+
)
5712+
}
5713+
56985714
export function CalendlyIcon(props: SVGProps<SVGSVGElement>) {
56995715
return (
57005716
<svg {...props} viewBox='169.28 46.16 502.57 502.57' xmlns='http://www.w3.org/2000/svg'>

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
BufferIcon,
3434
CalComIcon,
3535
CalendlyIcon,
36+
CbInsightsIcon,
3637
CirclebackIcon,
3738
ClaudeIcon,
3839
ClayIcon,
@@ -294,6 +295,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
294295
buffer: BufferIcon,
295296
calcom: CalComIcon,
296297
calendly: CalendlyIcon,
298+
cbinsights: CbInsightsIcon,
297299
circleback: CirclebackIcon,
298300
clay: ClayIcon,
299301
clerk: ClerkIcon,

apps/docs/content/docs/en/integrations/cbinsights.mdx

Lines changed: 611 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"calcom",
3535
"calcom-service-account",
3636
"calendly",
37+
"cbinsights",
3738
"circleback",
3839
"clay",
3940
"clerk",
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { CbInsightsBlock } from '@/blocks/blocks/cbinsights'
6+
7+
/**
8+
* Every assertion here runs against `{ ...inputs, ...buildParams(inputs) }`, the
9+
* shape the generic tool handler actually forwards. A key the mapper omits is
10+
* *not* dropped by that merge — the raw subBlock value survives — so asserting
11+
* on the mapper's return alone would prove nothing about what the tool receives.
12+
*/
13+
describe('CbInsightsBlock', () => {
14+
const buildParams = CbInsightsBlock.tools.config!.params!
15+
const selectTool = CbInsightsBlock.tools.config!.tool!
16+
17+
const resolve = (inputs: Record<string, unknown>) => ({ ...inputs, ...buildParams(inputs) })
18+
19+
const operationIds =
20+
CbInsightsBlock.subBlocks
21+
.find((subBlock) => subBlock.id === 'operation')
22+
?.options?.map((option) => (option as { id: string }).id) ?? []
23+
24+
it('maps every dropdown operation onto a registered tool', () => {
25+
expect(operationIds).toHaveLength(25)
26+
expect(new Set(operationIds.map((id) => selectTool({ operation: id })))).toEqual(
27+
new Set(CbInsightsBlock.tools.access)
28+
)
29+
})
30+
31+
it('rejects an operation the dropdown does not offer', () => {
32+
expect(() => selectTool({ operation: 'get_org_secrets' })).toThrow(
33+
/Invalid CB Insights operation/
34+
)
35+
})
36+
37+
it('gives every subblock a unique id', () => {
38+
const ids = CbInsightsBlock.subBlocks.map((subBlock) => subBlock.id)
39+
expect(ids).toHaveLength(new Set(ids).size)
40+
})
41+
42+
it('never hides a required field behind advanced mode', () => {
43+
const advancedRequired = CbInsightsBlock.subBlocks
44+
.filter((subBlock) => subBlock.mode === 'advanced' && subBlock.required)
45+
.map((subBlock) => subBlock.id)
46+
47+
expect(advancedRequired).toEqual([])
48+
})
49+
50+
it('always forwards the credentials', () => {
51+
const params = resolve({ operation: 'chat', clientId: 'id', clientSecret: 'secret' })
52+
expect(params.clientId).toBe('id')
53+
expect(params.clientSecret).toBe('secret')
54+
})
55+
56+
/*
57+
* The mapper's whole job is dropping keys that belong to another operation.
58+
* `shouldSerializeSubBlock` short-circuits on `mode: 'advanced'` before it
59+
* evaluates a condition, so a hidden advanced field still reaches `inputs` —
60+
* only an explicit `undefined` removes it from what goes out on the wire.
61+
*/
62+
it('drops a previous operation’s leftovers when the operation changes', () => {
63+
const params = resolve({
64+
operation: 'chat',
65+
clientId: 'id',
66+
clientSecret: 'secret',
67+
message: 'What is growing?',
68+
orgId: '129410',
69+
orgIds: '1,2,3',
70+
keyword: 'fintech',
71+
titleIds: '50',
72+
startDate: '2025-01-01',
73+
endDate: '2025-06-01',
74+
limit: '50',
75+
nextPageToken: 'stale-token',
76+
names: 'CB Insights',
77+
sortField: 'mosaicOverall',
78+
minCurrentHeadcount: '10',
79+
})
80+
81+
expect(params.message).toBe('What is growing?')
82+
expect(params.orgId).toBeUndefined()
83+
expect(params.orgIds).toBeUndefined()
84+
expect(params.keyword).toBeUndefined()
85+
expect(params.titleIds).toBeUndefined()
86+
expect(params.startDate).toBeUndefined()
87+
expect(params.endDate).toBeUndefined()
88+
expect(params.limit).toBeUndefined()
89+
expect(params.nextPageToken).toBeUndefined()
90+
expect(params.names).toBeUndefined()
91+
expect(params.sortField).toBeUndefined()
92+
expect(params.minCurrentHeadcount).toBeUndefined()
93+
})
94+
95+
/*
96+
* Lookup and firmographics both filter on organization IDs and websites, but
97+
* the endpoints take different shapes, so the block gives each its own
98+
* subblock and the mapper folds them onto the single tool param.
99+
*/
100+
it('folds the firmographics-only aliases onto the shared tool params', () => {
101+
const firmographics = resolve({
102+
operation: 'search_firmographics',
103+
clientId: 'id',
104+
clientSecret: 'secret',
105+
orgIdFilter: '129410, 129411',
106+
firmographicsUrls: 'cbinsights.com',
107+
urls: 'ignored-lookup-value.com',
108+
orgIds: 'ignored-list-value',
109+
})
110+
111+
expect(firmographics.orgIds).toBe('129410, 129411')
112+
expect(firmographics.urls).toBe('cbinsights.com')
113+
expect(firmographics.orgIdFilter).toBeUndefined()
114+
expect(firmographics.firmographicsUrls).toBeUndefined()
115+
116+
const lookup = resolve({
117+
operation: 'lookup_organizations',
118+
clientId: 'id',
119+
clientSecret: 'secret',
120+
urls: 'cbinsights.com',
121+
orgIdFilter: '129410',
122+
firmographicsUrls: 'wrong.com',
123+
})
124+
125+
expect(lookup.urls).toBe('cbinsights.com')
126+
expect(lookup.orgIds).toBeUndefined()
127+
})
128+
129+
it('sends the organization ID only to path-scoped operations', () => {
130+
const scoped = resolve({
131+
operation: 'get_org_outlook',
132+
clientId: 'id',
133+
clientSecret: 'secret',
134+
orgId: '129410',
135+
orgIds: '1,2',
136+
})
137+
expect(scoped.orgId).toBe('129410')
138+
expect(scoped.orgIds).toBeUndefined()
139+
140+
const listed = resolve({
141+
operation: 'list_outlook',
142+
clientId: 'id',
143+
clientSecret: 'secret',
144+
orgId: '129410',
145+
orgIds: '1,2',
146+
})
147+
expect(listed.orgId).toBeUndefined()
148+
expect(listed.orgIds).toBe('1,2')
149+
})
150+
151+
/*
152+
* Mosaic history takes a start date but no end date; the other two history
153+
* endpoints take both. Sending endDate to Mosaic history would be an
154+
* undocumented field on the wire.
155+
*/
156+
it('sends only the date fields each history endpoint documents', () => {
157+
const mosaic = resolve({
158+
operation: 'get_mosaic_history',
159+
clientId: 'id',
160+
clientSecret: 'secret',
161+
orgId: '1',
162+
startDate: '2025-01-01',
163+
endDate: '2025-06-01',
164+
})
165+
expect(mosaic.startDate).toBe('2025-01-01')
166+
expect(mosaic.endDate).toBeUndefined()
167+
168+
const maturity = resolve({
169+
operation: 'get_commercial_maturity_history',
170+
clientId: 'id',
171+
clientSecret: 'secret',
172+
orgId: '1',
173+
startDate: '2025-01-01',
174+
endDate: '2025-06-01',
175+
})
176+
expect(maturity.startDate).toBe('2025-01-01')
177+
expect(maturity.endDate).toBe('2025-06-01')
178+
})
179+
180+
/*
181+
* Business relationships pages with a token but documents no `limit`, so
182+
* carrying one over from another operation would put an undocumented field on
183+
* the wire.
184+
*/
185+
it('sends limit only to the operations whose endpoint documents it', () => {
186+
const relationships = resolve({
187+
operation: 'list_business_relationships',
188+
clientId: 'id',
189+
clientSecret: 'secret',
190+
orgIds: '1,2',
191+
limit: '50',
192+
nextPageToken: 'tok',
193+
})
194+
expect(relationships.limit).toBeUndefined()
195+
expect(relationships.nextPageToken).toBe('tok')
196+
197+
const fundings = resolve({
198+
operation: 'list_fundings',
199+
clientId: 'id',
200+
clientSecret: 'secret',
201+
orgIds: '1,2',
202+
limit: '50',
203+
})
204+
expect(fundings.limit).toBe('50')
205+
})
206+
})

0 commit comments

Comments
 (0)