Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 42 additions & 16 deletions apps/docs/content/docs/en/integrations/agiloft.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,7 +34,7 @@ In Sim, the Agiloft integration enables your agents to manage contracts and reco

## 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.
Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, record locking, and running action buttons across any table in your knowledge base.



Expand DownExpand Up@@ -129,6 +129,7 @@ Delete a record from an Agiloft table.
| `password` | string | Yes | Agiloft password |
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
| `recordId` | string | Yes | ID of the record to delete |
| `deleteRule` | string | No | How to treat records that depend on this one: ERROR_IF_DEPENDANTS \(default — fails rather than cascading\), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, or UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE |

#### Output

Expand DownExpand Up@@ -174,13 +175,14 @@ Lock, unlock, or check the lock status of an Agiloft record.
| `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" |
| `force` | boolean | No | Unlock only: release a lock held by another user. Requires membership in the admin group. |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Record ID |
| `lockStatus` | string | Lock status \(e.g., "LOCKED", "UNLOCKED"\) |
| `lockStatus` | string | Lock status: "LOCKED" when the record is held, "NO_LOCK" when it is free |
| `lockedBy` | string | Username of the user who locked the record |
| `lockExpiresInMinutes` | number | Minutes until the lock expires |

Expand DownExpand Up@@ -255,9 +257,9 @@ Download an attached file from an Agiloft record field.
| --------- | ---- | ----------- |
| `file` | file | Downloaded attachment file |

### Agiloft Saved Search
### Agiloft Run Action Button

List saved searches defined for an Agiloft table.
Run an action button on an Agiloft record, such as an approval or send-for-signature step.

#### Input

Expand All@@ -267,17 +269,40 @@ List saved searches defined for an Agiloft table.
| `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"\) |
| `table` | string | Yes | Table name \(e.g., "contracts", "case"\) |
| `recordId` | string | Yes | ID of the record to run the action button on |
| `actionButtonField` | string | Yes | Logical name of the field holding the action button \(e.g., "ab_field"\) |

#### 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` | number | Saved search database identifier |
| ↳ `description` | string | Saved search description |
| `recordId` | string | ID of the record the action button was run on |
| `callbackId` | string | Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID |

### saved_search


### Agiloft Saved Search (retired)

Retired. Agiloft does not document an endpoint for listing saved searches — use the Search Records operation and set its Saved Search field instead.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `instanceUrl` | string | No | Agiloft instance URL |
| `knowledgeBase` | string | No | Knowledge base name |
| `login` | string | No | Agiloft username |
| `password` | string | No | Agiloft password |
| `table` | string | No | Table name |
| `output` | string | No | No description |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `searches` | array | Always empty; this operation is retired |

### Agiloft Search Records

Expand All@@ -292,19 +317,20 @@ Search for records in an Agiloft table using a query.
| `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\'"\) |
| `query` | string | No | Ad hoc EWSearch query. Combine conditions with && \(and\) or \|\| \(or\) and quote every value — e.g. \"summary~='test'&&priority='High'\". Required unless a saved search is given. |
| `search` | string | No | Label of a saved search defined on the table \(e.g., "C: Status is Closed"\). Can be combined with a query to narrow it further. |
| `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 |
| `limit` | string | No | Maximum number of records to return per page. Agiloft treats 0 as "all records", so leave it unset or use a positive value to keep result sizes bounded. |

#### 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 |
| `totalCount` | number | Number of records reported by EWSearch. When paginating this is the count for the current page, not the whole result set. |
| `page` | number | Page number that was requested \(0-based\) |
| `limit` | number | Page size that was requested; 0 when no limit was sent and Agiloft chose one |

### Agiloft Select Records

Expand All@@ -319,7 +345,7 @@ Select record IDs matching a SQL WHERE clause from an Agiloft table.
| `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\'"\) |
| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\). EWSelect has no page size and returns every matching ID, so append a database limit such as "limit 0,200" to bound the result. |

#### Output

Expand Down
176 changes: 176 additions & 0 deletions apps/sim/app/api/tools/agiloft/create_record/route.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
hybridAuthMockFns,
inputValidationMock,
inputValidationMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)

import { POST } from '@/app/api/tools/agiloft/create_record/route'
import { POST as SEARCH } from '@/app/api/tools/agiloft/search_records/route'
import { POST as SELECT } from '@/app/api/tools/agiloft/select_records/route'

const PINNED_IP = '93.184.216.34'

const baseBody = {
instanceUrl: 'https://example.agiloft.com',
knowledgeBase: 'Demo',
login: 'admin',
password: 'secret',
table: 'contacts.employees',
data: JSON.stringify({ first_name: 'John', last_name: 'Doe' }),
}

function mockSecureFetchResponse(body: { ok?: boolean; json?: unknown; text?: string }) {
return {
ok: body.ok ?? true,
status: body.ok === false ? 400 : 200,
statusText: '',
headers: new Headers(),
body: null,
text: async () => body.text ?? '',
json: async () => body.json ?? {},
arrayBuffer: async () => new ArrayBuffer(0),
}
}

beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'internal_jwt',
})
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: PINNED_IP,
originalHostname: 'example.agiloft.com',
})
})

describe('POST /api/tools/agiloft/create_record', () => {
it("reads the record ID out of EWCreate's EWREST_id assignment", async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
.mockResolvedValueOnce(mockSecureFetchResponse({ text: "EWREST_id='353';" }))
.mockResolvedValueOnce(mockSecureFetchResponse({}))

const response = await POST(createMockRequest('POST', baseBody))
const data = (await response.json()) as {
success: boolean
output: { id: string | null }
}

expect(data.success).toBe(true)
expect(data.output.id).toBe('353')

const operationCall = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
expect(operationCall[0]).toContain('/ewws/EWCreate?')
expect(operationCall[0]).toContain('&first_name=John')
expect(operationCall[2]).toMatchObject({ method: 'POST' })
})

it('fails loudly when Agiloft answers 200 with something that is not an EWREST body', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
.mockResolvedValueOnce(
mockSecureFetchResponse({ text: 'Error executing query, please consult logs' })
)
.mockResolvedValueOnce(mockSecureFetchResponse({}))

const response = await POST(createMockRequest('POST', baseBody))
const data = (await response.json()) as { success: boolean; error?: string }

expect(data.success).toBe(false)
expect(data.error).toContain('did not return a record ID')
})

it('rejects a data payload that is not a JSON object', async () => {
const response = await POST(
createMockRequest('POST', { ...baseBody, data: '["not", "an", "object"]' })
)
const data = (await response.json()) as { success: boolean; error?: string }

expect(data.success).toBe(false)
expect(data.error).toContain('must be a JSON object')
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})
})

describe('empty EWREST bodies on search and select', () => {
const listBase = {
instanceUrl: 'https://example.agiloft.com',
knowledgeBase: 'Demo',
login: 'admin',
password: 'secret',
table: 'helpdesk_case',
}

function arrange(text: string) {
inputValidationMockFns.mockSecureFetchWithPinnedIP
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok' } }))
.mockResolvedValueOnce(mockSecureFetchResponse({ text }))
.mockResolvedValueOnce(mockSecureFetchResponse({}))
}

it('treats a plain-text refusal from EWSearch as a failure, not an empty result', async () => {
arrange('Error executing query, please consult logs')

const response = await SEARCH(
createMockRequest('POST', { ...listBase, query: "priority='High'" })
)
const data = (await response.json()) as { success: boolean; error?: string }

expect(data.success).toBe(false)
expect(data.error).toContain('did not return search results')
})

it('still reports a genuinely empty EWSearch result as a success', async () => {
arrange("EWREST_id_length = '0';")

const response = await SEARCH(
createMockRequest('POST', { ...listBase, query: "priority='High'" })
)
const data = (await response.json()) as {
success: boolean
output: { records: unknown[]; totalCount: number }
}

expect(data.success).toBe(true)
expect(data.output.records).toEqual([])
expect(data.output.totalCount).toBe(0)
})

it('treats a plain-text refusal from EWSelect as a failure, not an empty result', async () => {
arrange('Error executing query, please consult logs')

const response = await SELECT(
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
)
const data = (await response.json()) as { success: boolean; error?: string }

expect(data.success).toBe(false)
expect(data.error).toContain('did not return a result set')
})

it('still reports a genuinely empty EWSelect result as a success', async () => {
arrange("EWREST_id_length = '0';")

const response = await SELECT(
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
)
const data = (await response.json()) as {
success: boolean
output: { recordIds: string[]; totalCount: number }
}

expect(data.success).toBe(true)
expect(data.output.recordIds).toEqual([])
expect(data.output.totalCount).toBe(0)
})
})
53 changes: 35 additions & 18 deletions apps/sim/app/api/tools/agiloft/create_record/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,9 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
import { buildCreateRecordUrl } from '@/tools/agiloft/utils'
import { buildCreateRecordUrl, recordUrlLengthError } from '@/tools/agiloft/utils'
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'

export const dynamic = 'force-dynamic'
Expand DownExpand Up@@ -49,46 +50,62 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response
const params = parsed.data.body

let body: string
let fieldValues: Record<string, unknown>
try {
body = JSON.stringify(JSON.parse(params.data))
const parsedData = JSON.parse(params.data)
if (typeof parsedData !== 'object' || parsedData === null || Array.isArray(parsedData)) {
throw new Error('not an object')
}
fieldValues = parsedData as Record<string, unknown>
} catch {
return NextResponse.json({
success: false,
output: { id: null, fields: {} },
error: 'Invalid JSON in data parameter',
error: 'The data parameter must be a JSON object of field names to values',
})
}

const oversized = recordUrlLengthError(params.instanceUrl, (base) =>
buildCreateRecordUrl(base, params, fieldValues)
)
if (oversized) {
return NextResponse.json({
success: false,
output: { id: null, fields: {} },
error: oversized,
})
}

const result = await executeAgiloftRequest<AgiloftRecordResponse>(
params,
(base) => ({
url: buildCreateRecordUrl(base, params),
url: buildCreateRecordUrl(base, params, fieldValues),
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
async (response) => {
const body = await response.text()

if (!response.ok) {
const errorText = await response.text()
return {
success: false,
output: { id: null, fields: {} },
error: `Agiloft error: ${response.status} - ${errorText}`,
error: `Agiloft error: ${response.status} - ${body}`,
}
}

const data = (await response.json()) as Record<string, unknown>
const result = (data.result ?? data) as Record<string, unknown>
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
/** EWCreate answers with a single assignment: EWREST_id='353'; */
const { id, fields } = toRecord(parseEwRest(body))

return {
success: data.success !== false,
output: {
id: id != null ? String(id) : null,
fields: result ?? {},
},
if (id === null) {
return {
success: false,
output: { id: null, fields },
error: `Agiloft did not return a record ID: ${body.trim() || '(empty response)'}`,
}
}

return { success: true, output: { id, fields } }
}
)

Expand Down
Loading
Loading