From 13eff4ce2c4518253d3979fcfa1bc5bae386706f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 24 Feb 2026 19:48:33 +0000 Subject: [PATCH] feat: add Attio CRM integration - Add Attio tools: list_records, get_record, create_record, update_record, search_records - Add AttioBlock with operations for managing CRM records - Add AttioIcon to icons.tsx - Register Attio OAuth provider with required scopes - Add ATTIO_CLIENT_ID and ATTIO_CLIENT_SECRET env variables - Support for people, companies, and custom objects Co-authored-by: Emir Karabeg --- apps/sim/blocks/blocks/attio.ts | 276 +++++++++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 12 ++ apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/oauth/oauth.ts | 33 +++ apps/sim/lib/oauth/types.ts | 2 + apps/sim/tools/attio/create_record.ts | 101 +++++++++ apps/sim/tools/attio/get_record.ts | 81 ++++++++ apps/sim/tools/attio/index.ts | 5 + apps/sim/tools/attio/list_records.ts | 133 ++++++++++++ apps/sim/tools/attio/search_records.ts | 122 +++++++++++ apps/sim/tools/attio/types.ts | 240 +++++++++++++++++++++ apps/sim/tools/attio/update_record.ts | 106 ++++++++++ apps/sim/tools/registry.ts | 12 ++ 14 files changed, 1127 insertions(+) create mode 100644 apps/sim/blocks/blocks/attio.ts create mode 100644 apps/sim/tools/attio/create_record.ts create mode 100644 apps/sim/tools/attio/get_record.ts create mode 100644 apps/sim/tools/attio/index.ts create mode 100644 apps/sim/tools/attio/list_records.ts create mode 100644 apps/sim/tools/attio/search_records.ts create mode 100644 apps/sim/tools/attio/types.ts create mode 100644 apps/sim/tools/attio/update_record.ts diff --git a/apps/sim/blocks/blocks/attio.ts b/apps/sim/blocks/blocks/attio.ts new file mode 100644 index 0000000000..e16d297637 --- /dev/null +++ b/apps/sim/blocks/blocks/attio.ts @@ -0,0 +1,276 @@ +import { AttioIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' +import type { AttioResponse } from '@/tools/attio/types' + +export const AttioBlock: BlockConfig = { + type: 'attio', + name: 'Attio', + description: 'Interact with Attio CRM to manage records', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate Attio into your workflow. Manage people, companies, deals, and custom objects with powerful CRM automation capabilities. Create, update, search, and list records across your Attio workspace.', + docsLink: 'https://docs.attio.com', + category: 'tools', + bgColor: '#000000', + icon: AttioIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Records', id: 'list_records' }, + { label: 'Get Record', id: 'get_record' }, + { label: 'Create Record', id: 'create_record' }, + { label: 'Update Record', id: 'update_record' }, + { label: 'Search Records', id: 'search_records' }, + ], + value: () => 'list_records', + }, + { + id: 'credential', + title: 'Attio Account', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'basic', + serviceId: 'attio', + requiredScopes: [ + 'record_permission:read', + 'record_permission:read-write', + 'object_configuration:read', + ], + placeholder: 'Select Attio account', + required: true, + }, + { + id: 'manualCredential', + title: 'Attio Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + { + id: 'object', + title: 'Object Type', + type: 'short-input', + placeholder: 'Object slug (e.g., "people", "companies", or custom object)', + condition: { + field: 'operation', + value: ['list_records', 'get_record', 'create_record', 'update_record'], + }, + required: { + field: 'operation', + value: ['list_records', 'get_record', 'create_record', 'update_record'], + }, + }, + { + id: 'recordId', + title: 'Record ID', + type: 'short-input', + placeholder: 'The unique record ID', + condition: { field: 'operation', value: ['get_record', 'update_record'] }, + required: { field: 'operation', value: ['get_record', 'update_record'] }, + }, + { + id: 'values', + title: 'Record Values', + type: 'long-input', + placeholder: + 'JSON object with attribute values (e.g., {"name": "John Doe", "email_addresses": "john@example.com"})', + condition: { field: 'operation', value: ['create_record', 'update_record'] }, + required: { field: 'operation', value: ['create_record', 'update_record'] }, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert Attio CRM developer. Generate Attio record values as JSON based on the user's request. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the JSON object with Attio attribute values. Do not include any explanations, markdown formatting, comments, or additional text. Just the raw JSON object that can be used directly in Attio API create/update operations. + +### ATTIO VALUES STRUCTURE +Attio record values are defined as a JSON object with attribute slugs as keys. Values can be simple types or arrays for multi-value attributes. + +### COMMON PEOPLE ATTRIBUTES +- **name**: Full name (text) +- **email_addresses**: Email address(es) - string or array +- **phone_numbers**: Phone number(s) - string or array +- **job_title**: Job title (text) +- **description**: Description/notes (text) +- **linkedin_url**: LinkedIn profile URL (text) +- **twitter_url**: Twitter/X profile URL (text) + +### COMMON COMPANY ATTRIBUTES +- **name**: Company name (text) +- **domains**: Domain(s) - string or array (e.g., "example.com") +- **description**: Company description (text) +- **industry**: Industry (text) +- **employee_count**: Number of employees (number) +- **linkedin_url**: LinkedIn company page (text) +- **twitter_url**: Twitter/X handle (text) + +### EXAMPLES + +**Simple Person**: "Create a person named John Doe with email john@example.com" +→ { + "name": "John Doe", + "email_addresses": "john@example.com" +} + +**Complete Person**: "Create a person with full details" +→ { + "name": "Jane Smith", + "email_addresses": ["jane@company.com", "jane.personal@email.com"], + "phone_numbers": "+1-555-123-4567", + "job_title": "Marketing Director", + "description": "Key decision maker for marketing initiatives" +} + +**Simple Company**: "Create a company called Acme Corp" +→ { + "name": "Acme Corp", + "domains": "acme.com" +} + +**Complete Company**: "Create a tech company with full details" +→ { + "name": "TechStart Inc", + "domains": ["techstart.io", "techstart.com"], + "industry": "Technology", + "employee_count": 50, + "description": "Innovative software solutions company" +} + +### REMEMBER +Return ONLY the JSON object with attribute values - no explanations, no markdown, no extra text.`, + placeholder: 'Describe the record values you want to set...', + generationType: 'json-object', + }, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: 'Search term (names, domains, emails, phone numbers)', + condition: { field: 'operation', value: 'search_records' }, + required: { field: 'operation', value: 'search_records' }, + }, + { + id: 'objects', + title: 'Object Types to Search', + type: 'short-input', + placeholder: 'Comma-separated object slugs (e.g., "people,companies") or leave empty for all', + condition: { field: 'operation', value: 'search_records' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: 'Max results (default: 25, max: 500 for list, 25 for search)', + condition: { + field: 'operation', + value: ['list_records', 'search_records'], + }, + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: 'Number of records to skip for pagination', + condition: { field: 'operation', value: 'list_records' }, + }, + { + id: 'attributes', + title: 'Attributes to Return', + type: 'short-input', + placeholder: 'Comma-separated attribute slugs (e.g., "name,email_addresses")', + condition: { field: 'operation', value: 'list_records' }, + }, + ], + tools: { + access: [ + 'attio_list_records', + 'attio_get_record', + 'attio_create_record', + 'attio_update_record', + 'attio_search_records', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'list_records': + return 'attio_list_records' + case 'get_record': + return 'attio_get_record' + case 'create_record': + return 'attio_create_record' + case 'update_record': + return 'attio_update_record' + case 'search_records': + return 'attio_search_records' + default: + throw new Error(`Unknown operation: ${params.operation}`) + } + }, + params: (params) => { + const { oauthCredential, operation, attributes, objects, ...rest } = params + + const cleanParams: Record = { + oauthCredential, + } + + if (attributes && operation === 'list_records') { + const parsedAttributes = + typeof attributes === 'string' + ? attributes.split(',').map((a: string) => a.trim()) + : attributes + cleanParams.attributes = parsedAttributes + } + + if (objects && operation === 'search_records') { + const parsedObjects = + typeof objects === 'string' ? objects.split(',').map((o: string) => o.trim()) : objects + cleanParams.objects = parsedObjects + } + + Object.entries(rest).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + if (key === 'limit' || key === 'offset') { + cleanParams[key] = Number(value) + } else { + cleanParams[key] = value + } + } + }) + + return cleanParams + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + oauthCredential: { type: 'string', description: 'Attio access token' }, + object: { type: 'string', description: 'Object type slug (e.g., people, companies)' }, + recordId: { type: 'string', description: 'Record ID for get/update operations' }, + values: { type: 'json', description: 'Record values to create/update (JSON object)' }, + query: { type: 'string', description: 'Search query string' }, + objects: { type: 'string', description: 'Comma-separated object types to search' }, + limit: { type: 'number', description: 'Maximum results to return' }, + offset: { type: 'number', description: 'Number of records to skip' }, + attributes: { type: 'string', description: 'Comma-separated attribute slugs to return' }, + }, + outputs: { + records: { type: 'json', description: 'Array of record objects' }, + record: { type: 'json', description: 'Single record object' }, + recordId: { type: 'string', description: 'Record ID' }, + total: { type: 'number', description: 'Total number of matching results' }, + paging: { type: 'json', description: 'Pagination info' }, + metadata: { type: 'json', description: 'Operation metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index abc25f764f..03b9827a77 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -10,6 +10,7 @@ import { ApifyBlock } from '@/blocks/blocks/apify' import { ApolloBlock } from '@/blocks/blocks/apollo' import { ArxivBlock } from '@/blocks/blocks/arxiv' import { AsanaBlock } from '@/blocks/blocks/asana' +import { AttioBlock } from '@/blocks/blocks/attio' import { BrowserUseBlock } from '@/blocks/blocks/browser_use' import { CalComBlock } from '@/blocks/blocks/calcom' import { CalendlyBlock } from '@/blocks/blocks/calendly' @@ -187,6 +188,7 @@ export const registry: Record = { apollo: ApolloBlock, arxiv: ArxivBlock, asana: AsanaBlock, + attio: AttioBlock, browser_use: BrowserUseBlock, calcom: CalComBlock, calendly: CalendlyBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 5eb321929a..fcd0c750b0 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1179,6 +1179,18 @@ export function AlgoliaIcon(props: SVGProps) { ) } +export function AttioIcon(props: SVGProps) { + return ( + + + + + ) +} + export function GoogleBooksIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 0478e6f1e5..d18a62b6cc 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -240,6 +240,8 @@ export const env = createEnv({ JIRA_CLIENT_SECRET: z.string().optional(), // Atlassian Jira OAuth client secret ASANA_CLIENT_ID: z.string().optional(), // Asana OAuth client ID ASANA_CLIENT_SECRET: z.string().optional(), // Asana OAuth client secret + ATTIO_CLIENT_ID: z.string().optional(), // Attio OAuth client ID + ATTIO_CLIENT_SECRET: z.string().optional(), // Attio OAuth client secret AIRTABLE_CLIENT_ID: z.string().optional(), // Airtable OAuth client ID AIRTABLE_CLIENT_SECRET: z.string().optional(), // Airtable OAuth client secret APOLLO_API_KEY: z.string().optional(), // Apollo API key (optional system-wide config) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 11d91f6495..136d5ff88a 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { AirtableIcon, AsanaIcon, + AttioIcon, CalComIcon, ConfluenceIcon, DropboxIcon, @@ -629,6 +630,25 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'asana', }, + attio: { + name: 'Attio', + icon: AttioIcon, + services: { + attio: { + name: 'Attio', + description: 'Manage people, companies, and custom objects in Attio CRM.', + providerId: 'attio', + icon: AttioIcon, + baseProviderIcon: AttioIcon, + scopes: [ + 'record_permission:read', + 'record_permission:read-write', + 'object_configuration:read', + ], + }, + }, + defaultService: 'attio', + }, calcom: { name: 'Cal.com', icon: CalComIcon, @@ -1045,6 +1065,19 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: true, } } + case 'attio': { + const { clientId, clientSecret } = getCredentials( + env.ATTIO_CLIENT_ID, + env.ATTIO_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://app.attio.com/oauth/token', + clientId, + clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: true, + } + } case 'pipedrive': { const { clientId, clientSecret } = getCredentials( env.PIPEDRIVE_CLIENT_ID, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 22e1dfbcc7..d5114a38bc 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -34,6 +34,7 @@ export type OAuthProvider = | 'wealthbox' | 'webflow' | 'asana' + | 'attio' | 'pipedrive' | 'hubspot' | 'salesforce' @@ -76,6 +77,7 @@ export type OAuthService = | 'webflow' | 'trello' | 'asana' + | 'attio' | 'pipedrive' | 'hubspot' | 'salesforce' diff --git a/apps/sim/tools/attio/create_record.ts b/apps/sim/tools/attio/create_record.ts new file mode 100644 index 0000000000..7f2ed38c72 --- /dev/null +++ b/apps/sim/tools/attio/create_record.ts @@ -0,0 +1,101 @@ +import { createLogger } from '@sim/logger' +import type { AttioCreateRecordParams, AttioCreateRecordResponse } from '@/tools/attio/types' +import { RECORD_OBJECT_OUTPUT } from '@/tools/attio/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('AttioCreateRecord') + +export const attioCreateRecordTool: ToolConfig = + { + id: 'attio_create_record', + name: 'Create Record in Attio', + description: + 'Create a new record in an Attio object (people, companies, or custom objects). Values should be provided as attribute slug to value mappings.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Attio API', + }, + object: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The object type to create a record in (e.g., "people", "companies", or a custom object slug)', + }, + values: { + type: 'object', + required: true, + visibility: 'user-or-llm', + description: + 'Record values as JSON object with attribute slugs as keys (e.g., {"name": "John Doe", "email_addresses": "john@example.com"})', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/objects/${encodeURIComponent(params.object)}/records`, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + let values = params.values + if (typeof values === 'string') { + try { + values = JSON.parse(values) + } catch { + throw new Error('Invalid JSON format for values. Please provide a valid JSON object.') + } + } + + return { + data: { + values, + }, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || data.error || 'Failed to create record in Attio') + } + + const record = data.data + + return { + success: true, + output: { + record, + recordId: record?.id?.record_id || '', + success: true, + }, + } + }, + + outputs: { + record: RECORD_OBJECT_OUTPUT, + recordId: { type: 'string', description: 'The created record ID' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + } diff --git a/apps/sim/tools/attio/get_record.ts b/apps/sim/tools/attio/get_record.ts new file mode 100644 index 0000000000..6c06ce296b --- /dev/null +++ b/apps/sim/tools/attio/get_record.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import type { AttioGetRecordParams, AttioGetRecordResponse } from '@/tools/attio/types' +import { RECORD_OBJECT_OUTPUT } from '@/tools/attio/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('AttioGetRecord') + +export const attioGetRecordTool: ToolConfig = { + id: 'attio_get_record', + name: 'Get Record from Attio', + description: 'Get a specific record from an Attio object by its record ID.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Attio API', + }, + object: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object type (e.g., "people", "companies", or a custom object slug)', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique record ID to retrieve', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/objects/${encodeURIComponent(params.object)}/records/${encodeURIComponent(params.recordId)}`, + method: 'GET', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || data.error || 'Failed to get record from Attio') + } + + const record = data.data + + return { + success: true, + output: { + record, + recordId: record?.id?.record_id || '', + success: true, + }, + } + }, + + outputs: { + record: RECORD_OBJECT_OUTPUT, + recordId: { type: 'string', description: 'The record ID' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/attio/index.ts b/apps/sim/tools/attio/index.ts new file mode 100644 index 0000000000..96812aaee6 --- /dev/null +++ b/apps/sim/tools/attio/index.ts @@ -0,0 +1,5 @@ +export { attioCreateRecordTool } from './create_record' +export { attioGetRecordTool } from './get_record' +export { attioListRecordsTool } from './list_records' +export { attioSearchRecordsTool } from './search_records' +export { attioUpdateRecordTool } from './update_record' diff --git a/apps/sim/tools/attio/list_records.ts b/apps/sim/tools/attio/list_records.ts new file mode 100644 index 0000000000..e7d2c4879b --- /dev/null +++ b/apps/sim/tools/attio/list_records.ts @@ -0,0 +1,133 @@ +import { createLogger } from '@sim/logger' +import type { AttioListRecordsParams, AttioListRecordsResponse } from '@/tools/attio/types' +import { METADATA_OUTPUT, PAGING_OUTPUT, RECORDS_ARRAY_OUTPUT } from '@/tools/attio/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('AttioListRecords') + +export const attioListRecordsTool: ToolConfig = { + id: 'attio_list_records', + name: 'List Records in Attio', + description: + 'List records from an Attio object (people, companies, or custom objects). Supports pagination via limit and offset.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Attio API', + }, + object: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The object type to list records from (e.g., "people", "companies", or a custom object slug)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of records to return (default: 25, max: 500)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of records to skip for pagination', + }, + attributes: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Array of attribute slugs to include in the response. If not provided, all attributes are returned.', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/objects/${encodeURIComponent(params.object)}/records/query`, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const body: Record = {} + + if (params.limit !== undefined) { + body.limit = Number(params.limit) + } + + if (params.offset !== undefined) { + body.offset = Number(params.offset) + } + + if (params.attributes && params.attributes.length > 0) { + let parsedAttributes = params.attributes + if (typeof params.attributes === 'string') { + try { + parsedAttributes = JSON.parse(params.attributes) + } catch { + parsedAttributes = (params.attributes as string).split(',').map((a) => a.trim()) + } + } + if (Array.isArray(parsedAttributes) && parsedAttributes.length > 0) { + body.attributes = parsedAttributes + } + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || data.error || 'Failed to list records from Attio') + } + + const records = data.data || [] + const hasMore = records.length === (data.limit || 25) + + return { + success: true, + output: { + records, + paging: { + offset: data.offset || 0, + limit: data.limit || 25, + total: data.total, + }, + metadata: { + totalReturned: records.length, + hasMore, + }, + success: true, + }, + } + }, + + outputs: { + records: RECORDS_ARRAY_OUTPUT, + paging: PAGING_OUTPUT, + metadata: METADATA_OUTPUT, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/attio/search_records.ts b/apps/sim/tools/attio/search_records.ts new file mode 100644 index 0000000000..1bde08b0e3 --- /dev/null +++ b/apps/sim/tools/attio/search_records.ts @@ -0,0 +1,122 @@ +import { createLogger } from '@sim/logger' +import type { AttioSearchRecordsParams, AttioSearchRecordsResponse } from '@/tools/attio/types' +import { METADATA_OUTPUT, RECORDS_ARRAY_OUTPUT } from '@/tools/attio/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('AttioSearchRecords') + +export const attioSearchRecordsTool: ToolConfig< + AttioSearchRecordsParams, + AttioSearchRecordsResponse +> = { + id: 'attio_search_records', + name: 'Search Records in Attio', + description: + 'Perform a fuzzy search across Attio records. Searches names, domains, emails, phone numbers, and social handles for people and companies.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Attio API', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The search query string to match against records (names, domains, emails, phone numbers, social handles)', + }, + objects: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: + 'Array of object slugs to search within (e.g., ["people", "companies"]). If not provided, searches all objects.', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of records to return (default: 25)', + }, + }, + + request: { + url: () => 'https://api.attio.com/v2/objects/records/search', + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const body: Record = { + query: params.query, + } + + if (params.objects) { + let parsedObjects = params.objects + if (typeof params.objects === 'string') { + try { + parsedObjects = JSON.parse(params.objects) + } catch { + parsedObjects = (params.objects as string).split(',').map((o) => o.trim()) + } + } + if (Array.isArray(parsedObjects) && parsedObjects.length > 0) { + body.objects = parsedObjects + } + } + + if (params.limit !== undefined) { + body.limit = Number(params.limit) + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || data.error || 'Failed to search records in Attio') + } + + const records = data.data || [] + + return { + success: true, + output: { + records, + total: data.total, + metadata: { + totalReturned: records.length, + hasMore: records.length === (data.limit || 25), + }, + success: true, + }, + } + }, + + outputs: { + records: RECORDS_ARRAY_OUTPUT, + total: { type: 'number', description: 'Total number of matching records', optional: true }, + metadata: METADATA_OUTPUT, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/attio/types.ts b/apps/sim/tools/attio/types.ts new file mode 100644 index 0000000000..5933d6e45b --- /dev/null +++ b/apps/sim/tools/attio/types.ts @@ -0,0 +1,240 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +/** + * Shared output property definitions for Attio API responses. + * Based on Attio REST API v2 documentation. + * @see https://developers.attio.com/reference + */ + +/** + * Common record value properties returned by Attio API. + * Each attribute value has a type and value fields. + */ +export const RECORD_VALUE_OUTPUT: OutputProperty = { + type: 'object', + description: 'Record attribute value with type and data', + properties: { + active_from: { type: 'string', description: 'Timestamp when value became active' }, + active_until: { + type: 'string', + description: 'Timestamp when value became inactive', + optional: true, + }, + created_by_actor: { + type: 'object', + description: 'Actor who created this value', + properties: { + type: { type: 'string', description: 'Actor type (e.g., api-token, user, system)' }, + id: { type: 'string', description: 'Actor ID', optional: true }, + }, + }, + attribute_type: { + type: 'string', + description: 'Attribute type (e.g., text, number, email-address)', + }, + }, +} + +/** + * Record ID properties + */ +export const RECORD_ID_OUTPUT_PROPERTIES = { + workspace_id: { type: 'string', description: 'Workspace ID' }, + object_id: { + type: 'string', + description: 'Object ID (e.g., people, companies, or custom object)', + }, + record_id: { type: 'string', description: 'Unique record ID' }, +} as const satisfies Record + +/** + * Record object output definition + */ +export const RECORD_OBJECT_OUTPUT: OutputProperty = { + type: 'object', + description: 'Attio record object', + properties: { + id: { + type: 'object', + description: 'Record identifiers', + properties: RECORD_ID_OUTPUT_PROPERTIES, + }, + created_at: { type: 'string', description: 'Record creation timestamp (ISO 8601)' }, + values: { + type: 'object', + description: 'Record attribute values as key-value pairs', + }, + }, +} + +/** + * Records array output definition + */ +export const RECORDS_ARRAY_OUTPUT: OutputProperty = { + type: 'array', + description: 'Array of Attio record objects', + items: { + type: 'object', + properties: { + id: { + type: 'object', + description: 'Record identifiers', + properties: RECORD_ID_OUTPUT_PROPERTIES, + }, + created_at: { type: 'string', description: 'Record creation timestamp (ISO 8601)' }, + values: { + type: 'object', + description: 'Record attribute values', + }, + }, + }, +} + +/** + * Paging output properties for list endpoints. + */ +export const PAGING_OUTPUT_PROPERTIES = { + offset: { type: 'number', description: 'Current offset in the result set' }, + limit: { type: 'number', description: 'Maximum number of records returned' }, + total: { type: 'number', description: 'Total number of matching records', optional: true }, +} as const satisfies Record + +/** + * Complete paging object output definition. + */ +export const PAGING_OUTPUT: OutputProperty = { + type: 'object', + description: 'Pagination information for fetching more results', + optional: true, + properties: PAGING_OUTPUT_PROPERTIES, +} + +/** + * Metadata output properties for list endpoints. + */ +export const METADATA_OUTPUT_PROPERTIES = { + totalReturned: { type: 'number', description: 'Number of records returned in this response' }, + hasMore: { type: 'boolean', description: 'Whether more records are available' }, +} as const satisfies Record + +/** + * Complete metadata object output definition. + */ +export const METADATA_OUTPUT: OutputProperty = { + type: 'object', + description: 'Response metadata', + properties: METADATA_OUTPUT_PROPERTIES, +} + +// Attio record type +export interface AttioRecord { + id: { + workspace_id: string + object_id: string + record_id: string + } + created_at: string + values: Record +} + +export interface AttioPaging { + offset: number + limit: number + total?: number +} + +// List Records +export interface AttioListRecordsResponse extends ToolResponse { + output: { + records: AttioRecord[] + paging?: AttioPaging + metadata: { + totalReturned: number + hasMore: boolean + } + success: boolean + } +} + +export interface AttioListRecordsParams { + accessToken: string + object: string + limit?: number + offset?: number + attributes?: string[] +} + +// Get Record +export interface AttioGetRecordResponse extends ToolResponse { + output: { + record: AttioRecord + recordId: string + success: boolean + } +} + +export interface AttioGetRecordParams { + accessToken: string + object: string + recordId: string +} + +// Create Record +export interface AttioCreateRecordResponse extends ToolResponse { + output: { + record: AttioRecord + recordId: string + success: boolean + } +} + +export interface AttioCreateRecordParams { + accessToken: string + object: string + values: Record +} + +// Update Record +export interface AttioUpdateRecordResponse extends ToolResponse { + output: { + record: AttioRecord + recordId: string + success: boolean + } +} + +export interface AttioUpdateRecordParams { + accessToken: string + object: string + recordId: string + values: Record +} + +// Search Records +export interface AttioSearchRecordsResponse extends ToolResponse { + output: { + records: AttioRecord[] + total?: number + paging?: AttioPaging + metadata: { + totalReturned: number + hasMore: boolean + } + success: boolean + } +} + +export interface AttioSearchRecordsParams { + accessToken: string + query: string + objects?: string[] + limit?: number +} + +// Generic Attio response type for the block +export type AttioResponse = + | AttioListRecordsResponse + | AttioGetRecordResponse + | AttioCreateRecordResponse + | AttioUpdateRecordResponse + | AttioSearchRecordsResponse diff --git a/apps/sim/tools/attio/update_record.ts b/apps/sim/tools/attio/update_record.ts new file mode 100644 index 0000000000..6bd86d8f1d --- /dev/null +++ b/apps/sim/tools/attio/update_record.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@sim/logger' +import type { AttioUpdateRecordParams, AttioUpdateRecordResponse } from '@/tools/attio/types' +import { RECORD_OBJECT_OUTPUT } from '@/tools/attio/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('AttioUpdateRecord') + +export const attioUpdateRecordTool: ToolConfig = + { + id: 'attio_update_record', + name: 'Update Record in Attio', + description: + 'Update an existing record in an Attio object. Values should be provided as attribute slug to value mappings.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the Attio API', + }, + object: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object type (e.g., "people", "companies", or a custom object slug)', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The unique record ID to update', + }, + values: { + type: 'object', + required: true, + visibility: 'user-or-llm', + description: + 'Record values to update as JSON object with attribute slugs as keys (e.g., {"name": "Jane Doe", "phone_numbers": "+1234567890"})', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/objects/${encodeURIComponent(params.object)}/records/${encodeURIComponent(params.recordId)}`, + method: 'PATCH', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + let values = params.values + if (typeof values === 'string') { + try { + values = JSON.parse(values) + } catch { + throw new Error('Invalid JSON format for values. Please provide a valid JSON object.') + } + } + + return { + data: { + values, + }, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || data.error || 'Failed to update record in Attio') + } + + const record = data.data + + return { + success: true, + output: { + record, + recordId: record?.id?.record_id || '', + success: true, + }, + } + }, + + outputs: { + record: RECORD_OBJECT_OUTPUT, + recordId: { type: 'string', description: 'The updated record ID' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, + } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 54909993ab..da00889f5f 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -25,6 +25,13 @@ import { airtableUpdateRecordTool, } from '@/tools/airtable' import { airweaveSearchTool } from '@/tools/airweave' +import { + attioCreateRecordTool, + attioGetRecordTool, + attioListRecordsTool, + attioSearchRecordsTool, + attioUpdateRecordTool, +} from '@/tools/attio' import { algoliaAddRecordTool, algoliaBatchOperationsTool, @@ -2038,6 +2045,11 @@ export const tools: Record = { a2a_send_message: a2aSendMessageTool, a2a_set_push_notification: a2aSetPushNotificationTool, airweave_search: airweaveSearchTool, + attio_create_record: attioCreateRecordTool, + attio_get_record: attioGetRecordTool, + attio_list_records: attioListRecordsTool, + attio_search_records: attioSearchRecordsTool, + attio_update_record: attioUpdateRecordTool, arxiv_search: arxivSearchTool, arxiv_get_paper: arxivGetPaperTool, arxiv_get_author_papers: arxivGetAuthorPapersTool,