From 586b97fd1744ea1a112df02219a5efcedd5a0cfb Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 17:01:15 -0700 Subject: [PATCH] feat(attio): add attribute tools + fix API alignment gaps (#5445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(attio): add attribute tools + fix API alignment gaps - Add 4 new tools: attio_list_attributes, get_attribute, create_attribute, update_attribute - Add missing completed_at field to task tools - Fix note tags output to match Attio's actual response shape (workspace-member vs record tags) - Fix attribute outputs missing is_default_value_enabled, default_value, relationship - Fix create_comment sending both entry and thread_id (Attio requires exactly one) - Wire attribute pagination + task sort into the block * fix(attio): address review feedback on attribute tools - Throw on invalid config JSON instead of silently overwriting with {} (create_attribute, update_attribute) - create_attribute: omit config field entirely when not provided instead of always sending {} - Add "Leave unchanged" option to Required/Unique dropdowns so update_attribute no longer clears existing constraints on unrelated field updates - Fix stale sort param description on list_tasks (was missing completed_at:asc/desc variants) * fix(attio): fix silent update-clobber bugs + complete comment mutual-exclusion - create_comment: support all 3 of Attio's mutually-exclusive comment targets (thread_id, record, entry), not just thread_id/entry - Fix update_task silently clearing is_completed on unrelated field updates (taskIsCompletedUpdate now defaults to "leave unchanged") - Fix update_list silently resetting workspace_access to full-access on unrelated field updates (listWorkspaceAccessUpdate, same pattern) - create_attribute: restore config:{} as always-required per Attio's schema (previously omitted it entirely, which is invalid on create) - create_record/update_record/assert_record/list_records: throw on invalid values/filter/sorts JSON instead of silently substituting {} (was a silent data-loss risk on malformed input) - get_task: drop stray Content-Type header on a GET request - types.ts: remove two dead unreferenced interfaces, fix workspaceMemberAccess type (was string, is actually an array) * fix(attio): gate operation-specific param mapping on current operation Params like taskIsCompleted/listWorkspaceAccess/attributeIsMultiselect/ attributeIsArchived are only meant for one specific operation, but their mapping wasn't checking params.operation. A stale value persisted in block state from a prior operation selection (e.g. switching the dropdown from create_task to update_task) could leak through and override the "leave unchanged" default on the other operation. * fix(attio): explicit comment target selector + attribute archived tri-state - Add "Leave unchanged" default to attributeIsArchived dropdown, same pattern as isRequired/isUnique, so a stale archived flag from an earlier edit can't unarchive an attribute on an unrelated update - create_comment: replace field-presence inference (which let stale record fields hijack a list-entry comment or vice versa) with an explicit commentTarget selector (List Entry / Record / Reply to Thread). Only the fields for the selected target are ever forwarded * fix(attio): gate remaining unscoped param mappings + comment-target back-compat - taskFilterCompleted (list_tasks filter) was mapped to isCompleted for every operation; gate it to list_tasks so it can't override the isCompleted value on an unrelated update_task call - Generic threadId (get_thread) was unconditionally forwarded and create_comment prioritizes thread_id first, so a leftover threadId from configuring get_thread could silently hijack a comment meant for a list entry or record; gate it to get_thread - Default commentTarget to the pre-existing behavior (entry, or thread if commentThreadId is set) when absent, so blocks saved before this field existed keep working instead of throwing * fix(attio): gate every param mapping by its operation, eliminate stale-value class of bugs The params() function reuses cleanParams keys (title, content, apiSlug, filter, sorts, recordId, entryId, list, object, threadId, ...) across several unrelated operation families. Every mapping was unconditional on presence alone, so a stale value left in block state from a previously selected operation could silently leak into an unrelated request and overwrite the intended value (last-write-wins on a shared key). Rewrote the whole function to gate every line by params.operation against the exact operation set its subBlock's condition exposes it under, closing this entire bug class in one pass instead of patching individual instances as they were found in review. Also split attributeIsRequired/attributeIsUnique into create-only (default false) and update-only (tri-state, default "leave unchanged") variants — the shared field let an explicit Yes/No choice from create_attribute carry over and silently change constraints on an unrelated update_attribute call. * fix(attio): preserve legacy taskIsCompleted/listWorkspaceAccess on update taskIsCompleted and listWorkspaceAccess pre-date this PR as fields shared between create and update operations (confirmed present on origin/staging). Splitting them into create/update variants earlier this session meant existing saved blocks with a value stored under the legacy field name would silently stop applying it on update_task / update_list once the new -Update field (always undefined for old blocks) took over. Fall back to the legacy field when the new field is untouched, so old saved workflows keep behaving exactly as before. --- apps/sim/blocks/blocks/attio.ts | 718 ++++++++++++++++++++--- apps/sim/tools/attio/assert_record.ts | 4 +- apps/sim/tools/attio/create_attribute.ts | 157 +++++ apps/sim/tools/attio/create_comment.ts | 45 +- apps/sim/tools/attio/create_note.ts | 4 +- apps/sim/tools/attio/create_record.ts | 2 +- apps/sim/tools/attio/create_task.ts | 1 + apps/sim/tools/attio/get_attribute.ts | 87 +++ apps/sim/tools/attio/get_note.ts | 4 +- apps/sim/tools/attio/get_task.ts | 2 +- apps/sim/tools/attio/index.ts | 4 + apps/sim/tools/attio/list_attributes.ts | 124 ++++ apps/sim/tools/attio/list_notes.ts | 4 +- apps/sim/tools/attio/list_records.ts | 4 +- apps/sim/tools/attio/list_tasks.ts | 4 +- apps/sim/tools/attio/types.ts | 227 +++++-- apps/sim/tools/attio/update_attribute.ts | 150 +++++ apps/sim/tools/attio/update_record.ts | 2 +- apps/sim/tools/attio/update_task.ts | 1 + apps/sim/tools/registry.ts | 8 + 20 files changed, 1421 insertions(+), 131 deletions(-) create mode 100644 apps/sim/tools/attio/create_attribute.ts create mode 100644 apps/sim/tools/attio/get_attribute.ts create mode 100644 apps/sim/tools/attio/list_attributes.ts create mode 100644 apps/sim/tools/attio/update_attribute.ts diff --git a/apps/sim/blocks/blocks/attio.ts b/apps/sim/blocks/blocks/attio.ts index 3016a90570..e991d1a5de 100644 --- a/apps/sim/blocks/blocks/attio.ts +++ b/apps/sim/blocks/blocks/attio.ts @@ -64,6 +64,10 @@ export const AttioBlock: BlockConfig = { { label: 'Create Webhook', id: 'create_webhook' }, { label: 'Update Webhook', id: 'update_webhook' }, { label: 'Delete Webhook', id: 'delete_webhook' }, + { label: 'List Attributes', id: 'list_attributes' }, + { label: 'Get Attribute', id: 'get_attribute' }, + { label: 'Create Attribute', id: 'create_attribute' }, + { label: 'Update Attribute', id: 'update_attribute' }, ], value: () => 'list_records', }, @@ -430,7 +434,19 @@ YYYY-MM-DDTHH:mm:ss.SSSZ { label: 'Yes', id: 'true' }, ], value: () => 'false', - condition: { field: 'operation', value: ['create_task', 'update_task'] }, + condition: { field: 'operation', value: 'create_task' }, + }, + { + id: 'taskIsCompletedUpdate', + title: 'Completed', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_task' }, }, { id: 'taskLinkedRecords', @@ -662,7 +678,20 @@ Return ONLY the JSON array. No explanations, no markdown, no extra text. { label: 'Read Only', id: 'read-only' }, ], value: () => 'full-access', - condition: { field: 'operation', value: ['create_list', 'update_list'] }, + condition: { field: 'operation', value: 'create_list' }, + }, + { + id: 'listWorkspaceAccessUpdate', + title: 'Workspace Access', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'Full Access', id: 'full-access' }, + { label: 'Read & Write', id: 'read-and-write' }, + { label: 'Read Only', id: 'read-only' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_list' }, }, // List entry fields @@ -823,29 +852,97 @@ Return ONLY the JSON array. No explanations, no markdown, no extra text. condition: { field: 'operation', value: 'create_comment' }, required: { field: 'operation', value: 'create_comment' }, }, + { + id: 'commentTarget', + title: 'Comment On', + type: 'dropdown', + options: [ + { label: 'List Entry', id: 'entry' }, + { label: 'Record', id: 'record' }, + { label: 'Reply to Thread', id: 'thread' }, + ], + value: () => 'entry', + condition: { field: 'operation', value: 'create_comment' }, + }, { id: 'commentList', title: 'List', type: 'short-input', placeholder: 'List ID or slug', - condition: { field: 'operation', value: 'create_comment' }, - required: { field: 'operation', value: 'create_comment' }, + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'entry' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'entry' }, + }, }, { id: 'commentEntryId', title: 'Entry ID', type: 'short-input', placeholder: 'List entry ID to comment on', - condition: { field: 'operation', value: 'create_comment' }, - required: { field: 'operation', value: 'create_comment' }, + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'entry' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'entry' }, + }, + }, + { + id: 'commentRecordObject', + title: 'Record Object', + type: 'short-input', + placeholder: 'Object ID or slug the record belongs to', + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'record' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'record' }, + }, + }, + { + id: 'commentRecordId', + title: 'Record ID', + type: 'short-input', + placeholder: 'Record ID to comment on directly', + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'record' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'record' }, + }, }, { id: 'commentThreadId', title: 'Thread ID', type: 'short-input', - placeholder: 'Reply to thread (optional, omit to start new)', - condition: { field: 'operation', value: 'create_comment' }, - mode: 'advanced', + placeholder: 'Thread ID to reply to', + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'thread' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'thread' }, + }, }, { id: 'commentCreatedAt', @@ -983,6 +1080,208 @@ workspace-member.created }, }, + // Attribute fields + { + id: 'attributeTarget', + title: 'Target', + type: 'dropdown', + options: [ + { label: 'Object', id: 'objects' }, + { label: 'List', id: 'lists' }, + ], + value: () => 'objects', + condition: { + field: 'operation', + value: ['list_attributes', 'get_attribute', 'create_attribute', 'update_attribute'], + }, + required: { + field: 'operation', + value: ['list_attributes', 'get_attribute', 'create_attribute', 'update_attribute'], + }, + }, + { + id: 'attributeIdentifier', + title: 'Object or List ID/Slug', + type: 'short-input', + placeholder: 'e.g. people, companies', + condition: { + field: 'operation', + value: ['list_attributes', 'get_attribute', 'create_attribute', 'update_attribute'], + }, + required: { + field: 'operation', + value: ['list_attributes', 'get_attribute', 'create_attribute', 'update_attribute'], + }, + }, + { + id: 'attributeId', + title: 'Attribute ID or Slug', + type: 'short-input', + placeholder: 'e.g. email_addresses', + condition: { field: 'operation', value: ['get_attribute', 'update_attribute'] }, + required: { field: 'operation', value: ['get_attribute', 'update_attribute'] }, + }, + { + id: 'attributeTitle', + title: 'Title', + type: 'short-input', + placeholder: 'e.g. Lead Source', + condition: { field: 'operation', value: ['create_attribute', 'update_attribute'] }, + required: { field: 'operation', value: 'create_attribute' }, + }, + { + id: 'attributeApiSlug', + title: 'API Slug', + type: 'short-input', + placeholder: 'e.g. lead_source', + condition: { field: 'operation', value: ['create_attribute', 'update_attribute'] }, + required: { field: 'operation', value: 'create_attribute' }, + }, + { + id: 'attributeType', + title: 'Type', + type: 'dropdown', + options: [ + { label: 'Text', id: 'text' }, + { label: 'Number', id: 'number' }, + { label: 'Checkbox', id: 'checkbox' }, + { label: 'Currency', id: 'currency' }, + { label: 'Date', id: 'date' }, + { label: 'Timestamp', id: 'timestamp' }, + { label: 'Rating', id: 'rating' }, + { label: 'Status', id: 'status' }, + { label: 'Select', id: 'select' }, + { label: 'Record Reference', id: 'record-reference' }, + { label: 'Actor Reference', id: 'actor-reference' }, + { label: 'Location', id: 'location' }, + { label: 'Domain', id: 'domain' }, + { label: 'Email Address', id: 'email-address' }, + { label: 'Phone Number', id: 'phone-number' }, + ], + condition: { field: 'operation', value: 'create_attribute' }, + required: { field: 'operation', value: 'create_attribute' }, + }, + { + id: 'attributeDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Describe the attribute', + condition: { field: 'operation', value: ['create_attribute', 'update_attribute'] }, + mode: 'advanced', + }, + { + id: 'attributeIsRequired', + title: 'Required', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'create_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsRequiredUpdate', + title: 'Required', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsUnique', + title: 'Unique', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'create_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsUniqueUpdate', + title: 'Unique', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsMultiselect', + title: 'Multiselect', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'create_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsArchived', + title: 'Archived', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeConfig', + title: 'Config', + type: 'code', + placeholder: '{"default_currency_code": "USD", "display_type": "text"}', + condition: { field: 'operation', value: ['create_attribute', 'update_attribute'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `Generate Attio attribute type configuration as a JSON object. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the JSON object. No explanations, no markdown, no extra text. + +### EXAMPLES +Currency: {"default_currency_code": "USD", "display_type": "text"} +Record reference: {"allowed_objects": ["people", "companies"]}`, + placeholder: 'Describe the attribute configuration...', + generationType: 'json-object', + }, + }, + { + id: 'attributeShowArchived', + title: 'Show Archived', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'list_attributes' }, + mode: 'advanced', + }, + // Shared limit { id: 'limit', @@ -1000,6 +1299,7 @@ workspace-member.created 'query_list_entries', 'list_threads', 'list_webhooks', + 'list_attributes', ], }, }, @@ -1018,9 +1318,23 @@ workspace-member.created 'query_list_entries', 'list_threads', 'list_webhooks', + 'list_attributes', ], }, }, + { + id: 'taskSort', + title: 'Sort', + type: 'dropdown', + options: [ + { label: 'Created At (asc)', id: 'created_at:asc' }, + { label: 'Created At (desc)', id: 'created_at:desc' }, + { label: 'Completed At (asc)', id: 'completed_at:asc' }, + { label: 'Completed At (desc)', id: 'completed_at:desc' }, + ], + condition: { field: 'operation', value: 'list_tasks' }, + mode: 'advanced', + }, ...getTrigger('attio_record_created').subBlocks, ...getTrigger('attio_record_updated').subBlocks, ...getTrigger('attio_record_deleted').subBlocks, @@ -1116,6 +1430,10 @@ workspace-member.created 'attio_create_webhook', 'attio_update_webhook', 'attio_delete_webhook', + 'attio_list_attributes', + 'attio_get_attribute', + 'attio_create_attribute', + 'attio_update_attribute', ], config: { tool: (params) => `attio_${params.operation}`, @@ -1123,92 +1441,285 @@ workspace-member.created const cleanParams: Record = { oauthCredential: params.oauthCredential, } + const op = params.operation - // Record params - if (params.objectType) cleanParams.objectType = params.objectType - if (params.recordId) cleanParams.recordId = params.recordId - if (params.matchingAttribute) cleanParams.matchingAttribute = params.matchingAttribute - if (params.values) cleanParams.values = params.values - if (params.filter) cleanParams.filter = params.filter - if (params.sorts) cleanParams.sorts = params.sorts - if (params.query) cleanParams.query = params.query - if (params.objects) cleanParams.objects = params.objects + // Record params — each field is only sent for the operations whose subBlock condition + // actually exposes it, so a stale value left over from switching operations never leaks + // into an unrelated request (cleanParams keys are reused across several operation families). + if ( + [ + 'list_records', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + 'assert_record', + ].includes(op) && + params.objectType + ) + cleanParams.objectType = params.objectType + if (['get_record', 'update_record', 'delete_record'].includes(op) && params.recordId) + cleanParams.recordId = params.recordId + if (op === 'assert_record' && params.matchingAttribute) + cleanParams.matchingAttribute = params.matchingAttribute + if (['create_record', 'update_record', 'assert_record'].includes(op) && params.values) + cleanParams.values = params.values + if (op === 'list_records' && params.filter) cleanParams.filter = params.filter + if (op === 'list_records' && params.sorts) cleanParams.sorts = params.sorts + if (op === 'search_records' && params.query) cleanParams.query = params.query + if (op === 'search_records' && params.objects) cleanParams.objects = params.objects // Note params - if (params.noteParentObject) cleanParams.parentObject = params.noteParentObject - if (params.noteParentRecordId) cleanParams.parentRecordId = params.noteParentRecordId - if (params.noteTitle) cleanParams.title = params.noteTitle - if (params.noteContent) cleanParams.content = params.noteContent - if (params.noteFormat) cleanParams.format = params.noteFormat - if (params.noteId) cleanParams.noteId = params.noteId - if (params.noteCreatedAt) cleanParams.createdAt = params.noteCreatedAt - if (params.noteMeetingId) cleanParams.meetingId = params.noteMeetingId + if (['list_notes', 'create_note'].includes(op) && params.noteParentObject) + cleanParams.parentObject = params.noteParentObject + if (['list_notes', 'create_note'].includes(op) && params.noteParentRecordId) + cleanParams.parentRecordId = params.noteParentRecordId + if (op === 'create_note' && params.noteTitle) cleanParams.title = params.noteTitle + if (op === 'create_note' && params.noteContent) cleanParams.content = params.noteContent + if (op === 'create_note' && params.noteFormat) cleanParams.format = params.noteFormat + if (op === 'create_note' && params.noteCreatedAt) + cleanParams.createdAt = params.noteCreatedAt + if (op === 'create_note' && params.noteMeetingId) + cleanParams.meetingId = params.noteMeetingId + if (['get_note', 'delete_note'].includes(op) && params.noteId) + cleanParams.noteId = params.noteId // Task params - if (params.taskContent) cleanParams.content = params.taskContent - if (params.taskDeadline) cleanParams.deadlineAt = params.taskDeadline - if (params.taskIsCompleted !== undefined) + if (op === 'create_task' && params.taskContent) cleanParams.content = params.taskContent + if (['create_task', 'update_task'].includes(op) && params.taskDeadline) + cleanParams.deadlineAt = params.taskDeadline + if (op === 'create_task' && params.taskIsCompleted !== undefined) cleanParams.isCompleted = params.taskIsCompleted === 'true' || params.taskIsCompleted === true - if (params.taskLinkedRecords) cleanParams.linkedRecords = params.taskLinkedRecords - if (params.taskAssignees) cleanParams.assignees = params.taskAssignees - if (params.taskId) cleanParams.taskId = params.taskId - if (params.taskFilterObject) cleanParams.linkedObject = params.taskFilterObject - if (params.taskFilterRecordId) cleanParams.linkedRecordId = params.taskFilterRecordId - if (params.taskFilterAssignee) cleanParams.assignee = params.taskFilterAssignee - if (params.taskFilterCompleted && params.taskFilterCompleted !== 'all') + if (op === 'update_task') { + // taskIsCompletedUpdate is the current control; taskIsCompleted is a fallback for blocks + // saved before the split, when that field applied to both create_task and update_task. + if ( + params.taskIsCompletedUpdate !== undefined && + params.taskIsCompletedUpdate !== 'unchanged' + ) { + cleanParams.isCompleted = + params.taskIsCompletedUpdate === 'true' || params.taskIsCompletedUpdate === true + } else if ( + params.taskIsCompletedUpdate === undefined && + params.taskIsCompleted !== undefined + ) { + cleanParams.isCompleted = + params.taskIsCompleted === 'true' || params.taskIsCompleted === true + } + } + if (['create_task', 'update_task'].includes(op) && params.taskLinkedRecords) + cleanParams.linkedRecords = params.taskLinkedRecords + if (['create_task', 'update_task'].includes(op) && params.taskAssignees) + cleanParams.assignees = params.taskAssignees + if (['get_task', 'update_task', 'delete_task'].includes(op) && params.taskId) + cleanParams.taskId = params.taskId + if (op === 'list_tasks' && params.taskFilterObject) + cleanParams.linkedObject = params.taskFilterObject + if (op === 'list_tasks' && params.taskFilterRecordId) + cleanParams.linkedRecordId = params.taskFilterRecordId + if (op === 'list_tasks' && params.taskFilterAssignee) + cleanParams.assignee = params.taskFilterAssignee + if ( + op === 'list_tasks' && + params.taskFilterCompleted && + params.taskFilterCompleted !== 'all' + ) cleanParams.isCompleted = params.taskFilterCompleted === 'true' + if (op === 'list_tasks' && params.taskSort) cleanParams.sort = params.taskSort // Object params - if (params.objectIdOrSlug) cleanParams.object = params.objectIdOrSlug - if (params.objectApiSlug) cleanParams.apiSlug = params.objectApiSlug - if (params.objectSingularNoun) cleanParams.singularNoun = params.objectSingularNoun - if (params.objectPluralNoun) cleanParams.pluralNoun = params.objectPluralNoun + if (['get_object', 'update_object'].includes(op) && params.objectIdOrSlug) + cleanParams.object = params.objectIdOrSlug + if (['create_object', 'update_object'].includes(op) && params.objectApiSlug) + cleanParams.apiSlug = params.objectApiSlug + if (['create_object', 'update_object'].includes(op) && params.objectSingularNoun) + cleanParams.singularNoun = params.objectSingularNoun + if (['create_object', 'update_object'].includes(op) && params.objectPluralNoun) + cleanParams.pluralNoun = params.objectPluralNoun // List params - if (params.listIdOrSlug) cleanParams.list = params.listIdOrSlug - if (params.listName) cleanParams.name = params.listName - if (params.listParentObject) cleanParams.parentObject = params.listParentObject - if (params.listApiSlug) cleanParams.apiSlug = params.listApiSlug - if (params.listWorkspaceAccess) cleanParams.workspaceAccess = params.listWorkspaceAccess + if ( + [ + 'get_list', + 'update_list', + 'query_list_entries', + 'get_list_entry', + 'create_list_entry', + 'update_list_entry', + 'delete_list_entry', + ].includes(op) && + params.listIdOrSlug + ) + cleanParams.list = params.listIdOrSlug + if (['create_list', 'update_list'].includes(op) && params.listName) + cleanParams.name = params.listName + if (op === 'create_list' && params.listParentObject) + cleanParams.parentObject = params.listParentObject + if (['create_list', 'update_list'].includes(op) && params.listApiSlug) + cleanParams.apiSlug = params.listApiSlug + if (op === 'create_list' && params.listWorkspaceAccess) + cleanParams.workspaceAccess = params.listWorkspaceAccess + if (op === 'update_list') { + // listWorkspaceAccessUpdate is the current control; listWorkspaceAccess is a fallback + // for blocks saved before the split, when that field applied to both create and update. + if ( + params.listWorkspaceAccessUpdate && + params.listWorkspaceAccessUpdate !== 'unchanged' + ) { + cleanParams.workspaceAccess = params.listWorkspaceAccessUpdate + } else if (!params.listWorkspaceAccessUpdate && params.listWorkspaceAccess) { + cleanParams.workspaceAccess = params.listWorkspaceAccess + } + } // List entry params - if (params.entryId) cleanParams.entryId = params.entryId - if (params.entryParentRecordId) cleanParams.parentRecordId = params.entryParentRecordId - if (params.entryParentObject) cleanParams.parentObject = params.entryParentObject - if (params.entryValues) cleanParams.entryValues = params.entryValues - if (params.entryFilter) cleanParams.filter = params.entryFilter - if (params.entrySorts) cleanParams.sorts = params.entrySorts + if ( + ['get_list_entry', 'update_list_entry', 'delete_list_entry'].includes(op) && + params.entryId + ) + cleanParams.entryId = params.entryId + if (op === 'create_list_entry' && params.entryParentRecordId) + cleanParams.parentRecordId = params.entryParentRecordId + if (op === 'create_list_entry' && params.entryParentObject) + cleanParams.parentObject = params.entryParentObject + if (['create_list_entry', 'update_list_entry'].includes(op) && params.entryValues) + cleanParams.entryValues = params.entryValues + if (op === 'query_list_entries' && params.entryFilter) + cleanParams.filter = params.entryFilter + if (op === 'query_list_entries' && params.entrySorts) cleanParams.sorts = params.entrySorts // Member params - if (params.memberId) cleanParams.memberId = params.memberId + if (op === 'get_member' && params.memberId) cleanParams.memberId = params.memberId // Comment params - if (params.commentContent) cleanParams.content = params.commentContent - if (params.commentFormat) cleanParams.format = params.commentFormat - if (params.commentAuthorType) cleanParams.authorType = params.commentAuthorType - if (params.commentAuthorId) cleanParams.authorId = params.commentAuthorId - if (params.commentList) cleanParams.list = params.commentList - if (params.commentEntryId) cleanParams.entryId = params.commentEntryId - if (params.commentThreadId) cleanParams.threadId = params.commentThreadId - if (params.commentCreatedAt) cleanParams.createdAt = params.commentCreatedAt - if (params.commentId) cleanParams.commentId = params.commentId + if (op === 'create_comment') { + if (params.commentContent) cleanParams.content = params.commentContent + if (params.commentFormat) cleanParams.format = params.commentFormat + if (params.commentAuthorType) cleanParams.authorType = params.commentAuthorType + if (params.commentAuthorId) cleanParams.authorId = params.commentAuthorId + // Blocks saved before commentTarget existed have no value for it — fall back to the + // pre-existing behavior (entry-based comment, or thread reply if a thread ID was set). + const commentTarget = + params.commentTarget ?? (params.commentThreadId ? 'thread' : 'entry') + if (commentTarget === 'entry') { + if (params.commentList) cleanParams.list = params.commentList + if (params.commentEntryId) cleanParams.entryId = params.commentEntryId + } else if (commentTarget === 'record') { + if (params.commentRecordObject) cleanParams.recordObject = params.commentRecordObject + if (params.commentRecordId) cleanParams.recordId = params.commentRecordId + } else if (commentTarget === 'thread') { + if (params.commentThreadId) cleanParams.threadId = params.commentThreadId + } + if (params.commentCreatedAt) cleanParams.createdAt = params.commentCreatedAt + } + if (['get_comment', 'delete_comment'].includes(op) && params.commentId) + cleanParams.commentId = params.commentId // Thread params - if (params.threadId) cleanParams.threadId = params.threadId - if (params.threadFilterRecordId) cleanParams.recordId = params.threadFilterRecordId - if (params.threadFilterObject) cleanParams.object = params.threadFilterObject - if (params.threadFilterEntryId) cleanParams.entryId = params.threadFilterEntryId - if (params.threadFilterList) cleanParams.list = params.threadFilterList + if (op === 'get_thread' && params.threadId) cleanParams.threadId = params.threadId + if (op === 'list_threads' && params.threadFilterRecordId) + cleanParams.recordId = params.threadFilterRecordId + if (op === 'list_threads' && params.threadFilterObject) + cleanParams.object = params.threadFilterObject + if (op === 'list_threads' && params.threadFilterEntryId) + cleanParams.entryId = params.threadFilterEntryId + if (op === 'list_threads' && params.threadFilterList) + cleanParams.list = params.threadFilterList // Webhook params - if (params.webhookId) cleanParams.webhookId = params.webhookId - if (params.webhookTargetUrl) cleanParams.targetUrl = params.webhookTargetUrl - if (params.webhookSubscriptions) cleanParams.subscriptions = params.webhookSubscriptions + if (['get_webhook', 'update_webhook', 'delete_webhook'].includes(op) && params.webhookId) + cleanParams.webhookId = params.webhookId + if (['create_webhook', 'update_webhook'].includes(op) && params.webhookTargetUrl) + cleanParams.targetUrl = params.webhookTargetUrl + if (['create_webhook', 'update_webhook'].includes(op) && params.webhookSubscriptions) + cleanParams.subscriptions = params.webhookSubscriptions - // Shared params - if (params.limit) cleanParams.limit = Number(params.limit) - if (params.offset) cleanParams.offset = Number(params.offset) + // Attribute params + const attributeOps = [ + 'list_attributes', + 'get_attribute', + 'create_attribute', + 'update_attribute', + ] + if (attributeOps.includes(op) && params.attributeTarget) + cleanParams.target = params.attributeTarget + if (attributeOps.includes(op) && params.attributeIdentifier) + cleanParams.identifier = params.attributeIdentifier + if (['get_attribute', 'update_attribute'].includes(op) && params.attributeId) + cleanParams.attribute = params.attributeId + if (['create_attribute', 'update_attribute'].includes(op) && params.attributeTitle) + cleanParams.title = params.attributeTitle + if (['create_attribute', 'update_attribute'].includes(op) && params.attributeApiSlug) + cleanParams.apiSlug = params.attributeApiSlug + if (op === 'create_attribute' && params.attributeType) + cleanParams.type = params.attributeType + if (['create_attribute', 'update_attribute'].includes(op) && params.attributeDescription) + cleanParams.description = params.attributeDescription + if (op === 'create_attribute' && params.attributeIsRequired !== undefined) + cleanParams.isRequired = + params.attributeIsRequired === 'true' || params.attributeIsRequired === true + if ( + op === 'update_attribute' && + params.attributeIsRequiredUpdate !== undefined && + params.attributeIsRequiredUpdate !== 'unchanged' + ) + cleanParams.isRequired = + params.attributeIsRequiredUpdate === 'true' || params.attributeIsRequiredUpdate === true + if (op === 'create_attribute' && params.attributeIsUnique !== undefined) + cleanParams.isUnique = + params.attributeIsUnique === 'true' || params.attributeIsUnique === true + if ( + op === 'update_attribute' && + params.attributeIsUniqueUpdate !== undefined && + params.attributeIsUniqueUpdate !== 'unchanged' + ) + cleanParams.isUnique = + params.attributeIsUniqueUpdate === 'true' || params.attributeIsUniqueUpdate === true + if (op === 'create_attribute' && params.attributeIsMultiselect !== undefined) + cleanParams.isMultiselect = + params.attributeIsMultiselect === 'true' || params.attributeIsMultiselect === true + if ( + op === 'update_attribute' && + params.attributeIsArchived !== undefined && + params.attributeIsArchived !== 'unchanged' + ) + cleanParams.isArchived = + params.attributeIsArchived === 'true' || params.attributeIsArchived === true + if (['create_attribute', 'update_attribute'].includes(op) && params.attributeConfig) + cleanParams.config = params.attributeConfig + if (op === 'list_attributes' && params.attributeShowArchived !== undefined) + cleanParams.showArchived = + params.attributeShowArchived === 'true' || params.attributeShowArchived === true + + // Shared pagination params — only meaningful for list-style operations + if ( + [ + 'list_records', + 'search_records', + 'list_notes', + 'list_tasks', + 'query_list_entries', + 'list_threads', + 'list_webhooks', + 'list_attributes', + ].includes(op) && + params.limit + ) + cleanParams.limit = Number(params.limit) + if ( + [ + 'list_records', + 'list_notes', + 'list_tasks', + 'query_list_entries', + 'list_threads', + 'list_webhooks', + 'list_attributes', + ].includes(op) && + params.offset + ) + cleanParams.offset = Number(params.offset) return cleanParams }, @@ -1237,6 +1748,7 @@ workspace-member.created taskContent: { type: 'string', description: 'Task content' }, taskDeadline: { type: 'string', description: 'Task deadline' }, taskIsCompleted: { type: 'string', description: 'Task completion status' }, + taskIsCompletedUpdate: { type: 'string', description: 'Task completion status (update)' }, taskLinkedRecords: { type: 'json', description: 'Linked records JSON array' }, taskAssignees: { type: 'json', description: 'Assignees JSON array' }, taskId: { type: 'string', description: 'Task ID' }, @@ -1249,6 +1761,10 @@ workspace-member.created listParentObject: { type: 'string', description: 'List parent object' }, listApiSlug: { type: 'string', description: 'List API slug' }, listWorkspaceAccess: { type: 'string', description: 'List workspace-level access' }, + listWorkspaceAccessUpdate: { + type: 'string', + description: 'List workspace-level access (update)', + }, entryId: { type: 'string', description: 'List entry ID' }, entryParentRecordId: { type: 'string', description: 'Record ID for list entry' }, entryParentObject: { type: 'string', description: 'Record object type for list entry' }, @@ -1260,8 +1776,11 @@ workspace-member.created commentFormat: { type: 'string', description: 'Comment format' }, commentAuthorType: { type: 'string', description: 'Comment author type' }, commentAuthorId: { type: 'string', description: 'Comment author ID' }, + commentTarget: { type: 'string', description: 'What the comment is attached to' }, commentList: { type: 'string', description: 'List for comment' }, commentEntryId: { type: 'string', description: 'Entry ID for comment' }, + commentRecordObject: { type: 'string', description: 'Object for record comment' }, + commentRecordId: { type: 'string', description: 'Record ID for record comment' }, commentThreadId: { type: 'string', description: 'Thread ID to reply to' }, commentCreatedAt: { type: 'string', description: 'Comment creation timestamp (backdate)' }, commentId: { type: 'string', description: 'Comment ID' }, @@ -1269,6 +1788,34 @@ workspace-member.created webhookId: { type: 'string', description: 'Webhook ID' }, webhookTargetUrl: { type: 'string', description: 'Webhook target URL' }, webhookSubscriptions: { type: 'json', description: 'Webhook event subscriptions' }, + attributeTarget: { + type: 'string', + description: 'Whether the attribute is on an object or list', + }, + attributeIdentifier: { type: 'string', description: 'The object or list ID or slug' }, + attributeId: { type: 'string', description: 'The attribute ID or slug' }, + attributeTitle: { type: 'string', description: 'The attribute display title' }, + attributeApiSlug: { type: 'string', description: 'The attribute API slug' }, + attributeType: { type: 'string', description: 'The attribute value type' }, + attributeDescription: { type: 'string', description: 'The attribute description' }, + attributeIsRequired: { type: 'string', description: 'Whether the attribute is required' }, + attributeIsRequiredUpdate: { + type: 'string', + description: 'Whether the attribute is required (update)', + }, + attributeIsUnique: { type: 'string', description: 'Whether the attribute is unique' }, + attributeIsUniqueUpdate: { + type: 'string', + description: 'Whether the attribute is unique (update)', + }, + attributeIsMultiselect: { type: 'string', description: 'Whether the attribute is multiselect' }, + attributeIsArchived: { type: 'string', description: 'Whether the attribute is archived' }, + attributeConfig: { type: 'json', description: 'Type-dependent attribute configuration' }, + attributeShowArchived: { + type: 'string', + description: 'Whether to include archived attributes', + }, + taskSort: { type: 'string', description: 'Task list sort order' }, limit: { type: 'string', description: 'Maximum number of results' }, offset: { type: 'string', description: 'Number of results to skip for pagination' }, }, @@ -1289,6 +1836,7 @@ workspace-member.created content: { type: 'string', description: 'Task or note content' }, deadlineAt: { type: 'string', description: 'Task deadline' }, isCompleted: { type: 'boolean', description: 'Task completion status' }, + completedAt: { type: 'string', description: 'When the task was completed' }, linkedRecords: { type: 'json', description: 'Linked records' }, assignees: { type: 'json', description: 'Task assignees' }, objects: { type: 'json', description: 'Array of objects' }, @@ -1322,6 +1870,32 @@ workspace-member.created deleted: { type: 'boolean', description: 'Whether the item was deleted' }, createdAt: { type: 'string', description: 'When the item was created' }, success: { type: 'boolean', description: 'Whether the operation succeeded' }, + attributes: { type: 'json', description: 'Array of attributes' }, + attributeId: { type: 'string', description: 'The attribute ID' }, + description: { type: 'string', description: 'The attribute description' }, + type: { type: 'string', description: 'The attribute value type' }, + isSystemAttribute: { + type: 'boolean', + description: 'Whether this is a built-in system attribute', + }, + isWritable: { type: 'boolean', description: 'Whether the attribute can be written to' }, + isRequired: { type: 'boolean', description: 'Whether the attribute is required' }, + isUnique: { type: 'boolean', description: 'Whether the attribute enforces uniqueness' }, + isMultiselect: { type: 'boolean', description: 'Whether the attribute is multiselect' }, + isDefaultValueEnabled: { + type: 'boolean', + description: 'Whether this attribute has a default value enabled', + }, + isArchived: { type: 'boolean', description: 'Whether the attribute is archived' }, + defaultValue: { + type: 'json', + description: 'The default value for this attribute, if enabled', + }, + relationship: { + type: 'json', + description: 'The related attribute, if this attribute is part of a relationship', + }, + config: { type: 'json', description: 'Type-dependent attribute configuration' }, }, } diff --git a/apps/sim/tools/attio/assert_record.ts b/apps/sim/tools/attio/assert_record.ts index 00864d740a..71707cdae9 100644 --- a/apps/sim/tools/attio/assert_record.ts +++ b/apps/sim/tools/attio/assert_record.ts @@ -56,11 +56,11 @@ export const attioAssertRecordTool: ToolConfig { - let values: Record = {} + let values: Record try { values = typeof params.values === 'string' ? JSON.parse(params.values) : params.values } catch { - values = {} + throw new Error('Invalid JSON provided for record values') } return { data: { values } } }, diff --git a/apps/sim/tools/attio/create_attribute.ts b/apps/sim/tools/attio/create_attribute.ts new file mode 100644 index 0000000000..fe36235865 --- /dev/null +++ b/apps/sim/tools/attio/create_attribute.ts @@ -0,0 +1,157 @@ +import { createLogger } from '@sim/logger' +import type { ToolConfig } from '@/tools/types' +import type { AttioCreateAttributeParams, AttioCreateAttributeResponse } from './types' +import { ATTRIBUTE_OUTPUT_PROPERTIES } from './types' + +const logger = createLogger('AttioCreateAttribute') + +export const attioCreateAttributeTool: ToolConfig< + AttioCreateAttributeParams, + AttioCreateAttributeResponse +> = { + id: 'attio_create_attribute', + name: 'Attio Create Attribute', + description: 'Create a new attribute (schema field) on an Attio object or list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The OAuth access token for the Attio API', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Whether to create the attribute on an object or a list: objects or lists', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object or list ID or slug (e.g. people, companies)', + }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The attribute display title', + }, + apiSlug: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The attribute API slug (unique, snake_case)', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The attribute value type (e.g. text, number, checkbox, currency, date, timestamp, rating, status, select, record-reference, actor-reference, location, domain, email-address, phone-number)', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'A description of the attribute', + }, + isRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether new records must provide a value (default false)', + }, + isUnique: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the attribute enforces uniqueness on new data (default false)', + }, + isMultiselect: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the attribute supports multiple values (default false)', + }, + config: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object of type-dependent configuration (e.g. currency or record-reference settings)', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const data: Record = { + title: params.title, + api_slug: params.apiSlug, + description: params.description ?? null, + type: params.type, + is_required: params.isRequired ?? false, + is_unique: params.isUnique ?? false, + is_multiselect: params.isMultiselect ?? false, + // `config` is a required key on Attio's create-attribute request body (even though its + // nested fields are only required for type-dependent configs like currency/record-reference). + config: {}, + } + if (params.config) { + try { + data.config = + typeof params.config === 'string' ? JSON.parse(params.config) : params.config + } catch { + throw new Error('Invalid JSON provided for attribute config') + } + } + return { data } + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || 'Failed to create attribute') + } + const attr = data.data + return { + success: true, + output: { + attributeId: attr.id?.attribute_id ?? null, + title: attr.title ?? null, + apiSlug: attr.api_slug ?? null, + description: attr.description ?? null, + type: attr.type ?? null, + isSystemAttribute: attr.is_system_attribute ?? false, + isWritable: attr.is_writable ?? false, + isRequired: attr.is_required ?? false, + isUnique: attr.is_unique ?? false, + isMultiselect: attr.is_multiselect ?? false, + isDefaultValueEnabled: attr.is_default_value_enabled ?? false, + isArchived: attr.is_archived ?? false, + defaultValue: attr.default_value ?? null, + relationship: attr.relationship ?? null, + config: attr.config ?? null, + createdAt: attr.created_at ?? null, + }, + } + }, + + outputs: ATTRIBUTE_OUTPUT_PROPERTIES, +} diff --git a/apps/sim/tools/attio/create_comment.ts b/apps/sim/tools/attio/create_comment.ts index e9fe596ed9..b7fec32612 100644 --- a/apps/sim/tools/attio/create_comment.ts +++ b/apps/sim/tools/attio/create_comment.ts @@ -52,21 +52,37 @@ export const attioCreateCommentTool: ToolConfig< }, list: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', - description: 'The list ID or slug the entry belongs to', + description: + 'The list ID or slug the entry belongs to (used with entryId; omit if threadId or recordId is set)', }, entryId: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', - description: 'The entry ID to comment on', + description: + 'The list entry ID to comment on (used with list; omit if threadId or recordId is set)', + }, + recordObject: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The object ID or slug the record belongs to (used with recordId; omit if threadId or entryId is set)', + }, + recordId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The record ID to comment on directly (used with recordObject; omit if threadId or entryId is set)', }, threadId: { type: 'string', required: false, visibility: 'user-or-llm', - description: 'Thread ID to reply to (omit to start a new thread)', + description: 'Thread ID to reply to (omit to start a new thread on a record or list entry)', }, createdAt: { type: 'string', @@ -91,12 +107,25 @@ export const attioCreateCommentTool: ToolConfig< type: params.authorType, id: params.authorId, }, - entry: { + } + // Attio's comment body accepts exactly one of `thread_id`, `record`, or `entry` — mutually exclusive. + if (params.threadId) { + data.thread_id = params.threadId + } else if (params.recordObject && params.recordId) { + data.record = { + object: params.recordObject, + record_id: params.recordId, + } + } else if (params.list && params.entryId) { + data.entry = { list: params.list, entry_id: params.entryId, - }, + } + } else { + throw new Error( + 'Must provide either threadId, both recordObject and recordId, or both list and entryId' + ) } - if (params.threadId) data.thread_id = params.threadId if (params.createdAt) data.created_at = params.createdAt return { data } }, diff --git a/apps/sim/tools/attio/create_note.ts b/apps/sim/tools/attio/create_note.ts index 3124959a92..792366db66 100644 --- a/apps/sim/tools/attio/create_note.ts +++ b/apps/sim/tools/attio/create_note.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import type { ToolConfig } from '@/tools/types' import type { AttioCreateNoteParams, AttioCreateNoteResponse } from './types' -import { NOTE_OUTPUT_PROPERTIES } from './types' +import { mapNoteTags, NOTE_OUTPUT_PROPERTIES } from './types' const logger = createLogger('AttioCreateNote') @@ -105,7 +105,7 @@ export const attioCreateNoteTool: ToolConfig = + { + id: 'attio_get_attribute', + name: 'Attio Get Attribute', + description: 'Get a single attribute (schema field) on an Attio object or list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The OAuth access token for the Attio API', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Whether the attribute belongs to an object or a list: objects or lists', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object or list ID or slug (e.g. people, companies)', + }, + attribute: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The attribute ID or slug', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes/${params.attribute.trim()}`, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || 'Failed to get attribute') + } + const attr = data.data + return { + success: true, + output: { + attributeId: attr.id?.attribute_id ?? null, + title: attr.title ?? null, + apiSlug: attr.api_slug ?? null, + description: attr.description ?? null, + type: attr.type ?? null, + isSystemAttribute: attr.is_system_attribute ?? false, + isWritable: attr.is_writable ?? false, + isRequired: attr.is_required ?? false, + isUnique: attr.is_unique ?? false, + isMultiselect: attr.is_multiselect ?? false, + isDefaultValueEnabled: attr.is_default_value_enabled ?? false, + isArchived: attr.is_archived ?? false, + defaultValue: attr.default_value ?? null, + relationship: attr.relationship ?? null, + config: attr.config ?? null, + createdAt: attr.created_at ?? null, + }, + } + }, + + outputs: ATTRIBUTE_OUTPUT_PROPERTIES, + } diff --git a/apps/sim/tools/attio/get_note.ts b/apps/sim/tools/attio/get_note.ts index 0a9ad4a8e7..b253e975bc 100644 --- a/apps/sim/tools/attio/get_note.ts +++ b/apps/sim/tools/attio/get_note.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import type { ToolConfig } from '@/tools/types' import type { AttioGetNoteParams, AttioGetNoteResponse } from './types' -import { NOTE_OUTPUT_PROPERTIES } from './types' +import { mapNoteTags, NOTE_OUTPUT_PROPERTIES } from './types' const logger = createLogger('AttioGetNote') @@ -56,7 +56,7 @@ export const attioGetNoteTool: ToolConfig ({ Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', }), }, @@ -66,6 +65,7 @@ export const attioGetTaskTool: ToolConfig = { + id: 'attio_list_attributes', + name: 'Attio List Attributes', + description: 'List the attributes (schema fields) defined on an Attio object or list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The OAuth access token for the Attio API', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Whether the attributes belong to an object or a list: objects or lists', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object or list ID or slug (e.g. people, companies)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of attributes to return', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of attributes to skip for pagination', + }, + showArchived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include archived attributes (default false)', + }, + }, + + request: { + url: (params) => { + const searchParams = new URLSearchParams() + if (params.limit != null) searchParams.set('limit', String(params.limit)) + if (params.offset != null) searchParams.set('offset', String(params.offset)) + if (params.showArchived != null) + searchParams.set('show_archived', String(params.showArchived)) + const qs = searchParams.toString() + return `https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes${qs ? `?${qs}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || 'Failed to list attributes') + } + const attributes = (data.data ?? []).map((attr: Record) => { + const id = attr.id as { attribute_id?: string } | undefined + return { + attributeId: id?.attribute_id ?? null, + title: (attr.title as string) ?? null, + apiSlug: (attr.api_slug as string) ?? null, + description: (attr.description as string) ?? null, + type: (attr.type as string) ?? null, + isSystemAttribute: (attr.is_system_attribute as boolean) ?? false, + isWritable: (attr.is_writable as boolean) ?? false, + isRequired: (attr.is_required as boolean) ?? false, + isUnique: (attr.is_unique as boolean) ?? false, + isMultiselect: (attr.is_multiselect as boolean) ?? false, + isDefaultValueEnabled: (attr.is_default_value_enabled as boolean) ?? false, + isArchived: (attr.is_archived as boolean) ?? false, + defaultValue: (attr.default_value as Record) ?? null, + relationship: (attr.relationship as Record) ?? null, + config: (attr.config as Record) ?? null, + createdAt: (attr.created_at as string) ?? null, + } + }) + return { + success: true, + output: { + attributes, + count: attributes.length, + }, + } + }, + + outputs: { + attributes: { + type: 'array', + description: 'Array of attributes', + items: { + type: 'object', + properties: ATTRIBUTE_OUTPUT_PROPERTIES, + }, + }, + count: { type: 'number', description: 'Number of attributes returned' }, + }, +} diff --git a/apps/sim/tools/attio/list_notes.ts b/apps/sim/tools/attio/list_notes.ts index fe7230752d..ffd013cfec 100644 --- a/apps/sim/tools/attio/list_notes.ts +++ b/apps/sim/tools/attio/list_notes.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import type { ToolConfig } from '@/tools/types' import type { AttioListNotesParams, AttioListNotesResponse } from './types' -import { NOTE_OUTPUT_PROPERTIES } from './types' +import { mapNoteTags, NOTE_OUTPUT_PROPERTIES } from './types' const logger = createLogger('AttioListNotes') @@ -81,7 +81,7 @@ export const attioListNotesTool: ToolConfig ({ + type: tag.type ?? null, + workspaceMemberId: tag.workspace_member_id ?? null, + object: tag.object ?? null, + recordId: tag.record_id ?? null, + })) +} + /** Reusable actor shape returned by the Attio API */ export const ACTOR_OUTPUT_PROPERTIES = { type: { @@ -70,8 +96,22 @@ export const NOTE_OUTPUT_PROPERTIES = { items: { type: 'object', properties: { - type: { type: 'string', description: 'The tag type (e.g. workspace-member)' }, - workspaceMemberId: { type: 'string', description: 'The workspace member ID of the tagger' }, + type: { type: 'string', description: 'The tag type (workspace-member or record)' }, + workspaceMemberId: { + type: 'string', + description: 'The workspace member ID (present when type is workspace-member)', + optional: true, + }, + object: { + type: 'string', + description: 'The tagged object slug (present when type is record)', + optional: true, + }, + recordId: { + type: 'string', + description: 'The tagged record ID (present when type is record)', + optional: true, + }, }, }, }, @@ -89,6 +129,7 @@ export const TASK_OUTPUT_PROPERTIES = { content: { type: 'string', description: 'The task content' }, deadlineAt: { type: 'string', description: 'The task deadline', optional: true }, isCompleted: { type: 'boolean', description: 'Whether the task is completed' }, + completedAt: { type: 'string', description: 'When the task was completed', optional: true }, linkedRecords: { type: 'array', description: 'Records linked to this task', @@ -162,6 +203,43 @@ export const MEMBER_OUTPUT_PROPERTIES = { createdAt: { type: 'string', description: 'When the member was added' }, } as const satisfies Record +/** Shared output properties for Attio attributes */ +export const ATTRIBUTE_OUTPUT_PROPERTIES = { + attributeId: { type: 'string', description: 'The attribute ID' }, + title: { type: 'string', description: 'The attribute display title' }, + apiSlug: { type: 'string', description: 'The attribute API slug' }, + description: { type: 'string', description: 'The attribute description', optional: true }, + type: { + type: 'string', + description: 'The attribute value type (e.g. text, number, select, record-reference)', + }, + isSystemAttribute: { + type: 'boolean', + description: 'Whether this is a built-in system attribute', + }, + isWritable: { type: 'boolean', description: 'Whether the attribute can be written to' }, + isRequired: { type: 'boolean', description: 'Whether new records must provide a value' }, + isUnique: { type: 'boolean', description: 'Whether the attribute enforces uniqueness' }, + isMultiselect: { type: 'boolean', description: 'Whether the attribute supports multiple values' }, + isDefaultValueEnabled: { + type: 'boolean', + description: 'Whether this attribute has a default value enabled', + }, + isArchived: { type: 'boolean', description: 'Whether the attribute is archived' }, + defaultValue: { + type: 'json', + description: 'The default value for this attribute, if enabled', + optional: true, + }, + relationship: { + type: 'json', + description: 'The related attribute, if this attribute is part of a relationship', + optional: true, + }, + config: { type: 'json', description: 'Type-dependent attribute configuration', optional: true }, + createdAt: { type: 'string', description: 'When the attribute was created' }, +} as const satisfies Record + /** Shared output properties for Attio comments */ export const COMMENT_OUTPUT_PROPERTIES = { commentId: { type: 'string', description: 'The comment ID' }, @@ -248,32 +326,6 @@ interface AttioRecord { values: Record } -/** Raw Attio note shape from the API */ -interface AttioNote { - id: { workspace_id: string; note_id: string } - parent_object: string - parent_record_id: string - title: string - content_plaintext: string - content_markdown: string - meeting_id: string | null - tags: unknown[] - created_by_actor: unknown - created_at: string -} - -/** Raw Attio task shape from the API */ -interface AttioTask { - id: { workspace_id: string; task_id: string } - content_plaintext: string - deadline_at: string | null - is_completed: boolean - linked_records: Array<{ target_object_id: string; target_record_id: string }> - assignees: Array<{ referenced_actor_type: string; referenced_actor_id: string }> - created_by_actor: unknown - created_at: string -} - /** Params for listing/querying records */ export interface AttioListRecordsParams { accessToken: string @@ -453,7 +505,7 @@ export interface AttioListNotesResponse extends ToolResponse { contentPlaintext: string | null contentMarkdown: string | null meetingId: string | null - tags: unknown[] + tags: NoteTagOutput[] createdByActor: unknown createdAt: string | null }> @@ -471,7 +523,7 @@ export interface AttioCreateNoteResponse extends ToolResponse { contentPlaintext: string | null contentMarkdown: string | null meetingId: string | null - tags: unknown[] + tags: NoteTagOutput[] createdByActor: unknown createdAt: string | null } @@ -492,6 +544,7 @@ export interface AttioListTasksResponse extends ToolResponse { content: string | null deadlineAt: string | null isCompleted: boolean + completedAt: string | null linkedRecords: Array<{ targetObjectId: string | null; targetRecordId: string | null }> assignees: Array<{ type: string | null; id: string | null }> createdByActor: unknown @@ -508,6 +561,7 @@ export interface AttioCreateTaskResponse extends ToolResponse { content: string | null deadlineAt: string | null isCompleted: boolean + completedAt: string | null linkedRecords: Array<{ targetObjectId: string | null; targetRecordId: string | null }> assignees: Array<{ type: string | null; id: string | null }> createdByActor: unknown @@ -522,6 +576,7 @@ export interface AttioUpdateTaskResponse extends ToolResponse { content: string | null deadlineAt: string | null isCompleted: boolean + completedAt: string | null linkedRecords: Array<{ targetObjectId: string | null; targetRecordId: string | null }> assignees: Array<{ type: string | null; id: string | null }> createdByActor: unknown @@ -542,6 +597,7 @@ export interface AttioGetTaskResponse extends ToolResponse { content: string | null deadlineAt: string | null isCompleted: boolean + completedAt: string | null linkedRecords: Array<{ targetObjectId: string | null; targetRecordId: string | null }> assignees: Array<{ type: string | null; id: string | null }> createdByActor: unknown @@ -572,7 +628,7 @@ export interface AttioGetNoteResponse extends ToolResponse { contentPlaintext: string | null contentMarkdown: string | null meetingId: string | null - tags: unknown[] + tags: NoteTagOutput[] createdByActor: unknown createdAt: string | null } @@ -684,7 +740,7 @@ export interface AttioListListsResponse extends ToolResponse { name: string | null parentObject: string | null workspaceAccess: string | null - workspaceMemberAccess: string | null + workspaceMemberAccess: unknown createdByActor: { type: string | null; id: string | null } | null createdAt: string | null }> @@ -706,7 +762,7 @@ export interface AttioGetListResponse extends ToolResponse { name: string | null parentObject: string | null workspaceAccess: string | null - workspaceMemberAccess: string | null + workspaceMemberAccess: unknown createdByActor: { type: string | null; id: string | null } | null createdAt: string | null } @@ -730,7 +786,7 @@ export interface AttioCreateListResponse extends ToolResponse { name: string | null parentObject: string | null workspaceAccess: string | null - workspaceMemberAccess: string | null + workspaceMemberAccess: unknown createdByActor: { type: string | null; id: string | null } | null createdAt: string | null } @@ -754,7 +810,7 @@ export interface AttioUpdateListResponse extends ToolResponse { name: string | null parentObject: string | null workspaceAccess: string | null - workspaceMemberAccess: string | null + workspaceMemberAccess: unknown createdByActor: { type: string | null; id: string | null } | null createdAt: string | null } @@ -906,8 +962,10 @@ export interface AttioCreateCommentParams { format?: string authorType: string authorId: string - list: string - entryId: string + list?: string + entryId?: string + recordObject?: string + recordId?: string threadId?: string createdAt?: string } @@ -1098,6 +1156,97 @@ export interface AttioDeleteWebhookResponse extends ToolResponse { } } +/** Params for listing attributes on an object or list */ +export interface AttioListAttributesParams { + accessToken: string + target: string + identifier: string + limit?: number + offset?: number + showArchived?: boolean +} + +/** Attribute shape as returned in tool outputs (camelCase) */ +interface AttioAttributeOutput { + attributeId: string | null + title: string | null + apiSlug: string | null + description: string | null + type: string | null + isSystemAttribute: boolean + isWritable: boolean + isRequired: boolean + isUnique: boolean + isMultiselect: boolean + isDefaultValueEnabled: boolean + isArchived: boolean + defaultValue: Record | null + relationship: Record | null + config: Record | null + createdAt: string | null +} + +/** Response for listing attributes */ +export interface AttioListAttributesResponse extends ToolResponse { + output: { + attributes: AttioAttributeOutput[] + count: number + } +} + +/** Params for getting a single attribute */ +export interface AttioGetAttributeParams { + accessToken: string + target: string + identifier: string + attribute: string +} + +/** Response for getting a single attribute */ +export interface AttioGetAttributeResponse extends ToolResponse { + output: AttioAttributeOutput +} + +/** Params for creating an attribute */ +export interface AttioCreateAttributeParams { + accessToken: string + target: string + identifier: string + title: string + apiSlug: string + type: string + description?: string + isRequired?: boolean + isUnique?: boolean + isMultiselect?: boolean + config?: string +} + +/** Response for creating an attribute */ +export interface AttioCreateAttributeResponse extends ToolResponse { + output: AttioAttributeOutput +} + +/** Params for updating an attribute */ +export interface AttioUpdateAttributeParams { + accessToken: string + target: string + identifier: string + attribute: string + title?: string + apiSlug?: string + description?: string + isRequired?: boolean + isUnique?: boolean + isArchived?: boolean + config?: string +} + +/** Response for updating an attribute */ +export interface AttioUpdateAttributeResponse extends ToolResponse { + output: AttioAttributeOutput +} + export type AttioResponse = | AttioListRecordsResponse | AttioGetRecordResponse @@ -1140,3 +1289,7 @@ export type AttioResponse = | AttioCreateWebhookResponse | AttioUpdateWebhookResponse | AttioDeleteWebhookResponse + | AttioListAttributesResponse + | AttioGetAttributeResponse + | AttioCreateAttributeResponse + | AttioUpdateAttributeResponse diff --git a/apps/sim/tools/attio/update_attribute.ts b/apps/sim/tools/attio/update_attribute.ts new file mode 100644 index 0000000000..9cfd9c5d9d --- /dev/null +++ b/apps/sim/tools/attio/update_attribute.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import type { ToolConfig } from '@/tools/types' +import type { AttioUpdateAttributeParams, AttioUpdateAttributeResponse } from './types' +import { ATTRIBUTE_OUTPUT_PROPERTIES } from './types' + +const logger = createLogger('AttioUpdateAttribute') + +export const attioUpdateAttributeTool: ToolConfig< + AttioUpdateAttributeParams, + AttioUpdateAttributeResponse +> = { + id: 'attio_update_attribute', + name: 'Attio Update Attribute', + description: 'Update an attribute (schema field) on an Attio object or list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The OAuth access token for the Attio API', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Whether the attribute belongs to an object or a list: objects or lists', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object or list ID or slug (e.g. people, companies)', + }, + attribute: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The attribute ID or slug to update', + }, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New attribute display title', + }, + apiSlug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New attribute API slug', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New attribute description', + }, + isRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether new records must provide a value', + }, + isUnique: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the attribute enforces uniqueness on new data', + }, + isArchived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Archive or unarchive the attribute', + }, + config: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON object of type-dependent configuration', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes/${params.attribute.trim()}`, + method: 'PATCH', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const data: Record = {} + if (params.title != null) data.title = params.title + if (params.apiSlug != null) data.api_slug = params.apiSlug + if (params.description != null) data.description = params.description + if (params.isRequired != null) data.is_required = params.isRequired + if (params.isUnique != null) data.is_unique = params.isUnique + if (params.isArchived != null) data.is_archived = params.isArchived + if (params.config) { + try { + data.config = + typeof params.config === 'string' ? JSON.parse(params.config) : params.config + } catch { + throw new Error('Invalid JSON provided for attribute config') + } + } + return { data } + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || 'Failed to update attribute') + } + const attr = data.data + return { + success: true, + output: { + attributeId: attr.id?.attribute_id ?? null, + title: attr.title ?? null, + apiSlug: attr.api_slug ?? null, + description: attr.description ?? null, + type: attr.type ?? null, + isSystemAttribute: attr.is_system_attribute ?? false, + isWritable: attr.is_writable ?? false, + isRequired: attr.is_required ?? false, + isUnique: attr.is_unique ?? false, + isMultiselect: attr.is_multiselect ?? false, + isDefaultValueEnabled: attr.is_default_value_enabled ?? false, + isArchived: attr.is_archived ?? false, + defaultValue: attr.default_value ?? null, + relationship: attr.relationship ?? null, + config: attr.config ?? null, + createdAt: attr.created_at ?? null, + }, + } + }, + + outputs: ATTRIBUTE_OUTPUT_PROPERTIES, +} diff --git a/apps/sim/tools/attio/update_record.ts b/apps/sim/tools/attio/update_record.ts index 21d613f929..25548f4628 100644 --- a/apps/sim/tools/attio/update_record.ts +++ b/apps/sim/tools/attio/update_record.ts @@ -57,7 +57,7 @@ export const attioUpdateRecordTool: ToolConfig = { airtable_update_record: airtableUpdateRecordTool, airtable_upsert_records: airtableUpsertRecordsTool, attio_assert_record: attioAssertRecordTool, + attio_create_attribute: attioCreateAttributeTool, attio_create_comment: attioCreateCommentTool, attio_create_list: attioCreateListTool, attio_create_list_entry: attioCreateListEntryTool, @@ -6812,6 +6817,7 @@ export const tools: Record = { attio_delete_record: attioDeleteRecordTool, attio_delete_task: attioDeleteTaskTool, attio_delete_webhook: attioDeleteWebhookTool, + attio_get_attribute: attioGetAttributeTool, attio_get_comment: attioGetCommentTool, attio_get_list: attioGetListTool, attio_get_list_entry: attioGetListEntryTool, @@ -6822,6 +6828,7 @@ export const tools: Record = { attio_get_task: attioGetTaskTool, attio_get_thread: attioGetThreadTool, attio_get_webhook: attioGetWebhookTool, + attio_list_attributes: attioListAttributesTool, attio_list_lists: attioListListsTool, attio_list_members: attioListMembersTool, attio_list_notes: attioListNotesTool, @@ -6832,6 +6839,7 @@ export const tools: Record = { attio_list_webhooks: attioListWebhooksTool, attio_query_list_entries: attioQueryListEntriesTool, attio_search_records: attioSearchRecordsTool, + attio_update_attribute: attioUpdateAttributeTool, attio_update_list: attioUpdateListTool, attio_update_list_entry: attioUpdateListEntryTool, attio_update_object: attioUpdateObjectTool,