diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 2f91eebc39..aab80fe0cb 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -4835,6 +4835,17 @@ export function WordpressIcon(props: SVGProps) { ) } +export function AgiloftIcon(props: SVGProps) { + return ( + + + + + + + ) +} + export function AhrefsIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index df25ac18b3..4ed718ff7f 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -6,6 +6,7 @@ import type { ComponentType, SVGProps } from 'react' import { A2AIcon, AgentMailIcon, + AgiloftIcon, AhrefsIcon, AirtableIcon, AirweaveIcon, @@ -197,6 +198,7 @@ type IconComponent = ComponentType> export const blockTypeToIconMap: Record = { a2a: A2AIcon, agentmail: AgentMailIcon, + agiloft: AgiloftIcon, ahrefs: AhrefsIcon, airtable: AirtableIcon, airweave: AirweaveIcon, diff --git a/apps/docs/content/docs/en/tools/agiloft.mdx b/apps/docs/content/docs/en/tools/agiloft.mdx new file mode 100644 index 0000000000..b5579d98ff --- /dev/null +++ b/apps/docs/content/docs/en/tools/agiloft.mdx @@ -0,0 +1,332 @@ +--- +title: Agiloft +description: Manage records in Agiloft CLM +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Agiloft](https://www.agiloft.com/) is an enterprise contract lifecycle management (CLM) platform that helps organizations automate and manage contracts, agreements, and related business processes across any knowledge base. + +With the Agiloft integration in Sim, you can: + +- **Create records**: Add new records to any Agiloft table with custom field values +- **Read records**: Retrieve individual records by ID with optional field selection +- **Update records**: Modify existing record fields in any table +- **Delete records**: Remove records from your knowledge base +- **Search records**: Find records using Agiloft's query syntax with pagination support +- **Select records**: Query records using SQL WHERE clauses for advanced filtering +- **Saved searches**: List saved search definitions available for a table +- **Attach files**: Upload and attach files to record fields +- **Retrieve attachments**: Download attached files from record fields +- **Remove attachments**: Delete attached files from record fields by position +- **Attachment info**: Get metadata about all files attached to a record field +- **Lock records**: Check, acquire, or release locks on records for concurrent editing + +In Sim, the Agiloft integration enables your agents to manage contracts and records programmatically as part of automated workflows. Agents can create and update records, search across tables, handle file attachments, and manage record locks — enabling intelligent contract lifecycle automation. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base. + + + +## Tools + +### `agiloft_attach_file` + +Attach a file to a field in an Agiloft record. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts"\) | +| `recordId` | string | Yes | ID of the record to attach the file to | +| `fieldName` | string | Yes | Name of the attachment field | +| `file` | file | No | File to attach | +| `fileName` | string | No | Name to assign to the file \(defaults to original file name\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the record the file was attached to | +| `fieldName` | string | Name of the field the file was attached to | +| `fileName` | string | Name of the attached file | +| `totalAttachments` | number | Total number of files attached in the field after the operation | + +### `agiloft_attachment_info` + +Get information about file attachments on a record field. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts"\) | +| `recordId` | string | Yes | ID of the record to check attachments on | +| `fieldName` | string | Yes | Name of the attachment field to inspect | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `attachments` | array | List of attachments with position, name, and size | +| ↳ `position` | number | Position index of the attachment in the field | +| ↳ `name` | string | File name of the attachment | +| ↳ `size` | number | File size in bytes | +| `totalCount` | number | Total number of attachments in the field | + +### `agiloft_create_record` + +Create a new record in an Agiloft table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) | +| `data` | string | Yes | Record field values as a JSON object \(e.g., \{"first_name": "John", "status": "Active"\}\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the created record | +| `fields` | json | Field values of the created record | + +### `agiloft_delete_record` + +Delete a record from an Agiloft table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) | +| `recordId` | string | Yes | ID of the record to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the deleted record | +| `deleted` | boolean | Whether the record was successfully deleted | + +### `agiloft_lock_record` + +Lock, unlock, or check the lock status of an Agiloft record. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts"\) | +| `recordId` | string | Yes | ID of the record to lock, unlock, or check | +| `lockAction` | string | Yes | Action to perform: "lock", "unlock", or "check" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Record ID | +| `lockStatus` | string | Lock status \(e.g., "LOCKED", "UNLOCKED"\) | +| `lockedBy` | string | Username of the user who locked the record | +| `lockExpiresInMinutes` | number | Minutes until the lock expires | + +### `agiloft_read_record` + +Read a record by ID from an Agiloft table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) | +| `recordId` | string | Yes | ID of the record to read | +| `fields` | string | No | Comma-separated list of field names to include in the response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the record | +| `fields` | json | Field values of the record | + +### `agiloft_remove_attachment` + +Remove an attached file from a field in an Agiloft record. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts"\) | +| `recordId` | string | Yes | ID of the record containing the attachment | +| `fieldName` | string | Yes | Name of the attachment field | +| `position` | string | Yes | Position index of the file to remove \(starting from 0\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the record | +| `fieldName` | string | Name of the attachment field | +| `remainingAttachments` | number | Number of attachments remaining in the field after removal | + +### `agiloft_retrieve_attachment` + +Download an attached file from an Agiloft record field. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts"\) | +| `recordId` | string | Yes | ID of the record containing the attachment | +| `fieldName` | string | Yes | Name of the attachment field | +| `position` | string | Yes | Position index of the file in the field \(starting from 0\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded attachment file | + +### `agiloft_saved_search` + +List saved searches defined for an Agiloft table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name to list saved searches for \(e.g., "contracts"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `searches` | array | List of saved searches for the table | +| ↳ `name` | string | Saved search name | +| ↳ `label` | string | Saved search display label | +| ↳ `id` | string | Saved search database identifier | +| ↳ `description` | string | Saved search description | + +### `agiloft_search_records` + +Search for records in an Agiloft table using a query. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name to search in \(e.g., "contracts", "contacts.employees"\) | +| `query` | string | Yes | Search query using Agiloft query syntax \(e.g., "status=\'Active\'" or "company_name~=\'Acme\'"\) | +| `fields` | string | No | Comma-separated list of field names to include in the results | +| `page` | string | No | Page number for paginated results \(starting from 0\) | +| `limit` | string | No | Maximum number of records to return per page | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `records` | json | Array of matching records with their field values | +| `totalCount` | number | Total number of matching records | +| `page` | number | Current page number | +| `limit` | number | Records per page | + +### `agiloft_select_records` + +Select record IDs matching a SQL WHERE clause from an Agiloft table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) | +| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordIds` | array | Array of record IDs matching the query | +| `totalCount` | number | Total number of matching records | + +### `agiloft_update_record` + +Update an existing record in an Agiloft table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) | +| `knowledgeBase` | string | Yes | Knowledge base name | +| `login` | string | Yes | Agiloft username | +| `password` | string | Yes | Agiloft password | +| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) | +| `recordId` | string | Yes | ID of the record to update | +| `data` | string | Yes | Updated field values as a JSON object \(e.g., \{"status": "Active", "priority": "High"\}\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | ID of the updated record | +| `fields` | json | Updated field values of the record | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index dfc8894b49..edded07463 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -3,6 +3,7 @@ "index", "a2a", "agentmail", + "agiloft", "ahrefs", "airtable", "airweave", diff --git a/apps/docs/content/docs/en/tools/trello.mdx b/apps/docs/content/docs/en/tools/trello.mdx index c7a6ad5709..01e3ac67ad 100644 --- a/apps/docs/content/docs/en/tools/trello.mdx +++ b/apps/docs/content/docs/en/tools/trello.mdx @@ -36,6 +36,7 @@ Before connecting Trello in Sim, add your Sim app origin to the **Allowed Origin Trello's authorization flow redirects back to Sim using a `return_url`. If your Sim origin is not whitelisted in Trello, Trello will block the redirect and the connection flow will fail before Sim can save the token. {/* MANUAL-CONTENT-END */} + Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments. diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index a29451dcdd..1b09e2c7be 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -6,6 +6,7 @@ import type { ComponentType, SVGProps } from 'react' import { A2AIcon, AgentMailIcon, + AgiloftIcon, AhrefsIcon, AirtableIcon, AirweaveIcon, @@ -197,6 +198,7 @@ type IconComponent = ComponentType> export const blockTypeToIconMap: Record = { a2a: A2AIcon, agentmail: AgentMailIcon, + agiloft: AgiloftIcon, ahrefs: AhrefsIcon, airtable: AirtableIcon, airweave: AirweaveIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 241e229ae5..6f31dc4257 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -208,6 +208,73 @@ "integrationTypes": ["email", "communication"], "tags": ["messaging"] }, + { + "type": "agiloft", + "slug": "agiloft", + "name": "Agiloft", + "description": "Manage records in Agiloft CLM", + "longDescription": "Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base.", + "bgColor": "#263A5C", + "iconName": "AgiloftIcon", + "docsUrl": "https://docs.sim.ai/tools/agiloft", + "operations": [ + { + "name": "Create Record", + "description": "Create a new record in an Agiloft table." + }, + { + "name": "Read Record", + "description": "Read a record by ID from an Agiloft table." + }, + { + "name": "Update Record", + "description": "Update an existing record in an Agiloft table." + }, + { + "name": "Delete Record", + "description": "Delete a record from an Agiloft table." + }, + { + "name": "Search Records", + "description": "Search for records in an Agiloft table using a query." + }, + { + "name": "Select Records", + "description": "Select record IDs matching a SQL WHERE clause from an Agiloft table." + }, + { + "name": "Saved Search", + "description": "List saved searches defined for an Agiloft table." + }, + { + "name": "Attach File", + "description": "Attach a file to a field in an Agiloft record." + }, + { + "name": "Retrieve Attachment", + "description": "Download an attached file from an Agiloft record field." + }, + { + "name": "Remove Attachment", + "description": "Remove an attached file from a field in an Agiloft record." + }, + { + "name": "Attachment Info", + "description": "Get information about file attachments on a record field." + }, + { + "name": "Lock Record", + "description": "Lock, unlock, or check the lock status of an Agiloft record." + } + ], + "operationCount": 12, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationTypes": ["productivity", "developer-tools"], + "tags": ["automation"] + }, { "type": "ahrefs", "slug": "ahrefs", @@ -2374,7 +2441,7 @@ "authType": "none", "category": "tools", "integrationTypes": ["security", "analytics", "developer-tools"], - "tags": ["monitoring", "security"] + "tags": ["identity", "monitoring"] }, { "type": "cursor_v2", diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts new file mode 100644 index 0000000000..db55283d82 --- /dev/null +++ b/apps/sim/app/api/tools/agiloft/attach/route.ts @@ -0,0 +1,144 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { agiloftLogin, agiloftLogout, buildAttachFileUrl } from '@/tools/agiloft/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('AgiloftAttachAPI') + +const AgiloftAttachSchema = z.object({ + instanceUrl: z.string().min(1, 'Instance URL is required'), + knowledgeBase: z.string().min(1, 'Knowledge base is required'), + login: z.string().min(1, 'Login is required'), + password: z.string().min(1, 'Password is required'), + table: z.string().min(1, 'Table is required'), + recordId: z.string().min(1, 'Record ID is required'), + fieldName: z.string().min(1, 'Field name is required'), + file: FileInputSchema.optional().nullable(), + fileName: z.string().optional().nullable(), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Agiloft attach attempt: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const body = await request.json() + const data = AgiloftAttachSchema.parse(body) + + if (!data.file) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger) + + if (userFiles.length === 0) { + return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 }) + } + + const userFile = userFiles[0] + logger.info( + `[${requestId}] Downloading file for Agiloft attach: ${userFile.name} (${userFile.size} bytes)` + ) + + const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) + const resolvedFileName = data.fileName || userFile.name || 'attachment' + + const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, { + instanceUrl: data.instanceUrl, + }) + return NextResponse.json( + { success: false, error: urlValidation.error || 'Invalid instance URL' }, + { status: 400 } + ) + } + + const token = await agiloftLogin(data) + const base = data.instanceUrl.replace(/\/$/, '') + + try { + const url = buildAttachFileUrl(base, data, resolvedFileName) + + logger.info(`[${requestId}] Uploading file to Agiloft: ${resolvedFileName}`) + + const agiloftResponse = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': userFile.type || 'application/octet-stream', + Authorization: `Bearer ${token}`, + }, + body: new Uint8Array(fileBuffer), + }) + + if (!agiloftResponse.ok) { + const errorText = await agiloftResponse.text() + logger.error( + `[${requestId}] Agiloft attach error: ${agiloftResponse.status} - ${errorText}` + ) + return NextResponse.json( + { success: false, error: `Agiloft error: ${agiloftResponse.status} - ${errorText}` }, + { status: agiloftResponse.status } + ) + } + + let totalAttachments = 0 + const responseText = await agiloftResponse.text() + try { + const responseData = JSON.parse(responseText) + const result = responseData.result ?? responseData + totalAttachments = typeof result === 'number' ? result : (result.count ?? result.total ?? 1) + } catch { + totalAttachments = Number(responseText) || 1 + } + + logger.info( + `[${requestId}] File attached successfully. Total attachments: ${totalAttachments}` + ) + + return NextResponse.json({ + success: true, + output: { + recordId: data.recordId.trim(), + fieldName: data.fieldName.trim(), + fileName: resolvedFileName, + totalAttachments, + }, + }) + } finally { + await agiloftLogout(data.instanceUrl, data.knowledgeBase, token) + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { success: false, error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error attaching file to Agiloft:`, error) + + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/blocks/blocks/agiloft.ts b/apps/sim/blocks/blocks/agiloft.ts new file mode 100644 index 0000000000..35af080fb9 --- /dev/null +++ b/apps/sim/blocks/blocks/agiloft.ts @@ -0,0 +1,407 @@ +import { AgiloftIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' + +export const AgiloftBlock: BlockConfig = { + type: 'agiloft', + name: 'Agiloft', + description: 'Manage records in Agiloft CLM', + longDescription: + 'Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base.', + docsLink: 'https://docs.sim.ai/tools/agiloft', + category: 'tools', + integrationType: IntegrationType.Productivity, + tags: ['automation'], + bgColor: '#263A5C', + icon: AgiloftIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Create Record', id: 'create_record' }, + { label: 'Read Record', id: 'read_record' }, + { label: 'Update Record', id: 'update_record' }, + { label: 'Delete Record', id: 'delete_record' }, + { label: 'Search Records', id: 'search_records' }, + { label: 'Select Records', id: 'select_records' }, + { label: 'Saved Search', id: 'saved_search' }, + { label: 'Attach File', id: 'attach_file' }, + { label: 'Retrieve Attachment', id: 'retrieve_attachment' }, + { label: 'Remove Attachment', id: 'remove_attachment' }, + { label: 'Attachment Info', id: 'attachment_info' }, + { label: 'Lock Record', id: 'lock_record' }, + ], + value: () => 'search_records', + }, + { + id: 'instanceUrl', + title: 'Instance URL', + type: 'short-input', + placeholder: 'https://mycompany.agiloft.com', + required: true, + password: false, + }, + { + id: 'knowledgeBase', + title: 'Knowledge Base', + type: 'short-input', + placeholder: 'e.g., Demo', + required: true, + }, + { + id: 'login', + title: 'Login', + type: 'short-input', + placeholder: 'Username', + required: true, + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + placeholder: 'Password', + password: true, + required: true, + }, + { + id: 'table', + title: 'Table', + type: 'short-input', + placeholder: 'e.g., contracts, contacts.employees', + required: true, + }, + { + id: 'recordId', + title: 'Record ID', + type: 'short-input', + placeholder: 'Record ID', + condition: { + field: 'operation', + value: [ + 'read_record', + 'update_record', + 'delete_record', + 'attach_file', + 'retrieve_attachment', + 'remove_attachment', + 'attachment_info', + 'lock_record', + ], + }, + required: { + field: 'operation', + value: [ + 'read_record', + 'update_record', + 'delete_record', + 'attach_file', + 'retrieve_attachment', + 'remove_attachment', + 'attachment_info', + 'lock_record', + ], + }, + }, + { + id: 'data', + title: 'Record Data', + type: 'long-input', + placeholder: '{"field_name": "value", "another_field": "value"}', + condition: { field: 'operation', value: ['create_record', 'update_record'] }, + required: { field: 'operation', value: ['create_record', 'update_record'] }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON object with the field names and values for an Agiloft record. Return ONLY the JSON object - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: "status='Active' AND company_name~='Acme'", + condition: { field: 'operation', value: 'search_records' }, + required: { field: 'operation', value: 'search_records' }, + wandConfig: { + enabled: true, + prompt: + "Generate an Agiloft search query. Use field_name='value' for exact match, field_name~='value' for contains, and AND/OR for combining conditions. Return ONLY the query string - no explanations, no extra text.", + }, + }, + { + id: 'where', + title: 'WHERE Clause', + type: 'short-input', + placeholder: "summary like '%new%' AND status='Active'", + condition: { field: 'operation', value: 'select_records' }, + required: { field: 'operation', value: 'select_records' }, + wandConfig: { + enabled: true, + prompt: + "Generate a SQL WHERE clause for an Agiloft EWSelect query using database column names. Use standard SQL syntax (e.g., column='value', column like '%text%'). Return ONLY the WHERE clause - no explanations, no extra text.", + }, + }, + { + id: 'fieldName', + title: 'Field Name', + type: 'short-input', + placeholder: 'e.g., attached_docs', + condition: { + field: 'operation', + value: ['attach_file', 'retrieve_attachment', 'remove_attachment', 'attachment_info'], + }, + required: { + field: 'operation', + value: ['attach_file', 'retrieve_attachment', 'remove_attachment', 'attachment_info'], + }, + }, + { + id: 'uploadFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'attachFile', + placeholder: 'Upload file to attach', + condition: { field: 'operation', value: 'attach_file' }, + mode: 'basic', + multiple: false, + required: { field: 'operation', value: 'attach_file' }, + }, + { + id: 'fileRef', + title: 'File', + type: 'short-input', + canonicalParamId: 'attachFile', + placeholder: 'Reference file from previous block', + condition: { field: 'operation', value: 'attach_file' }, + mode: 'advanced', + required: { field: 'operation', value: 'attach_file' }, + }, + { + id: 'fileName', + title: 'File Name', + type: 'short-input', + placeholder: 'Optional name for the attached file', + condition: { field: 'operation', value: 'attach_file' }, + mode: 'advanced', + }, + { + id: 'position', + title: 'File Position', + type: 'short-input', + placeholder: '0', + condition: { + field: 'operation', + value: ['retrieve_attachment', 'remove_attachment'], + }, + required: { + field: 'operation', + value: ['retrieve_attachment', 'remove_attachment'], + }, + }, + { + id: 'lockAction', + title: 'Lock Action', + type: 'dropdown', + options: [ + { label: 'Check Status', id: 'check' }, + { label: 'Lock', id: 'lock' }, + { label: 'Unlock', id: 'unlock' }, + ], + value: () => 'check', + condition: { field: 'operation', value: 'lock_record' }, + required: { field: 'operation', value: 'lock_record' }, + }, + { + id: 'fields', + title: 'Fields', + type: 'short-input', + placeholder: 'Comma-separated field names to return', + mode: 'advanced', + condition: { field: 'operation', value: ['read_record', 'search_records'] }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Agiloft table field names to include in the response. Return ONLY the comma-separated list - no explanations, no extra text.', + }, + }, + { + id: 'page', + title: 'Page', + type: 'short-input', + placeholder: '0', + mode: 'advanced', + condition: { field: 'operation', value: 'search_records' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '25', + mode: 'advanced', + condition: { field: 'operation', value: 'search_records' }, + }, + ], + + tools: { + access: [ + 'agiloft_attach_file', + 'agiloft_attachment_info', + 'agiloft_create_record', + 'agiloft_delete_record', + 'agiloft_lock_record', + 'agiloft_read_record', + 'agiloft_remove_attachment', + 'agiloft_retrieve_attachment', + 'agiloft_saved_search', + 'agiloft_search_records', + 'agiloft_select_records', + 'agiloft_update_record', + ], + config: { + tool: (params) => `agiloft_${params.operation}`, + params: (params) => { + const normalizedFile = normalizeFileInput(params.attachFile, { + single: true, + }) + if (normalizedFile) { + params.file = normalizedFile + } + return params + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + instanceUrl: { type: 'string', description: 'Agiloft instance URL' }, + knowledgeBase: { type: 'string', description: 'Knowledge base name' }, + login: { type: 'string', description: 'Agiloft username' }, + password: { type: 'string', description: 'Agiloft password' }, + table: { type: 'string', description: 'Table name' }, + recordId: { type: 'string', description: 'Record ID' }, + data: { type: 'string', description: 'Record data as JSON' }, + query: { type: 'string', description: 'Search query' }, + where: { type: 'string', description: 'SQL WHERE clause for select' }, + fieldName: { type: 'string', description: 'Attachment field name' }, + attachFile: { type: 'file', description: 'File to attach' }, + fileName: { type: 'string', description: 'Name for the attached file' }, + position: { type: 'string', description: 'Attachment position index' }, + lockAction: { type: 'string', description: 'Lock action (lock, unlock, check)' }, + fields: { type: 'string', description: 'Fields to return' }, + page: { type: 'string', description: 'Page number' }, + limit: { type: 'string', description: 'Results per page' }, + }, + + outputs: { + id: { + type: 'string', + description: 'Record ID', + condition: { + field: 'operation', + value: ['create_record', 'read_record', 'update_record', 'delete_record', 'lock_record'], + }, + }, + fields: { + type: 'json', + description: 'Record field values', + condition: { + field: 'operation', + value: ['create_record', 'read_record', 'update_record'], + }, + }, + deleted: { + type: 'boolean', + description: 'Whether the record was deleted', + condition: { field: 'operation', value: 'delete_record' }, + }, + records: { + type: 'json', + description: 'Array of matching records', + condition: { field: 'operation', value: 'search_records' }, + }, + totalCount: { + type: 'number', + description: 'Total number of matching results', + condition: { + field: 'operation', + value: ['search_records', 'select_records', 'attachment_info'], + }, + }, + page: { + type: 'number', + description: 'Current page number', + condition: { field: 'operation', value: 'search_records' }, + }, + limit: { + type: 'number', + description: 'Results per page', + condition: { field: 'operation', value: 'search_records' }, + }, + recordIds: { + type: 'json', + description: 'Array of record IDs matching the WHERE clause', + condition: { field: 'operation', value: 'select_records' }, + }, + searches: { + type: 'json', + description: 'Array of saved search definitions (name, label, id, description)', + condition: { field: 'operation', value: 'saved_search' }, + }, + file: { + type: 'file', + description: 'Downloaded attachment file', + condition: { field: 'operation', value: 'retrieve_attachment' }, + }, + attachments: { + type: 'json', + description: 'Array of attachment info (position, name, size)', + condition: { field: 'operation', value: 'attachment_info' }, + }, + recordId: { + type: 'string', + description: 'ID of the record the file operation was performed on', + condition: { field: 'operation', value: ['attach_file', 'remove_attachment'] }, + }, + fieldName: { + type: 'string', + description: 'Name of the attachment field', + condition: { field: 'operation', value: ['attach_file', 'remove_attachment'] }, + }, + fileName: { + type: 'string', + description: 'Name of the attached file', + condition: { field: 'operation', value: 'attach_file' }, + }, + totalAttachments: { + type: 'number', + description: 'Total number of files attached in the field', + condition: { field: 'operation', value: 'attach_file' }, + }, + remainingAttachments: { + type: 'number', + description: 'Number of attachments remaining after removal', + condition: { field: 'operation', value: 'remove_attachment' }, + }, + lockStatus: { + type: 'string', + description: 'Lock status (e.g., LOCKED, UNLOCKED)', + condition: { field: 'operation', value: 'lock_record' }, + }, + lockedBy: { + type: 'string', + description: 'Username of the user who locked the record', + condition: { field: 'operation', value: 'lock_record' }, + }, + lockExpiresInMinutes: { + type: 'number', + description: 'Minutes until the lock expires', + condition: { field: 'operation', value: 'lock_record' }, + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 3816508285..db351f7b24 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -1,6 +1,7 @@ import { A2ABlock } from '@/blocks/blocks/a2a' import { AgentBlock } from '@/blocks/blocks/agent' import { AgentMailBlock } from '@/blocks/blocks/agentmail' +import { AgiloftBlock } from '@/blocks/blocks/agiloft' import { AhrefsBlock } from '@/blocks/blocks/ahrefs' import { AirtableBlock } from '@/blocks/blocks/airtable' import { AirweaveBlock } from '@/blocks/blocks/airweave' @@ -226,6 +227,7 @@ export const registry: Record = { a2a: A2ABlock, agent: AgentBlock, agentmail: AgentMailBlock, + agiloft: AgiloftBlock, ahrefs: AhrefsBlock, airtable: AirtableBlock, airweave: AirweaveBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 2f91eebc39..aab80fe0cb 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4835,6 +4835,17 @@ export function WordpressIcon(props: SVGProps) { ) } +export function AgiloftIcon(props: SVGProps) { + return ( + + + + + + + ) +} + export function AhrefsIcon(props: SVGProps) { return ( diff --git a/apps/sim/tools/agiloft/attach_file.ts b/apps/sim/tools/agiloft/attach_file.ts new file mode 100644 index 0000000000..03aa6ca74c --- /dev/null +++ b/apps/sim/tools/agiloft/attach_file.ts @@ -0,0 +1,132 @@ +import type { AgiloftAttachFileParams, AgiloftAttachFileResponse } from '@/tools/agiloft/types' +import type { ToolConfig } from '@/tools/types' + +export const agiloftAttachFileTool: ToolConfig = + { + id: 'agiloft_attach_file', + name: 'Agiloft Attach File', + description: 'Attach a file to a field in an Agiloft record.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts")', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the record to attach the file to', + }, + fieldName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the attachment field', + }, + file: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'File to attach', + }, + fileName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name to assign to the file (defaults to original file name)', + }, + }, + + request: { + url: '/api/tools/agiloft/attach', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + instanceUrl: params.instanceUrl, + knowledgeBase: params.knowledgeBase, + login: params.login, + password: params.password, + table: params.table, + recordId: params.recordId, + fieldName: params.fieldName, + file: params.file, + fileName: params.fileName, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + recordId: '', + fieldName: '', + fileName: '', + totalAttachments: 0, + }, + error: data.error || 'Failed to attach file', + } + } + + return { + success: true, + output: { + recordId: data.output?.recordId ?? '', + fieldName: data.output?.fieldName ?? '', + fileName: data.output?.fileName ?? '', + totalAttachments: data.output?.totalAttachments ?? 0, + }, + } + }, + + outputs: { + recordId: { + type: 'string', + description: 'ID of the record the file was attached to', + }, + fieldName: { + type: 'string', + description: 'Name of the field the file was attached to', + }, + fileName: { + type: 'string', + description: 'Name of the attached file', + }, + totalAttachments: { + type: 'number', + description: 'Total number of files attached in the field after the operation', + }, + }, + } diff --git a/apps/sim/tools/agiloft/attachment_info.ts b/apps/sim/tools/agiloft/attachment_info.ts new file mode 100644 index 0000000000..26b9969b4f --- /dev/null +++ b/apps/sim/tools/agiloft/attachment_info.ts @@ -0,0 +1,133 @@ +import type { + AgiloftAttachmentInfoParams, + AgiloftAttachmentInfoResponse, +} from '@/tools/agiloft/types' +import { buildAttachmentInfoUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftAttachmentInfoTool: ToolConfig< + AgiloftAttachmentInfoParams, + AgiloftAttachmentInfoResponse +> = { + id: 'agiloft_attachment_info', + name: 'Agiloft Attachment Info', + description: 'Get information about file attachments on a record field.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts")', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the record to check attachments on', + }, + fieldName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the attachment field to inspect', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'GET', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildAttachmentInfoUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { attachments: [], totalCount: 0 }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + const result = data.result ?? data + + const attachments: Array<{ position: number; name: string; size: number }> = [] + + if (Array.isArray(result)) { + for (let i = 0; i < result.length; i++) { + const item = result[i] + attachments.push({ + position: item.position ?? i, + name: item.name ?? item.filename ?? '', + size: item.size ?? 0, + }) + } + } + + return { + success: data.success !== false, + output: { + attachments, + totalCount: attachments.length, + }, + } + } + ) + }, + + outputs: { + attachments: { + type: 'array', + description: 'List of attachments with position, name, and size', + items: { + type: 'object', + properties: { + position: { + type: 'number', + description: 'Position index of the attachment in the field', + }, + name: { type: 'string', description: 'File name of the attachment' }, + size: { type: 'number', description: 'File size in bytes' }, + }, + }, + }, + totalCount: { + type: 'number', + description: 'Total number of attachments in the field', + }, + }, +} diff --git a/apps/sim/tools/agiloft/create_record.ts b/apps/sim/tools/agiloft/create_record.ts new file mode 100644 index 0000000000..0130473370 --- /dev/null +++ b/apps/sim/tools/agiloft/create_record.ts @@ -0,0 +1,113 @@ +import type { AgiloftCreateRecordParams, AgiloftRecordResponse } from '@/tools/agiloft/types' +import { buildCreateRecordUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftCreateRecordTool: ToolConfig = + { + id: 'agiloft_create_record', + name: 'Agiloft Create Record', + description: 'Create a new record in an Agiloft table.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts", "contacts.employees")', + }, + data: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Record field values as a JSON object (e.g., {"first_name": "John", "status": "Active"})', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'POST', + headers: () => ({}), + }, + + directExecution: async (params) => { + let body: string + try { + body = JSON.stringify(JSON.parse(params.data)) + } catch { + return { + success: false, + output: { id: null, fields: {} }, + error: 'Invalid JSON in data parameter', + } + } + + return executeAgiloftRequest( + params, + (base) => ({ + url: buildCreateRecordUrl(base, params), + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { id: null, fields: {} }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + const result = data.result ?? data + const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null + + return { + success: data.success !== false, + output: { + id: id != null ? String(id) : null, + fields: result ?? {}, + }, + } + } + ) + }, + + outputs: { + id: { + type: 'string', + description: 'ID of the created record', + }, + fields: { + type: 'json', + description: 'Field values of the created record', + }, + }, + } diff --git a/apps/sim/tools/agiloft/delete_record.ts b/apps/sim/tools/agiloft/delete_record.ts new file mode 100644 index 0000000000..3796459dd6 --- /dev/null +++ b/apps/sim/tools/agiloft/delete_record.ts @@ -0,0 +1,95 @@ +import type { AgiloftDeleteRecordParams, AgiloftDeleteResponse } from '@/tools/agiloft/types' +import { buildDeleteRecordUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftDeleteRecordTool: ToolConfig = + { + id: 'agiloft_delete_record', + name: 'Agiloft Delete Record', + description: 'Delete a record from an Agiloft table.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts", "contacts.employees")', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the record to delete', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'DELETE', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildDeleteRecordUrl(base, params), + method: 'DELETE', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { id: params.recordId?.trim() ?? '', deleted: false }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + return { + success: true, + output: { + id: params.recordId?.trim() ?? '', + deleted: true, + }, + } + } + ) + }, + + outputs: { + id: { + type: 'string', + description: 'ID of the deleted record', + }, + deleted: { + type: 'boolean', + description: 'Whether the record was successfully deleted', + }, + }, + } diff --git a/apps/sim/tools/agiloft/index.ts b/apps/sim/tools/agiloft/index.ts new file mode 100644 index 0000000000..2ff7ac6f4f --- /dev/null +++ b/apps/sim/tools/agiloft/index.ts @@ -0,0 +1,13 @@ +export { agiloftAttachFileTool } from '@/tools/agiloft/attach_file' +export { agiloftAttachmentInfoTool } from '@/tools/agiloft/attachment_info' +export { agiloftCreateRecordTool } from '@/tools/agiloft/create_record' +export { agiloftDeleteRecordTool } from '@/tools/agiloft/delete_record' +export { agiloftLockRecordTool } from '@/tools/agiloft/lock_record' +export { agiloftReadRecordTool } from '@/tools/agiloft/read_record' +export { agiloftRemoveAttachmentTool } from '@/tools/agiloft/remove_attachment' +export { agiloftRetrieveAttachmentTool } from '@/tools/agiloft/retrieve_attachment' +export { agiloftSavedSearchTool } from '@/tools/agiloft/saved_search' +export { agiloftSearchRecordsTool } from '@/tools/agiloft/search_records' +export { agiloftSelectRecordsTool } from '@/tools/agiloft/select_records' +export * from '@/tools/agiloft/types' +export { agiloftUpdateRecordTool } from '@/tools/agiloft/update_record' diff --git a/apps/sim/tools/agiloft/lock_record.ts b/apps/sim/tools/agiloft/lock_record.ts new file mode 100644 index 0000000000..324fa3916c --- /dev/null +++ b/apps/sim/tools/agiloft/lock_record.ts @@ -0,0 +1,121 @@ +import type { AgiloftLockRecordParams, AgiloftLockResponse } from '@/tools/agiloft/types' +import { buildLockRecordUrl, executeAgiloftRequest, getLockHttpMethod } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftLockRecordTool: ToolConfig = { + id: 'agiloft_lock_record', + name: 'Agiloft Lock Record', + description: 'Lock, unlock, or check the lock status of an Agiloft record.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts")', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the record to lock, unlock, or check', + }, + lockAction: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Action to perform: "lock", "unlock", or "check"', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'GET', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildLockRecordUrl(base, params), + method: getLockHttpMethod(params.lockAction), + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { + id: params.recordId?.trim() ?? '', + lockStatus: 'UNKNOWN', + lockedBy: null, + lockExpiresInMinutes: null, + }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + const result = data.result ?? data + + return { + success: data.success !== false, + output: { + id: String(result.id ?? params.recordId?.trim() ?? ''), + lockStatus: result.lock_status ?? result.lockStatus ?? 'UNKNOWN', + lockedBy: result.locked_by ?? result.lockedBy ?? null, + lockExpiresInMinutes: + result.lock_expires_in_minutes ?? result.lockExpiresInMinutes ?? null, + }, + } + } + ) + }, + + outputs: { + id: { + type: 'string', + description: 'Record ID', + }, + lockStatus: { + type: 'string', + description: 'Lock status (e.g., "LOCKED", "UNLOCKED")', + }, + lockedBy: { + type: 'string', + description: 'Username of the user who locked the record', + optional: true, + }, + lockExpiresInMinutes: { + type: 'number', + description: 'Minutes until the lock expires', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/agiloft/read_record.ts b/apps/sim/tools/agiloft/read_record.ts new file mode 100644 index 0000000000..a01a474c0c --- /dev/null +++ b/apps/sim/tools/agiloft/read_record.ts @@ -0,0 +1,104 @@ +import type { AgiloftReadRecordParams, AgiloftRecordResponse } from '@/tools/agiloft/types' +import { buildReadRecordUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftReadRecordTool: ToolConfig = { + id: 'agiloft_read_record', + name: 'Agiloft Read Record', + description: 'Read a record by ID from an Agiloft table.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts", "contacts.employees")', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the record to read', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated list of field names to include in the response', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'GET', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildReadRecordUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { id: null, fields: {} }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + const result = data.result ?? data + const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null + + return { + success: data.success !== false, + output: { + id: id != null ? String(id) : null, + fields: result ?? {}, + }, + } + } + ) + }, + + outputs: { + id: { + type: 'string', + description: 'ID of the record', + }, + fields: { + type: 'json', + description: 'Field values of the record', + }, + }, +} diff --git a/apps/sim/tools/agiloft/remove_attachment.ts b/apps/sim/tools/agiloft/remove_attachment.ts new file mode 100644 index 0000000000..3117017719 --- /dev/null +++ b/apps/sim/tools/agiloft/remove_attachment.ts @@ -0,0 +1,132 @@ +import type { + AgiloftRemoveAttachmentParams, + AgiloftRemoveAttachmentResponse, +} from '@/tools/agiloft/types' +import { buildRemoveAttachmentUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftRemoveAttachmentTool: ToolConfig< + AgiloftRemoveAttachmentParams, + AgiloftRemoveAttachmentResponse +> = { + id: 'agiloft_remove_attachment', + name: 'Agiloft Remove Attachment', + description: 'Remove an attached file from a field in an Agiloft record.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts")', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the record containing the attachment', + }, + fieldName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the attachment field', + }, + position: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Position index of the file to remove (starting from 0)', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'GET', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildRemoveAttachmentUrl(base, params), + method: 'GET', + }), + async (response) => { + const text = await response.text() + + if (!response.ok) { + return { + success: false, + output: { + recordId: params.recordId?.trim() ?? '', + fieldName: params.fieldName?.trim() ?? '', + remainingAttachments: 0, + }, + error: `Agiloft error: ${response.status} - ${text}`, + } + } + + let remainingAttachments = 0 + try { + const data = JSON.parse(text) + const result = data.result ?? data + remainingAttachments = + typeof result === 'number' ? result : (result.count ?? result.remaining ?? 0) + } catch { + remainingAttachments = Number(text) || 0 + } + + return { + success: true, + output: { + recordId: params.recordId?.trim() ?? '', + fieldName: params.fieldName?.trim() ?? '', + remainingAttachments, + }, + } + } + ) + }, + + outputs: { + recordId: { + type: 'string', + description: 'ID of the record', + }, + fieldName: { + type: 'string', + description: 'Name of the attachment field', + }, + remainingAttachments: { + type: 'number', + description: 'Number of attachments remaining in the field after removal', + }, + }, +} diff --git a/apps/sim/tools/agiloft/retrieve_attachment.ts b/apps/sim/tools/agiloft/retrieve_attachment.ts new file mode 100644 index 0000000000..721efdc006 --- /dev/null +++ b/apps/sim/tools/agiloft/retrieve_attachment.ts @@ -0,0 +1,128 @@ +import type { + AgiloftRetrieveAttachmentParams, + AgiloftRetrieveAttachmentResponse, +} from '@/tools/agiloft/types' +import { buildRetrieveAttachmentUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftRetrieveAttachmentTool: ToolConfig< + AgiloftRetrieveAttachmentParams, + AgiloftRetrieveAttachmentResponse +> = { + id: 'agiloft_retrieve_attachment', + name: 'Agiloft Retrieve Attachment', + description: 'Download an attached file from an Agiloft record field.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts")', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the record containing the attachment', + }, + fieldName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the attachment field', + }, + position: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Position index of the file in the field (starting from 0)', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'GET', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildRetrieveAttachmentUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { + file: { name: '', mimeType: '', data: '', size: 0 }, + }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const contentType = response.headers.get('content-type') || 'application/octet-stream' + const contentDisposition = response.headers.get('content-disposition') + let fileName = 'attachment' + + if (contentDisposition) { + const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (match?.[1]) { + fileName = match[1].replace(/['"]/g, '') + } + } + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + return { + success: true, + output: { + file: { + name: fileName, + mimeType: contentType, + data: buffer.toString('base64'), + size: buffer.length, + }, + }, + } + } + ) + }, + + outputs: { + file: { + type: 'file', + description: 'Downloaded attachment file', + }, + }, +} diff --git a/apps/sim/tools/agiloft/saved_search.ts b/apps/sim/tools/agiloft/saved_search.ts new file mode 100644 index 0000000000..79dacc0357 --- /dev/null +++ b/apps/sim/tools/agiloft/saved_search.ts @@ -0,0 +1,116 @@ +import type { AgiloftSavedSearchParams, AgiloftSavedSearchResponse } from '@/tools/agiloft/types' +import { buildSavedSearchUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftSavedSearchTool: ToolConfig< + AgiloftSavedSearchParams, + AgiloftSavedSearchResponse +> = { + id: 'agiloft_saved_search', + name: 'Agiloft Saved Search', + description: 'List saved searches defined for an Agiloft table.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to list saved searches for (e.g., "contracts")', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'GET', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildSavedSearchUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { searches: [] }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + const result = data.result ?? data + + const searches: Array<{ + name: string + label: string + id: string | number + description: string | null + }> = [] + + if (Array.isArray(result)) { + for (const item of result) { + searches.push({ + name: item.name ?? '', + label: item.label ?? item.name ?? '', + id: item.id ?? item.ID ?? '', + description: item.description ?? null, + }) + } + } + + return { + success: data.success !== false, + output: { + searches, + }, + } + } + ) + }, + + outputs: { + searches: { + type: 'array', + description: 'List of saved searches for the table', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Saved search name' }, + label: { type: 'string', description: 'Saved search display label' }, + id: { type: 'string', description: 'Saved search database identifier' }, + description: { type: 'string', description: 'Saved search description' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/agiloft/search_records.ts b/apps/sim/tools/agiloft/search_records.ts new file mode 100644 index 0000000000..32a41e3b23 --- /dev/null +++ b/apps/sim/tools/agiloft/search_records.ts @@ -0,0 +1,166 @@ +import type { AgiloftSearchRecordsParams, AgiloftSearchResponse } from '@/tools/agiloft/types' +import { buildSearchRecordsUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftSearchRecordsTool: ToolConfig< + AgiloftSearchRecordsParams, + AgiloftSearchResponse +> = { + id: 'agiloft_search_records', + name: 'Agiloft Search Records', + description: 'Search for records in an Agiloft table using a query.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to search in (e.g., "contracts", "contacts.employees")', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Search query using Agiloft query syntax (e.g., "status=\'Active\'" or "company_name~=\'Acme\'")', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated list of field names to include in the results', + }, + page: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Page number for paginated results (starting from 0)', + }, + limit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of records to return per page', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'GET', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildSearchRecordsUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { records: [], totalCount: 0, page: 0, limit: 25 }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + const records: Record[] = [] + + if (data.result && Array.isArray(data.result)) { + for (const item of data.result) { + records.push(item) + } + } else if (Array.isArray(data)) { + for (const item of data) { + records.push(item) + } + } else if (data.results && Array.isArray(data.results)) { + for (const item of data.results) { + records.push(item) + } + } else if (data.records && Array.isArray(data.records)) { + for (const item of data.records) { + records.push(item) + } + } else if (typeof data.EWREST_length === 'number') { + const count = data.EWREST_length as number + for (let i = 0; i < count; i++) { + const record: Record = {} + for (const key of Object.keys(data)) { + const match = key.match(/^EWREST_(.+)_(\d+)$/) + if (match && Number(match[2]) === i) { + record[match[1]] = data[key] + } + } + if (Object.keys(record).length > 0) { + records.push(record) + } + } + } + + const totalCount = + data.totalCount ?? data.total ?? data.count ?? data.EWREST_length ?? records.length + const page = params.page ? Number(params.page) : 0 + const limit = params.limit ? Number(params.limit) : 25 + + return { + success: data.success !== false, + output: { + records, + totalCount, + page, + limit, + }, + } + } + ) + }, + + outputs: { + records: { + type: 'json', + description: 'Array of matching records with their field values', + }, + totalCount: { + type: 'number', + description: 'Total number of matching records', + }, + page: { + type: 'number', + description: 'Current page number', + }, + limit: { + type: 'number', + description: 'Records per page', + }, + }, +} diff --git a/apps/sim/tools/agiloft/select_records.ts b/apps/sim/tools/agiloft/select_records.ts new file mode 100644 index 0000000000..f580dd13ae --- /dev/null +++ b/apps/sim/tools/agiloft/select_records.ts @@ -0,0 +1,125 @@ +import type { AgiloftSelectRecordsParams, AgiloftSelectResponse } from '@/tools/agiloft/types' +import { buildSelectRecordsUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftSelectRecordsTool: ToolConfig< + AgiloftSelectRecordsParams, + AgiloftSelectResponse +> = { + id: 'agiloft_select_records', + name: 'Agiloft Select Records', + description: 'Select record IDs matching a SQL WHERE clause from an Agiloft table.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts", "contacts.employees")', + }, + where: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'SQL WHERE clause using database column names (e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'")', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'GET', + headers: () => ({}), + }, + + directExecution: async (params) => { + return executeAgiloftRequest( + params, + (base) => ({ + url: buildSelectRecordsUrl(base, params), + method: 'GET', + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { recordIds: [], totalCount: 0 }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + const result = data.result ?? data + const recordIds: string[] = [] + + if (Array.isArray(result)) { + for (const item of result) { + const id = item.id ?? item.ID ?? item + recordIds.push(String(id)) + } + } else if (typeof result === 'object' && result !== null) { + let i = 0 + while (result[`id_${i}`] !== undefined || result[`EWREST_id_${i}`] !== undefined) { + const id = result[`id_${i}`] ?? result[`EWREST_id_${i}`] + recordIds.push(String(id)) + i++ + } + if (recordIds.length === 0 && result.id !== undefined) { + recordIds.push(String(result.id)) + } + } + + const totalCount = + data.EWREST_id_length ?? data.totalCount ?? data.total ?? data.count ?? recordIds.length + + return { + success: data.success !== false, + output: { + recordIds, + totalCount: Number(totalCount), + }, + } + } + ) + }, + + outputs: { + recordIds: { + type: 'array', + description: 'Array of record IDs matching the query', + items: { + type: 'string', + }, + }, + totalCount: { + type: 'number', + description: 'Total number of matching records', + }, + }, +} diff --git a/apps/sim/tools/agiloft/types.ts b/apps/sim/tools/agiloft/types.ts new file mode 100644 index 0000000000..9d13263155 --- /dev/null +++ b/apps/sim/tools/agiloft/types.ts @@ -0,0 +1,162 @@ +import type { ToolResponse } from '@/tools/types' + +/** + * Base parameters shared by all Agiloft tools. + * Agiloft authenticates via instance URL, KB name, and user credentials. + */ +export interface AgiloftBaseParams { + instanceUrl: string + knowledgeBase: string + login: string + password: string + table: string +} + +export interface AgiloftCreateRecordParams extends AgiloftBaseParams { + data: string +} + +export interface AgiloftReadRecordParams extends AgiloftBaseParams { + recordId: string + fields?: string +} + +export interface AgiloftUpdateRecordParams extends AgiloftBaseParams { + recordId: string + data: string +} + +export interface AgiloftDeleteRecordParams extends AgiloftBaseParams { + recordId: string +} + +export interface AgiloftSearchRecordsParams extends AgiloftBaseParams { + query: string + fields?: string + page?: string + limit?: string +} + +export interface AgiloftSelectRecordsParams extends AgiloftBaseParams { + where: string +} + +export type AgiloftSavedSearchParams = AgiloftBaseParams + +export interface AgiloftAttachmentInfoParams extends AgiloftBaseParams { + recordId: string + fieldName: string +} + +export interface AgiloftLockRecordParams extends AgiloftBaseParams { + recordId: string + lockAction: 'lock' | 'unlock' | 'check' +} + +export interface AgiloftRecordResponse extends ToolResponse { + output: { + id: string | null + fields: Record + } +} + +export interface AgiloftDeleteResponse extends ToolResponse { + output: { + id: string + deleted: boolean + } +} + +export interface AgiloftSearchResponse extends ToolResponse { + output: { + records: Record[] + totalCount: number + page: number + limit: number + } +} + +export interface AgiloftSelectResponse extends ToolResponse { + output: { + recordIds: string[] + totalCount: number + } +} + +export interface AgiloftSavedSearchResponse extends ToolResponse { + output: { + searches: Array<{ + name: string + label: string + id: string | number + description: string | null + }> + } +} + +export interface AgiloftAttachmentInfoResponse extends ToolResponse { + output: { + attachments: Array<{ + position: number + name: string + size: number + }> + totalCount: number + } +} + +export interface AgiloftLockResponse extends ToolResponse { + output: { + id: string + lockStatus: string + lockedBy: string | null + lockExpiresInMinutes: number | null + } +} + +export interface AgiloftAttachFileParams extends AgiloftBaseParams { + recordId: string + fieldName: string + file?: unknown + fileName?: string +} + +export interface AgiloftAttachFileResponse extends ToolResponse { + output: { + recordId: string + fieldName: string + fileName: string + totalAttachments: number + } +} + +export interface AgiloftRetrieveAttachmentParams extends AgiloftBaseParams { + recordId: string + fieldName: string + position: string +} + +export interface AgiloftRetrieveAttachmentResponse extends ToolResponse { + output: { + file: { + name: string + mimeType: string + data: string + size: number + } + } +} + +export interface AgiloftRemoveAttachmentParams extends AgiloftBaseParams { + recordId: string + fieldName: string + position: string +} + +export interface AgiloftRemoveAttachmentResponse extends ToolResponse { + output: { + recordId: string + fieldName: string + remainingAttachments: number + } +} diff --git a/apps/sim/tools/agiloft/update_record.ts b/apps/sim/tools/agiloft/update_record.ts new file mode 100644 index 0000000000..e370e61b68 --- /dev/null +++ b/apps/sim/tools/agiloft/update_record.ts @@ -0,0 +1,119 @@ +import type { AgiloftRecordResponse, AgiloftUpdateRecordParams } from '@/tools/agiloft/types' +import { buildUpdateRecordUrl, executeAgiloftRequest } from '@/tools/agiloft/utils' +import type { ToolConfig } from '@/tools/types' + +export const agiloftUpdateRecordTool: ToolConfig = + { + id: 'agiloft_update_record', + name: 'Agiloft Update Record', + description: 'Update an existing record in an Agiloft table.', + version: '1.0.0', + + params: { + instanceUrl: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)', + }, + knowledgeBase: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Knowledge base name', + }, + login: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft username', + }, + password: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Agiloft password', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name (e.g., "contracts", "contacts.employees")', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the record to update', + }, + data: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Updated field values as a JSON object (e.g., {"status": "Active", "priority": "High"})', + }, + }, + + request: { + url: 'https://placeholder.agiloft.com', + method: 'PUT', + headers: () => ({}), + }, + + directExecution: async (params) => { + let body: string + try { + body = JSON.stringify(JSON.parse(params.data)) + } catch { + return { + success: false, + output: { id: null, fields: {} }, + error: 'Invalid JSON in data parameter', + } + } + + return executeAgiloftRequest( + params, + (base) => ({ + url: buildUpdateRecordUrl(base, params), + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body, + }), + async (response) => { + if (!response.ok) { + const errorText = await response.text() + return { + success: false, + output: { id: null, fields: {} }, + error: `Agiloft error: ${response.status} - ${errorText}`, + } + } + + const data = await response.json() + const result = data.result ?? data + const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null + + return { + success: data.success !== false, + output: { + id: id != null ? String(id) : null, + fields: result ?? {}, + }, + } + } + ) + }, + + outputs: { + id: { + type: 'string', + description: 'ID of the updated record', + }, + fields: { + type: 'json', + description: 'Updated field values of the record', + }, + }, + } diff --git a/apps/sim/tools/agiloft/utils.ts b/apps/sim/tools/agiloft/utils.ts new file mode 100644 index 0000000000..252dcb4a81 --- /dev/null +++ b/apps/sim/tools/agiloft/utils.ts @@ -0,0 +1,255 @@ +import { createLogger } from '@sim/logger' +import { validateExternalUrl } from '@/lib/core/security/input-validation' +import type { + AgiloftAttachmentInfoParams, + AgiloftBaseParams, + AgiloftDeleteRecordParams, + AgiloftLockRecordParams, + AgiloftReadRecordParams, + AgiloftRemoveAttachmentParams, + AgiloftRetrieveAttachmentParams, + AgiloftSavedSearchParams, + AgiloftSearchRecordsParams, + AgiloftSelectRecordsParams, +} from '@/tools/agiloft/types' +import type { HttpMethod, ToolResponse } from '@/tools/types' + +const logger = createLogger('AgiloftAuth') + +interface AgiloftRequestConfig { + url: string + method: HttpMethod + headers?: Record + body?: BodyInit +} + +/** + * Exchanges login/password for a short-lived Bearer token via EWLogin. + */ +async function agiloftLogin(params: AgiloftBaseParams): Promise { + const base = params.instanceUrl.replace(/\/$/, '') + + const urlValidation = validateExternalUrl(params.instanceUrl, 'instanceUrl') + if (!urlValidation.isValid) { + throw new Error(`Invalid Agiloft instance URL: ${urlValidation.error}`) + } + + const kb = encodeURIComponent(params.knowledgeBase) + const login = encodeURIComponent(params.login) + const password = encodeURIComponent(params.password) + + const url = `${base}/ewws/EWLogin?$KB=${kb}&$login=${login}&$password=${password}` + const response = await fetch(url, { method: 'POST' }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Agiloft login failed: ${response.status} - ${errorText}`) + } + + const data = await response.json() + const token = data.access_token + + if (!token) { + throw new Error('Agiloft login did not return an access token') + } + + return token +} + +/** + * Cleans up the server session. Best-effort — failures are logged but not thrown. + */ +async function agiloftLogout( + instanceUrl: string, + knowledgeBase: string, + token: string +): Promise { + try { + const base = instanceUrl.replace(/\/$/, '') + const kb = encodeURIComponent(knowledgeBase) + await fetch(`${base}/ewws/EWLogout?$KB=${kb}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + } catch (error) { + logger.warn('Agiloft logout failed (best-effort)', { error }) + } +} + +/** + * Shared wrapper that handles the full auth lifecycle: + * 1. Login to get Bearer token + * 2. Execute the request with the token + * 3. Logout to clean up the session + * + * The `buildRequest` callback receives the token and base URL, and returns + * the request config. The `transformResponse` callback converts the raw + * Response into the tool's output format. + */ +export async function executeAgiloftRequest( + params: AgiloftBaseParams, + buildRequest: (base: string) => AgiloftRequestConfig, + transformResponse: (response: Response) => Promise +): Promise { + const token = await agiloftLogin(params) + const base = params.instanceUrl.replace(/\/$/, '') + + try { + const req = buildRequest(base) + const response = await fetch(req.url, { + method: req.method, + headers: { + ...req.headers, + Authorization: `Bearer ${token}`, + }, + body: req.body, + }) + return await transformResponse(response) + } finally { + await agiloftLogout(params.instanceUrl, params.knowledgeBase, token) + } +} + +/** + * Login helper exported for use in the attach file API route. + */ +export { agiloftLogin, agiloftLogout } + +/** URL builders (credential-free -- auth is via Bearer token header) */ + +function encodeTable(params: AgiloftBaseParams) { + return { + kb: encodeURIComponent(params.knowledgeBase), + table: encodeURIComponent(params.table), + } +} + +export function buildCreateRecordUrl(base: string, params: AgiloftBaseParams): string { + const { kb, table } = encodeTable(params) + return `${base}/ewws/REST/${kb}/${table}?$lang=en` +} + +export function buildReadRecordUrl(base: string, params: AgiloftReadRecordParams): string { + const { kb, table } = encodeTable(params) + const id = encodeURIComponent(params.recordId.trim()) + let url = `${base}/ewws/REST/${kb}/${table}/${id}?$lang=en` + + if (params.fields) { + const fieldList = params.fields + .split(',') + .map((f) => f.trim()) + .filter(Boolean) + for (const field of fieldList) { + url += `&$fields=${encodeURIComponent(field)}` + } + } + + return url +} + +export function buildUpdateRecordUrl( + base: string, + params: AgiloftBaseParams & { recordId: string } +): string { + const { kb, table } = encodeTable(params) + const id = encodeURIComponent(params.recordId.trim()) + return `${base}/ewws/REST/${kb}/${table}/${id}?$lang=en` +} + +export function buildDeleteRecordUrl(base: string, params: AgiloftDeleteRecordParams): string { + const { kb, table } = encodeTable(params) + const id = encodeURIComponent(params.recordId.trim()) + return `${base}/ewws/REST/${kb}/${table}/${id}?$lang=en` +} + +function buildEwBaseQuery(params: AgiloftBaseParams): string { + const { kb, table } = encodeTable(params) + return `$KB=${kb}&$table=${table}&$lang=en` +} + +export function buildSearchRecordsUrl(base: string, params: AgiloftSearchRecordsParams): string { + const query = encodeURIComponent(params.query) + let url = `${base}/ewws/EWSearch/.json?${buildEwBaseQuery(params)}&query=${query}` + + if (params.fields) { + const fieldList = params.fields + .split(',') + .map((f) => f.trim()) + .filter(Boolean) + for (const field of fieldList) { + url += `&field=${encodeURIComponent(field)}` + } + } + + if (params.page) { + url += `&page=${encodeURIComponent(params.page)}` + } + if (params.limit) { + url += `&limit=${encodeURIComponent(params.limit)}` + } + + return url +} + +export function buildSelectRecordsUrl(base: string, params: AgiloftSelectRecordsParams): string { + const where = encodeURIComponent(params.where) + return `${base}/ewws/EWSelect/.json?${buildEwBaseQuery(params)}&where=${where}` +} + +export function buildSavedSearchUrl(base: string, params: AgiloftSavedSearchParams): string { + return `${base}/ewws/EWSavedSearch/.json?${buildEwBaseQuery(params)}` +} + +export function buildRetrieveAttachmentUrl( + base: string, + params: AgiloftRetrieveAttachmentParams +): string { + const id = encodeURIComponent(params.recordId.trim()) + const field = encodeURIComponent(params.fieldName.trim()) + const position = encodeURIComponent(params.position) + return `${base}/ewws/EWRetrieve?${buildEwBaseQuery(params)}&id=${id}&field=${field}&filePosition=${position}` +} + +export function buildRemoveAttachmentUrl( + base: string, + params: AgiloftRemoveAttachmentParams +): string { + const id = encodeURIComponent(params.recordId.trim()) + const field = encodeURIComponent(params.fieldName.trim()) + const position = encodeURIComponent(params.position) + return `${base}/ewws/EWRemoveAttachment?${buildEwBaseQuery(params)}&id=${id}&field=${field}&filePosition=${position}` +} + +export function buildAttachmentInfoUrl(base: string, params: AgiloftAttachmentInfoParams): string { + const id = encodeURIComponent(params.recordId.trim()) + const fieldName = encodeURIComponent(params.fieldName.trim()) + return `${base}/ewws/EWAttachInfo/.json?${buildEwBaseQuery(params)}&id=${id}&field=${fieldName}` +} + +export function buildLockRecordUrl(base: string, params: AgiloftLockRecordParams): string { + const id = encodeURIComponent(params.recordId.trim()) + return `${base}/ewws/EWLock/.json?${buildEwBaseQuery(params)}&id=${id}` +} + +export function buildAttachFileUrl( + base: string, + params: AgiloftBaseParams & { recordId: string; fieldName: string }, + fileName: string +): string { + const { kb, table } = encodeTable(params) + const recordId = encodeURIComponent(params.recordId.trim()) + const fieldName = encodeURIComponent(params.fieldName.trim()) + const encodedFileName = encodeURIComponent(fileName) + return `${base}/ewws/EWAttach?$KB=${kb}&$table=${table}&$lang=en&id=${recordId}&field=${fieldName}&fileName=${encodedFileName}` +} + +export function getLockHttpMethod(lockAction: string): HttpMethod { + switch (lockAction) { + case 'lock': + return 'PUT' + case 'unlock': + return 'DELETE' + default: + return 'GET' + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index c26110f07a..6737fc1655 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -31,6 +31,20 @@ import { agentmailUpdateMessageTool, agentmailUpdateThreadTool, } from '@/tools/agentmail' +import { + agiloftAttachFileTool, + agiloftAttachmentInfoTool, + agiloftCreateRecordTool, + agiloftDeleteRecordTool, + agiloftLockRecordTool, + agiloftReadRecordTool, + agiloftRemoveAttachmentTool, + agiloftRetrieveAttachmentTool, + agiloftSavedSearchTool, + agiloftSearchRecordsTool, + agiloftSelectRecordsTool, + agiloftUpdateRecordTool, +} from '@/tools/agiloft' import { ahrefsBacklinksStatsTool, ahrefsBacklinksTool, @@ -2800,6 +2814,18 @@ export const tools: Record = { agentmail_update_inbox: agentmailUpdateInboxTool, agentmail_update_message: agentmailUpdateMessageTool, agentmail_update_thread: agentmailUpdateThreadTool, + agiloft_attach_file: agiloftAttachFileTool, + agiloft_attachment_info: agiloftAttachmentInfoTool, + agiloft_create_record: agiloftCreateRecordTool, + agiloft_delete_record: agiloftDeleteRecordTool, + agiloft_lock_record: agiloftLockRecordTool, + agiloft_read_record: agiloftReadRecordTool, + agiloft_remove_attachment: agiloftRemoveAttachmentTool, + agiloft_retrieve_attachment: agiloftRetrieveAttachmentTool, + agiloft_saved_search: agiloftSavedSearchTool, + agiloft_search_records: agiloftSearchRecordsTool, + agiloft_select_records: agiloftSelectRecordsTool, + agiloft_update_record: agiloftUpdateRecordTool, airweave_search: airweaveSearchTool, amplitude_send_event: amplitudeSendEventTool, amplitude_identify_user: amplitudeIdentifyUserTool,