Skip to content

Commit 4eb7645

Browse files
committed
fix(granola): correct array output schemas, listing-truncation signal, and docs
Findings from validation passes over the tools, trigger, and connector. Tools — array outputs were declared as `type: 'json'` with `properties`, which describes an object, not an array. Agents and the output picker therefore saw `notes.title` instead of `notes[i].title`. All 15 array outputs (including the pre-existing three tools) now use `type: 'array'` with `items`, matching the 2000+ other tool files. The audit event `data` field stays `json`; it is genuinely free-form per the spec. Connector — `hasMore` was ANDed with the cursor, so a `hasMore: true` response with no cursor was reported as a complete listing. The sync engine treats exactly that shape as truncated and sets `listingTruncated` to block deletion reconciliation; masking it meant a partial first page could be taken for the whole corpus and reconciliation would hard-delete every note past it. Granola would have to violate its own contract to emit that shape, but the engine already handles it and the connector was hiding the signal. Also aligns mimeType with the `.txt`/text-plain bytes the engine actually writes (it was the only connector of 101 claiming text/markdown). Trigger — the setup instructions named a Granola settings path that does not exist; the help center says Settings > Connectors > API keys in the desktop app. Both list parsers now split commas inside array entries, so an array-wrapped free-text value cannot be sent as one malformed identifier. Block — `id`, `events`, and `hasMore` are produced by several operations but their descriptions named only one, unlike `folders` which already documented both meanings. Adds connector tests pinning all four listingCapped quadrants and the truncation signal, and tool tests for the list parser and the PATCH body's per-field "omit means unchanged" semantics.
1 parent 1f7f5bd commit 4eb7645

19 files changed

Lines changed: 560 additions & 189 deletions

File tree

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

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Lists meeting notes from Granola with optional date filters and pagination.
5151

5252
| Parameter | Type | Description |
5353
| --------- | ---- | ----------- |
54-
| `notes` | json | List of meeting notes |
54+
| `notes` | array | List of meeting notes |
5555
|`id` | string | Note ID |
5656
|`title` | string | Note title |
5757
|`ownerName` | string | Note owner name |
@@ -86,19 +86,19 @@ Retrieves a specific meeting note from Granola by ID, including summary, attende
8686
| `webUrl` | string | URL to view the note in Granola |
8787
| `summaryText` | string | Plain text summary of the meeting |
8888
| `summaryMarkdown` | string | Markdown-formatted summary of the meeting |
89-
| `attendees` | json | Meeting attendees |
89+
| `attendees` | array | Meeting attendees |
9090
|`name` | string | Attendee name |
9191
|`email` | string | Attendee email |
92-
| `folders` | json | Folders the note belongs to |
92+
| `folders` | array | Folders the note belongs to |
9393
|`id` | string | Folder ID |
9494
|`name` | string | Folder name |
9595
| `calendarEventTitle` | string | Calendar event title |
9696
| `calendarOrganiser` | string | Calendar event organiser email |
9797
| `calendarEventId` | string | Calendar event ID |
9898
| `scheduledStartTime` | string | Scheduled start time |
9999
| `scheduledEndTime` | string | Scheduled end time |
100-
| `invitees` | json | Calendar event invitee emails |
101-
| `transcript` | json | Meeting transcript entries \(only if requested\) |
100+
| `invitees` | array | Calendar event invitee emails |
101+
| `transcript` | array | Meeting transcript entries \(only if requested\) |
102102
|`speaker` | string | Speaker source \(microphone or speaker\) |
103103
|`speakerAttribution` | string | Who spoke relative to the note owner: "me" for the note-taker, "them" for other participants. Null when attribution is unknown. |
104104
|`speakerLabel` | string | Diarization label for the speaker \(e.g., Speaker A\) |
@@ -124,7 +124,7 @@ Retrieves a meeting transcript from Granola one page at a time, including when G
124124

125125
| Parameter | Type | Description |
126126
| --------- | ---- | ----------- |
127-
| `transcript` | json | Transcript items for this page |
127+
| `transcript` | array | Transcript items for this page |
128128
|`speaker` | string | Audio source of the speaker \(microphone or speaker\) |
129129
|`speakerAttribution` | string | Who spoke relative to the note owner: "me" for the note-taker, "them" for other participants. Null when attribution is unknown. |
130130
|`speakerLabel` | string | Anonymous diarization label for the speaker \(e.g., Speaker A\) |
@@ -151,7 +151,7 @@ Lists folders from Granola, sorted alphabetically, with pagination.
151151

152152
| Parameter | Type | Description |
153153
| --------- | ---- | ----------- |
154-
| `folders` | json | List of folders |
154+
| `folders` | array | List of folders |
155155
|`id` | string | Folder ID |
156156
|`name` | string | Folder name |
157157
|`parentFolderId` | string | Parent folder ID, or null for top-level folders |
@@ -177,7 +177,7 @@ Lists workspace audit events from Granola, with optional action and date filters
177177

178178
| Parameter | Type | Description |
179179
| --------- | ---- | ----------- |
180-
| `events` | json | List of audit events |
180+
| `events` | array | List of audit events |
181181
|`id` | string | Audit event ID |
182182
|`action` | string | The recorded action \(e.g., workspace.member_added\). Treat as an open set — actions are added over time. |
183183
|`occurredAt` | string | When the action happened |
@@ -213,9 +213,9 @@ Registers an HTTPS URL in Granola to receive note event deliveries. The signing
213213
| `id` | string | Webhook endpoint ID |
214214
| `url` | string | The HTTPS URL deliveries are sent to |
215215
| `urlRedacted` | boolean | Whether the returned URL was reduced to its origin because the caller is not the endpoint creator |
216-
| `events` | json | Event names this endpoint is subscribed to |
217-
| `folderIds` | json | Folder IDs delivery is restricted to, or an empty array when unrestricted |
218-
| `scopes` | json | Which notes this endpoint receives events for |
216+
| `events` | array | Event names this endpoint is subscribed to |
217+
| `folderIds` | array | Folder IDs delivery is restricted to, or an empty array when unrestricted |
218+
| `scopes` | array | Which notes this endpoint receives events for |
219219
| `createdByName` | string | Name of the user who created the endpoint |
220220
| `createdByEmail` | string | Email of the user who created the endpoint |
221221
| `enabled` | boolean | Whether deliveries are active |
@@ -236,13 +236,13 @@ Lists the Granola webhook endpoints the API key can manage. A personal key sees
236236

237237
| Parameter | Type | Description |
238238
| --------- | ---- | ----------- |
239-
| `webhookEndpoints` | json | List of webhook endpoints |
239+
| `webhookEndpoints` | array | List of webhook endpoints |
240240
|`id` | string | Webhook endpoint ID |
241241
|`url` | string | The HTTPS URL deliveries are sent to, reduced to its origin when urlRedacted is true |
242242
|`urlRedacted` | boolean | Whether the URL was reduced to its origin because the caller is not the endpoint creator |
243-
|`events` | json | Event names this endpoint is subscribed to |
244-
|`folderIds` | json | Folder IDs delivery is restricted to, or an empty array when unrestricted |
245-
|`scopes` | json | Which notes this endpoint receives events for |
243+
|`events` | array | Event names this endpoint is subscribed to |
244+
|`folderIds` | array | Folder IDs delivery is restricted to, or an empty array when unrestricted |
245+
|`scopes` | array | Which notes this endpoint receives events for |
246246
|`createdByName` | string | Name of the user who created the endpoint |
247247
|`createdByEmail` | string | Email of the user who created the endpoint |
248248
|`enabled` | boolean | Whether deliveries are active |
@@ -271,9 +271,9 @@ Updates a Granola webhook endpoint. Each supplied field replaces its current val
271271
| `id` | string | Webhook endpoint ID |
272272
| `url` | string | The HTTPS URL deliveries are sent to |
273273
| `urlRedacted` | boolean | Whether the returned URL was reduced to its origin because the caller is not the endpoint creator |
274-
| `events` | json | Event names this endpoint is subscribed to |
275-
| `folderIds` | json | Folder IDs delivery is restricted to, or an empty array when unrestricted |
276-
| `scopes` | json | Which notes this endpoint receives events for |
274+
| `events` | array | Event names this endpoint is subscribed to |
275+
| `folderIds` | array | Folder IDs delivery is restricted to, or an empty array when unrestricted |
276+
| `scopes` | array | Which notes this endpoint receives events for |
277277
| `createdByName` | string | Name of the user who created the endpoint |
278278
| `createdByEmail` | string | Email of the user who created the endpoint |
279279
| `enabled` | boolean | Whether deliveries are active |

apps/sim/blocks/blocks/granola.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -361,9 +361,17 @@ export const GranolaBlock: BlockConfig = {
361361
type: 'json',
362362
description: 'List of meeting notes (id, title, ownerName, ownerEmail, createdAt, updatedAt)',
363363
},
364-
hasMore: { type: 'boolean', description: 'Whether more notes are available' },
364+
hasMore: {
365+
type: 'boolean',
366+
description:
367+
'Whether another page is available, for whichever listing ran — notes, folders, audit events, or transcript items',
368+
},
365369
cursor: { type: 'string', description: 'Pagination cursor for next page' },
366-
id: { type: 'string', description: 'Note ID' },
370+
id: {
371+
type: 'string',
372+
description:
373+
'Note ID for Get Note, or the webhook endpoint ID for the create, update, and delete webhook endpoint operations',
374+
},
367375
title: { type: 'string', description: 'Note title' },
368376
ownerName: { type: 'string', description: 'Note owner name' },
369377
ownerEmail: { type: 'string', description: 'Note owner email' },
@@ -392,7 +400,7 @@ export const GranolaBlock: BlockConfig = {
392400
events: {
393401
type: 'json',
394402
description:
395-
'Audit events (id, action, occurredAt, collectedAt, actorType, actorId, actorEmail, data, ipAddress, userAgent, clientVersion)',
403+
'Audit events (id, action, occurredAt, collectedAt, actorType, actorId, actorEmail, data, ipAddress, userAgent, clientVersion) for List Audit Events, or the subscribed webhook event names for the create and update webhook endpoint operations',
396404
},
397405
webhookEndpoints: {
398406
type: 'json',
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* Pins the listing-completeness signals the sync engine relies on to decide whether it may
3+
* hard-delete stored documents. `listingCapped` and a truthful `hasMore` are the only things
4+
* standing between a partial listing and reconciliation purging the rest of the knowledge base,
5+
* so each quadrant is asserted explicitly.
6+
*
7+
* @vitest-environment node
8+
*/
9+
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
11+
const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }))
12+
13+
vi.mock('@/lib/knowledge/documents/utils', () => ({
14+
fetchWithRetry: mockFetchWithRetry,
15+
VALIDATE_RETRY_OPTIONS: {},
16+
}))
17+
vi.mock('@/components/icons', () => ({ GranolaIcon: () => null }))
18+
19+
import { granolaConnector } from '@/connectors/granola/granola'
20+
21+
function note(id: string) {
22+
return {
23+
id,
24+
object: 'note',
25+
title: `Note ${id}`,
26+
owner: { name: 'Oat Benson', email: 'oat@granola.ai' },
27+
created_at: '2026-01-27T15:30:00Z',
28+
updated_at: '2026-01-27T16:45:00Z',
29+
}
30+
}
31+
32+
/** Queue a single Granola list-notes response. */
33+
function mockListResponse(body: unknown, status = 200) {
34+
mockFetchWithRetry.mockResolvedValue({
35+
ok: status >= 200 && status < 300,
36+
status,
37+
json: async () => body,
38+
text: async () => JSON.stringify(body),
39+
} as unknown as Response)
40+
}
41+
42+
const list = (sourceConfig: Record<string, unknown>, syncContext: Record<string, unknown>) =>
43+
granolaConnector.listDocuments('tok', sourceConfig, undefined, syncContext, undefined)
44+
45+
describe('granola connector listing completeness', () => {
46+
beforeEach(() => {
47+
mockFetchWithRetry.mockReset()
48+
})
49+
50+
it("passes Granola's hasMore through with the cursor on a normal page", async () => {
51+
mockListResponse({ notes: [note('not_1')], hasMore: true, cursor: 'cur_2' })
52+
53+
const syncContext: Record<string, unknown> = {}
54+
const page = await list({}, syncContext)
55+
56+
expect(page.hasMore).toBe(true)
57+
expect(page.nextCursor).toBe('cur_2')
58+
expect(syncContext.listingCapped).toBeUndefined()
59+
})
60+
61+
it('surfaces hasMore=true with no cursor so the engine can mark the listing truncated', async () => {
62+
/**
63+
* The engine sets `listingTruncated` (which blocks deletion reconciliation outright) only when
64+
* a connector reports this shape. Collapsing it to hasMore=false would present a partial page
65+
* as the complete corpus.
66+
*/
67+
mockListResponse({ notes: [note('not_1')], hasMore: true, cursor: null })
68+
69+
const page = await list({}, {})
70+
71+
expect(page.hasMore).toBe(true)
72+
expect(page.nextCursor).toBeUndefined()
73+
})
74+
75+
it('reports a complete listing when the source is exhausted', async () => {
76+
mockListResponse({ notes: [note('not_1'), note('not_2')], hasMore: false, cursor: null })
77+
78+
const syncContext: Record<string, unknown> = {}
79+
const page = await list({}, syncContext)
80+
81+
expect(page.hasMore).toBe(false)
82+
expect(page.nextCursor).toBeUndefined()
83+
expect(syncContext.listingCapped).toBeUndefined()
84+
expect(page.documents).toHaveLength(2)
85+
})
86+
87+
it('never caps when no maxNotes is configured', async () => {
88+
mockListResponse({ notes: [note('not_1'), note('not_2')], hasMore: true, cursor: 'cur_2' })
89+
90+
const syncContext: Record<string, unknown> = {}
91+
await list({ maxNotes: '' }, syncContext)
92+
93+
expect(syncContext.listingCapped).toBeUndefined()
94+
})
95+
96+
it('caps and flags when maxNotes slices a page, hiding notes that still exist', async () => {
97+
mockListResponse({
98+
notes: [note('not_1'), note('not_2'), note('not_3')],
99+
hasMore: false,
100+
cursor: null,
101+
})
102+
103+
const syncContext: Record<string, unknown> = {}
104+
const page = await list({ maxNotes: '2' }, syncContext)
105+
106+
expect(page.documents).toHaveLength(2)
107+
expect(syncContext.listingCapped).toBe(true)
108+
expect(page.hasMore).toBe(false)
109+
})
110+
111+
it('caps and flags when maxNotes lands on a page boundary but more pages exist', async () => {
112+
mockListResponse({ notes: [note('not_1'), note('not_2')], hasMore: true, cursor: 'cur_2' })
113+
114+
const syncContext: Record<string, unknown> = {}
115+
const page = await list({ maxNotes: '2' }, syncContext)
116+
117+
expect(page.documents).toHaveLength(2)
118+
expect(syncContext.listingCapped).toBe(true)
119+
expect(page.hasMore).toBe(false)
120+
})
121+
122+
it('does NOT flag when the cap lands exactly on the last note and the source is exhausted', async () => {
123+
/**
124+
* Flagging here would block deletion reconciliation on every ordinary sync, stranding notes
125+
* deleted in Granola in the knowledge base indefinitely.
126+
*/
127+
mockListResponse({ notes: [note('not_1'), note('not_2')], hasMore: false, cursor: null })
128+
129+
const syncContext: Record<string, unknown> = {}
130+
const page = await list({ maxNotes: '2' }, syncContext)
131+
132+
expect(page.documents).toHaveLength(2)
133+
expect(syncContext.listingCapped).toBeUndefined()
134+
})
135+
136+
it('carries the cap across pages via totalDocsFetched', async () => {
137+
mockListResponse({ notes: [note('not_3'), note('not_4')], hasMore: true, cursor: 'cur_3' })
138+
139+
const syncContext: Record<string, unknown> = { totalDocsFetched: 1 }
140+
const page = await list({ maxNotes: '2' }, syncContext)
141+
142+
expect(page.documents).toHaveLength(1)
143+
expect(syncContext.totalDocsFetched).toBe(2)
144+
expect(syncContext.listingCapped).toBe(true)
145+
})
146+
})
147+
148+
describe('granola connector request shaping', () => {
149+
beforeEach(() => {
150+
mockFetchWithRetry.mockReset()
151+
})
152+
153+
it('requests the maximum page size and applies only valid scope filters', async () => {
154+
mockListResponse({ notes: [], hasMore: false, cursor: null })
155+
156+
await granolaConnector.listDocuments(
157+
'tok',
158+
{ folderId: 'not-a-folder-id', createdAfter: '2025-01-01' },
159+
undefined,
160+
{},
161+
new Date('2026-01-01T00:00:00Z')
162+
)
163+
164+
const url = new URL(mockFetchWithRetry.mock.calls[0][0] as string)
165+
expect(url.searchParams.get('page_size')).toBe('30')
166+
expect(url.searchParams.get('created_after')).toBe('2025-01-01T00:00:00.000Z')
167+
expect(url.searchParams.get('updated_after')).toBe('2026-01-01T00:00:00.000Z')
168+
/* A malformed folder id must not be sent — it would scope the sync to nothing. */
169+
expect(url.searchParams.get('folder_id')).toBeNull()
170+
})
171+
172+
it('stores content as plain text, matching the bytes the sync engine writes', async () => {
173+
mockListResponse({ notes: [note('not_1')], hasMore: false, cursor: null })
174+
175+
const page = await list({}, {})
176+
177+
expect(page.documents[0].mimeType).toBe('text/plain')
178+
expect(page.documents[0].contentDeferred).toBe(true)
179+
})
180+
})

apps/sim/connectors/granola/granola.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ function noteSummaryToStub(note: GranolaNoteSummary): ExternalDocument {
196196
title: note.title?.trim() || 'Untitled Note',
197197
content: '',
198198
contentDeferred: true,
199-
mimeType: 'text/markdown',
199+
mimeType: 'text/plain',
200200
contentHash: buildContentHash(note.id, note.updated_at),
201201
metadata: {
202202
title: note.title?.trim() || undefined,
@@ -274,7 +274,15 @@ export const granolaConnector: ConnectorConfig = {
274274
const totalFetched = prevFetched + documents.length
275275
if (syncContext) syncContext.totalDocsFetched = totalFetched
276276

277-
const sourceHasMore = Boolean(data.hasMore) && Boolean(nextCursor)
277+
/**
278+
* Report Granola's own `hasMore` verbatim rather than ANDing the cursor into
279+
* it. The sync engine treats `hasMore: true` with no cursor as a truncated
280+
* listing and sets `listingTruncated`, which blocks deletion reconciliation
281+
* outright. Collapsing that shape to `hasMore: false` here would hide the
282+
* signal and present a partial page as the complete corpus, letting
283+
* reconciliation hard-delete every note past it.
284+
*/
285+
const sourceHasMore = Boolean(data.hasMore)
278286
const hitLimit = maxNotes > 0 && totalFetched >= maxNotes
279287

280288
/**
@@ -337,7 +345,7 @@ export const granolaConnector: ConnectorConfig = {
337345
title: note.title?.trim() || 'Untitled Note',
338346
content,
339347
contentDeferred: false,
340-
mimeType: 'text/markdown',
348+
mimeType: 'text/plain',
341349
sourceUrl: note.web_url?.trim() || undefined,
342350
contentHash: buildContentHash(note.id, note.updated_at),
343351
metadata: {

apps/sim/lib/webhooks/providers/granola.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,15 @@ function verifyGranolaSignature(
7070
* Trigger fields are free text, so both forms are accepted.
7171
*/
7272
function parseList(value: unknown): string[] {
73-
if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean)
73+
const split = (entry: unknown) =>
74+
String(entry)
75+
.split(',')
76+
.map((part) => part.trim())
77+
.filter(Boolean)
78+
79+
/* Trigger config can arrive array-wrapped rather than split, so an entry may still
80+
hold a comma-separated list; split inside entries as well as across them. */
81+
if (Array.isArray(value)) return value.flatMap(split)
7482
if (typeof value !== 'string') return []
7583

7684
const trimmed = value.trim()
@@ -80,17 +88,14 @@ function parseList(value: unknown): string[] {
8088
try {
8189
const parsed: unknown = JSON.parse(trimmed)
8290
if (Array.isArray(parsed)) {
83-
return parsed.map((entry) => String(entry).trim()).filter(Boolean)
91+
return parsed.flatMap(split)
8492
}
8593
} catch {
8694
/* Fall through to comma-separated parsing. */
8795
}
8896
}
8997

90-
return trimmed
91-
.split(',')
92-
.map((entry) => entry.trim())
93-
.filter(Boolean)
98+
return split(trimmed)
9499
}
95100

96101
/** Turn a Granola API failure into a message worth showing on the deploy dialog. */

apps/sim/tools/generated/tool-ids.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/generated/tool-outputs.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)