-
Notifications
You must be signed in to change notification settings - Fork 96
feat: add create_bulk_items tool #351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
7864588
47d712f
7cc6f49
b4ddd11
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { gql } from 'graphql-request'; | ||
|
|
||
| export const ingestItemsMutation = gql` | ||
| mutation IngestItems($boardId: ID!, $groupId: ID!, $onMatch: OnMatchInput) { | ||
| ingest_items(board_id: $boardId, group_id: $groupId, on_match: $onMatch) { | ||
| job_id | ||
| upload_url | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| export const fetchJobStatusQuery = gql` | ||
| query PollJob($jobId: ID!) { | ||
| fetch_job_status(job_id: $jobId) { | ||
| ... on ItemsJobStatus { | ||
| status | ||
| progress_percentage | ||
| fully_imported | ||
| counts { | ||
| submitted | ||
| invalid | ||
| skipped | ||
| created | ||
| updated | ||
| failed | ||
| } | ||
| failure_reason | ||
| failure_message | ||
| } | ||
| } | ||
| } | ||
| `; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import { z } from 'zod'; | ||
| import { ingestItemsMutation, fetchJobStatusQuery } from './bulk-items.graphql'; | ||
|
|
||
| export interface IngestItemsResponse { | ||
| ingest_items: { | ||
| job_id: string; | ||
| upload_url: string; | ||
| }; | ||
| } | ||
|
|
||
| export interface JobStatusResponse { | ||
| fetch_job_status: { | ||
| status: string; | ||
| progress_percentage: number; | ||
| fully_imported: boolean; | ||
| counts: { | ||
| submitted: number; | ||
| invalid: number; | ||
| skipped: number; | ||
| created: number; | ||
| updated: number; | ||
| failed: number; | ||
| }; | ||
| failure_reason: string | null; | ||
| failure_message: string | null; | ||
| }; | ||
| } | ||
|
|
||
| const POLL_INTERVAL_MS = 2000; | ||
| const POLL_TIMEOUT_MS = 60000; | ||
|
|
||
| export const itemsSchema = z | ||
| .array( | ||
| z.record(z.string(), z.string()).describe('A row object where keys are column IDs and values are strings'), | ||
| ) | ||
| .min(1) | ||
| .max(10000) | ||
| .describe( | ||
| 'Array of item objects (up to 10,000). Each object represents a row — keys are column IDs (e.g. name, status, date4), values are strings. The "name" key is required in every row.', | ||
| ); | ||
|
|
||
| export const boardIdSchema = z.number().describe('The ID of the target board'); | ||
|
|
||
| export const groupIdSchema = z.string().describe('The ID of the group to target. The operation only affects items in this specific group. To update items across multiple groups, call this tool once per group.'); | ||
|
|
||
| function escapeCsvField(value: string): string { | ||
| if (value.includes(',') || value.includes('"') || value.includes('\n')) { | ||
| return `"${value.replace(/"/g, '""')}"`; | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| export function buildCsv(items: Record<string, string>[]): string { | ||
| const columnIds = [...new Set(items.flatMap((item) => Object.keys(item)))]; | ||
| const header = columnIds.join(','); | ||
| const rows = items.map((item) => | ||
| columnIds.map((col) => escapeCsvField(item[col] ?? '')).join(','), | ||
| ); | ||
| return [header, ...rows].join('\n'); | ||
| } | ||
|
|
||
| export async function pollJobStatus( | ||
| request: <T>(query: string, variables?: Record<string, any>, options?: any) => Promise<T>, | ||
| jobId: string, | ||
| ): Promise<JobStatusResponse['fetch_job_status']> { | ||
| const start = Date.now(); | ||
| while (Date.now() - start < POLL_TIMEOUT_MS) { | ||
| await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); | ||
| const res = await request<JobStatusResponse>(fetchJobStatusQuery, { jobId }, { versionOverride: '2026-07' }); | ||
| const status = res.fetch_job_status; | ||
| if (status.fully_imported) { | ||
| return status; | ||
| } | ||
| if (status.failure_reason) { | ||
| return status; | ||
| } | ||
| } | ||
| throw new Error('Polling timed out after 60 seconds'); | ||
| } | ||
|
|
||
| export async function executeIngestItems( | ||
| mondayApi: { request: <T>(query: string, variables?: Record<string, any>, options?: any) => Promise<T> }, | ||
| params: { boardId: string; groupId: string; items: Record<string, string>[]; onMatch?: { match_column_id: string; behaviour: string } }, | ||
| ) { | ||
| const variables: Record<string, any> = { boardId: params.boardId, groupId: params.groupId }; | ||
| if (params.onMatch) { | ||
| variables.onMatch = params.onMatch; | ||
| } | ||
|
|
||
| const ingestRes = await mondayApi.request<IngestItemsResponse>( | ||
| ingestItemsMutation, | ||
| variables, | ||
| { versionOverride: '2026-07' }, | ||
| ); | ||
|
|
||
| const { job_id, upload_url } = ingestRes.ingest_items; | ||
|
|
||
| const csv = buildCsv(params.items); | ||
| const uploadResponse = await fetch(upload_url, { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we return the url to the agent and instruct him to upload the file and then all us back again it will reduce the resources we use for this opteration.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting idea but I'd lean against it. Splitting into multiple tool calls means:
The resource cost on our side is one S3 PUT of a small payload + polling — both lightweight. Keeping it atomic gives us reliability and simplicity. If resource usage becomes a concern at scale we could revisit with a streaming approach, but for now the single-call UX is much more robust.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok just note that large file uploads can consume allot of resources (you basically pass all the payload twice). |
||
| method: 'PUT', | ||
| headers: { 'Content-Type': 'text/csv' }, | ||
| body: csv, | ||
| }); | ||
|
|
||
| if (!uploadResponse.ok) { | ||
| throw new Error(`S3 upload failed: HTTP ${uploadResponse.status}`); | ||
| } | ||
|
|
||
| const jobStatus = await pollJobStatus(mondayApi.request.bind(mondayApi), job_id); | ||
|
|
||
| if (jobStatus.failure_reason) { | ||
| return { | ||
| content: { | ||
| job_id, | ||
| error: jobStatus.failure_reason, | ||
| message: jobStatus.failure_message, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| content: { | ||
| job_id, | ||
| counts: jobStatus.counts, | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { z } from 'zod'; | ||
| import { ToolInputType, ToolOutputType, ToolType } from '../../../tool'; | ||
| import { BaseMondayApiTool, createMondayApiAnnotations } from '../base-monday-api-tool'; | ||
| import { boardIdSchema, groupIdSchema, itemsSchema, executeIngestItems } from './bulk-items.utils'; | ||
|
|
||
| export const createBulkItemsSchema = { | ||
| board_id: boardIdSchema, | ||
| group_id: groupIdSchema.optional().default('topics'), | ||
| items: itemsSchema, | ||
| }; | ||
|
|
||
| export class CreateBulkItemsTool extends BaseMondayApiTool<typeof createBulkItemsSchema, never> { | ||
| name = 'create_bulk_items'; | ||
| type = ToolType.WRITE; | ||
| annotations = createMondayApiAnnotations({ | ||
| title: 'Create Bulk Items', | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: false, | ||
| }); | ||
|
|
||
| getDescription(): string { | ||
| return ( | ||
| 'Bulk create up to 10,000 new items on a monday.com board. ' + | ||
| 'Provide an array of row objects where keys are column IDs (e.g. name, status, date4) and values are strings. ' + | ||
| 'Every row is created as a new item in the specified group. The default group_id "topics" is the first group on the board. ' + | ||
| 'Returns the job ID and final counts (created, failed, invalid).' | ||
| ); | ||
| } | ||
|
|
||
| getInputSchema(): typeof createBulkItemsSchema { | ||
| return createBulkItemsSchema; | ||
| } | ||
|
|
||
| protected async executeInternal(input: ToolInputType<typeof createBulkItemsSchema>): Promise<ToolOutputType<never>> { | ||
| const { board_id, group_id, items } = input; | ||
| return executeIngestItems(this.mondayApi, { | ||
| boardId: String(board_id), | ||
| groupId: group_id, | ||
| items, | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { CreateBulkItemsTool } from './create-bulk-items-tool'; | ||
| export { UpdateBulkItemsTool } from './update-bulk-items-tool'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { z } from 'zod'; | ||
| import { ToolInputType, ToolOutputType, ToolType } from '../../../tool'; | ||
| import { BaseMondayApiTool, createMondayApiAnnotations } from '../base-monday-api-tool'; | ||
| import { boardIdSchema, groupIdSchema, itemsSchema, executeIngestItems } from './bulk-items.utils'; | ||
|
|
||
| const onMatchSchema = z.object({ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe you can reference these tools from the existing change column value / create item tools in their description
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea! Added cross-references in both change_item_column_values ("For updating multiple items at once, use update_bulk_items instead") and create_item ("For creating many items at once, use create_bulk_items instead") descriptions. |
||
| match_column_id: z.string().describe('The column ID to match existing items against (e.g. "name" or "email")'), | ||
| behaviour: z.enum(['UPSERT', 'SKIP']).describe('UPSERT to update matched items with new values, SKIP to leave matched items unchanged').optional().default('UPSERT'), | ||
| }); | ||
|
|
||
| export const updateBulkItemsSchema = { | ||
| board_id: boardIdSchema, | ||
| group_id: groupIdSchema, | ||
| items: itemsSchema, | ||
| on_match: onMatchSchema.describe('Controls how existing items are matched and updated'), | ||
| }; | ||
|
|
||
| export class UpdateBulkItemsTool extends BaseMondayApiTool<typeof updateBulkItemsSchema, never> { | ||
| name = 'update_bulk_items'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can the name match the existing change column value tool?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd prefer keeping |
||
| type = ToolType.WRITE; | ||
| annotations = createMondayApiAnnotations({ | ||
| title: 'Update Bulk Items', | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| }); | ||
|
|
||
| getDescription(): string { | ||
| return ( | ||
| 'Bulk update existing items on a monday.com board by matching against a column value. ' + | ||
| 'Provide an array of row objects and an on_match config specifying which column to match on and the behaviour (UPSERT or SKIP). ' + | ||
| 'Matched items are updated with new values; unmatched rows are created as new items. ' + | ||
| 'Important: the operation is scoped to a single group.' + | ||
| 'If the board has multiple groups, call this tool once per group to update all items. ' + | ||
| 'Returns the job ID and final counts (created, updated, skipped, failed, invalid).' | ||
| ); | ||
| } | ||
|
|
||
| getInputSchema(): typeof updateBulkItemsSchema { | ||
| return updateBulkItemsSchema; | ||
| } | ||
|
|
||
| protected async executeInternal(input: ToolInputType<typeof updateBulkItemsSchema>): Promise<ToolOutputType<never>> { | ||
| const { board_id, group_id, items, on_match } = input; | ||
| return executeIngestItems(this.mondayApi, { | ||
| boardId: String(board_id), | ||
| groupId: group_id, | ||
| items, | ||
| onMatch: { match_column_id: on_match.match_column_id, behaviour: on_match.behaviour}, | ||
| }); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is it worth building the csv for 10 items? feels to me this solution fits huge uploads but not day-to-day updates. or is this the only existing API?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right that for 5-10 items it's heavier than needed. However,
ingest_itemsis the only bulk API available — the alternative is N individualchange_item_column_valuesmutations which means N round-trips. Even for small batches, the CSV+S3 overhead is negligible (a few hundred bytes PUT) compared to the latency of multiple sequential GraphQL calls. We could add a threshold (e.g. <3 items → fall back to individual calls) but that adds branching complexity for marginal gain. Happy to revisit if we see performance issues in practice.