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
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);

Copy link
Copy Markdown
Collaborator

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?

Copy link
Copy Markdown
Collaborator Author

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_items is the only bulk API available — the alternative is N individual change_item_column_values mutations 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.

const uploadResponse = await fetch(upload_url, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.
the downside is that it's more work for the agent. WDYT?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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:

  1. The agent needs to know how to format CSV and PUT to S3 — that's fragile and model-dependent
  2. 3 tool calls minimum (get URL → upload → poll) instead of 1 atomic call
  3. More LLM tokens consumed per operation
  4. More failure points (agent might format CSV wrong, forget content-type header, etc.)

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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).
maybe as an initial phase we should start we less than 10000 items counts

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({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can the name match the existing change column value tool?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer keeping update_bulk_items rather than change_item_column_values_bulk — the name was intentionally chosen for LLM discoverability. When an LLM sees "update multiple items" in a user request, update_bulk_items is a more natural match. The change_item_column_values naming is a legacy convention that's already confusing for models. That said, I've added cross-references in the existing tool descriptions so the LLM knows to route to bulk tools when appropriate.

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},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class ChangeItemColumnValuesTool extends BaseMondayApiTool<ChangeItemColu
getDescription(): string {
return (
'Change the column values of an item in a monday.com board. ' +
'For updating multiple items at once, use update_bulk_items instead. ' +
'[REQUIRED PRECONDITION]: For board-relation linking tasks, call link_board_items_workflow before using this tool.'
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class CreateItemTool extends BaseMondayApiTool<CreateItemToolInput> {

getDescription(): string {
return (
'Create a new item with provided values, create a subitem under a parent item, or duplicate an existing item and update it with new values. Use parentItemId when creating a subitem under an existing item. Use duplicateFromItemId when copying an existing item with modifications.' +
'Create a new item with provided values, create a subitem under a parent item, or duplicate an existing item and update it with new values. For creating many items at once, use create_bulk_items instead. Use parentItemId when creating a subitem under an existing item. Use duplicateFromItemId when copying an existing item with modifications.' +
`[REQUIRED PRECONDITION]: Before using this tool, if new columns were added to the board or if you are not familiar with the board's structure (column IDs, column types, status labels, etc.), first use get_board_info to understand the board metadata. This is essential for constructing proper column values and knowing which columns are available.`
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { CreateAgentTool } from './agents-tools/create-agent/create-agent-tool';
import { DeleteAgentTool } from './agents-tools/delete-agent/delete-agent-tool';
import { ListWorkflowsTool } from './workflows-tools/list-workflows/list-workflows-tool';
import { ManageWorkflowsTool } from './workflows-tools/manage-workflows/manage-workflows-tool';
import { CreateBulkItemsTool, UpdateBulkItemsTool } from './bulk-items-tools';

export const allGraphqlApiTools: BaseMondayApiToolConstructor[] = [
DeleteItemTool,
Expand Down Expand Up @@ -150,6 +151,9 @@ export const allGraphqlApiTools: BaseMondayApiToolConstructor[] = [
// Workflows (subgraph still on dev API version)
ListWorkflowsTool,
ManageWorkflowsTool,
// Bulk operations
CreateBulkItemsTool,
UpdateBulkItemsTool,
];

export * from './all-monday-api-tool';
Expand Down Expand Up @@ -224,5 +228,7 @@ export * from './agents-tools';
export * from './workflows-tools';
// Dashboard Tools
export * from './dashboard-tools';
// Bulk operations
export * from './bulk-items-tools';
// Monday Dev Tools
export * from '../monday-dev-tools';