Skip to content

Commit f755418

Browse files
committed
feat(tools): add Ashby custom field writes, delete, source, and anonymize
customField.setValue/setValues are the only way to annotate a job or req, since Ashby has no job notes and no job tags. Writing null clears a value, so the annotation is reversible. application.delete needs candidatesDelete, a module permission separate from candidatesWrite. application.changeSource requires sourceId to be present even when unsetting, so an empty input serializes to explicit null. candidate.anonymize strips PII but leaves the record; Ashby exposes no candidate deletion endpoint.
1 parent 881fae6 commit f755418

10 files changed

Lines changed: 447 additions & 4 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { AshbyCandidate } from '@/tools/ashby/types'
2+
import {
3+
ashbyAuthHeaders,
4+
ashbyErrorMessage,
5+
CANDIDATE_OUTPUTS,
6+
mapCandidate,
7+
} from '@/tools/ashby/utils'
8+
import type { ToolConfig, ToolResponse } from '@/tools/types'
9+
10+
interface AshbyAnonymizeCandidateParams {
11+
apiKey: string
12+
candidateId: string
13+
}
14+
15+
interface AshbyAnonymizeCandidateResponse extends ToolResponse {
16+
output: AshbyCandidate
17+
}
18+
19+
export const anonymizeCandidateTool: ToolConfig<
20+
AshbyAnonymizeCandidateParams,
21+
AshbyAnonymizeCandidateResponse
22+
> = {
23+
id: 'ashby_anonymize_candidate',
24+
name: 'Ashby Anonymize Candidate',
25+
description:
26+
'Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.',
27+
version: '1.0.0',
28+
29+
params: {
30+
apiKey: {
31+
type: 'string',
32+
required: true,
33+
visibility: 'user-only',
34+
description: 'Ashby API Key',
35+
},
36+
candidateId: {
37+
type: 'string',
38+
required: true,
39+
visibility: 'user-or-llm',
40+
description: 'UUID of the candidate to anonymize',
41+
},
42+
},
43+
44+
request: {
45+
url: 'https://api.ashbyhq.com/candidate.anonymize',
46+
method: 'POST',
47+
headers: (params) => ashbyAuthHeaders(params.apiKey),
48+
body: (params) => ({ candidateId: params.candidateId.trim() }),
49+
},
50+
51+
transformResponse: async (response: Response) => {
52+
const data = await response.json()
53+
54+
if (!data.success) {
55+
throw new Error(ashbyErrorMessage(data, 'Failed to anonymize candidate'))
56+
}
57+
58+
return {
59+
success: true,
60+
output: mapCandidate(data.results),
61+
}
62+
},
63+
64+
outputs: CANDIDATE_OUTPUTS,
65+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { AshbyApplication } from '@/tools/ashby/types'
2+
import {
3+
APPLICATION_OUTPUTS,
4+
ashbyAuthHeaders,
5+
ashbyErrorMessage,
6+
mapApplication,
7+
} from '@/tools/ashby/utils'
8+
import type { ToolConfig, ToolResponse } from '@/tools/types'
9+
10+
interface AshbyChangeApplicationSourceParams {
11+
apiKey: string
12+
applicationId: string
13+
sourceId?: string
14+
}
15+
16+
interface AshbyChangeApplicationSourceResponse extends ToolResponse {
17+
output: AshbyApplication
18+
}
19+
20+
export const changeApplicationSourceTool: ToolConfig<
21+
AshbyChangeApplicationSourceParams,
22+
AshbyChangeApplicationSourceResponse
23+
> = {
24+
id: 'ashby_change_application_source',
25+
name: 'Ashby Change Application Source',
26+
description:
27+
'Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.',
28+
version: '1.0.0',
29+
30+
params: {
31+
apiKey: {
32+
type: 'string',
33+
required: true,
34+
visibility: 'user-only',
35+
description: 'Ashby API Key',
36+
},
37+
applicationId: {
38+
type: 'string',
39+
required: true,
40+
visibility: 'user-or-llm',
41+
description: 'UUID of the application whose source should change',
42+
},
43+
sourceId: {
44+
type: 'string',
45+
required: false,
46+
visibility: 'user-or-llm',
47+
description:
48+
'UUID of the source to attribute the application to, as returned by List Sources. Leave empty to unset the application source.',
49+
},
50+
},
51+
52+
request: {
53+
url: 'https://api.ashbyhq.com/application.changeSource',
54+
method: 'POST',
55+
headers: (params) => ashbyAuthHeaders(params.apiKey),
56+
/**
57+
* Ashby requires `sourceId` to be present even when unsetting the source, so
58+
* an empty input must serialize to an explicit null rather than a missing key.
59+
*/
60+
body: (params) => {
61+
const sourceId = params.sourceId?.trim()
62+
return {
63+
applicationId: params.applicationId.trim(),
64+
sourceId: sourceId ? sourceId : null,
65+
}
66+
},
67+
},
68+
69+
transformResponse: async (response: Response) => {
70+
const data = await response.json()
71+
72+
if (!data.success) {
73+
throw new Error(ashbyErrorMessage(data, 'Failed to change application source'))
74+
}
75+
76+
return {
77+
success: true,
78+
output: mapApplication(data.results),
79+
}
80+
},
81+
82+
outputs: APPLICATION_OUTPUTS,
83+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ashbyAuthHeaders, ashbyErrorMessage } from '@/tools/ashby/utils'
2+
import type { ToolConfig, ToolResponse } from '@/tools/types'
3+
4+
interface AshbyDeleteApplicationParams {
5+
apiKey: string
6+
applicationId: string
7+
}
8+
9+
interface AshbyDeleteApplicationResponse extends ToolResponse {
10+
output: {
11+
applicationId: string
12+
}
13+
}
14+
15+
export const deleteApplicationTool: ToolConfig<
16+
AshbyDeleteApplicationParams,
17+
AshbyDeleteApplicationResponse
18+
> = {
19+
id: 'ashby_delete_application',
20+
name: 'Ashby Delete Application',
21+
description:
22+
'Permanently deletes an application in Ashby. Requires the candidatesDelete permission, which is a separate module permission from candidatesWrite - a read and write key returns 403 here. There is no equivalent endpoint for deleting a candidate; candidate deletion is UI-only.',
23+
version: '1.0.0',
24+
25+
params: {
26+
apiKey: {
27+
type: 'string',
28+
required: true,
29+
visibility: 'user-only',
30+
description: 'Ashby API Key',
31+
},
32+
applicationId: {
33+
type: 'string',
34+
required: true,
35+
visibility: 'user-or-llm',
36+
description: 'UUID of the application to delete',
37+
},
38+
},
39+
40+
request: {
41+
url: 'https://api.ashbyhq.com/application.delete',
42+
method: 'POST',
43+
headers: (params) => ashbyAuthHeaders(params.apiKey),
44+
body: (params) => ({ applicationId: params.applicationId.trim() }),
45+
},
46+
47+
transformResponse: async (response: Response) => {
48+
const data = await response.json()
49+
50+
if (!data.success) {
51+
throw new Error(ashbyErrorMessage(data, 'Failed to delete application'))
52+
}
53+
54+
const result = (data.results ?? {}) as Record<string, unknown>
55+
56+
return {
57+
success: true,
58+
output: {
59+
applicationId: (result.applicationId as string) ?? '',
60+
},
61+
}
62+
},
63+
64+
outputs: {
65+
applicationId: {
66+
type: 'string',
67+
description: 'UUID of the deleted application',
68+
},
69+
},
70+
}

apps/sim/tools/ashby/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { addCandidateTagTool } from '@/tools/ashby/add_candidate_tag'
2+
import { anonymizeCandidateTool } from '@/tools/ashby/anonymize_candidate'
3+
import { changeApplicationSourceTool } from '@/tools/ashby/change_application_source'
24
import { changeApplicationStageTool } from '@/tools/ashby/change_application_stage'
35
import { createApplicationTool } from '@/tools/ashby/create_application'
46
import { createCandidateTool } from '@/tools/ashby/create_candidate'
57
import { createNoteTool } from '@/tools/ashby/create_note'
8+
import { deleteApplicationTool } from '@/tools/ashby/delete_application'
69
import { getApplicationTool } from '@/tools/ashby/get_application'
710
import { getCandidateTool } from '@/tools/ashby/get_candidate'
811
import { getJobTool } from '@/tools/ashby/get_job'
@@ -25,17 +28,22 @@ import { listSourcesTool } from '@/tools/ashby/list_sources'
2528
import { listUsersTool } from '@/tools/ashby/list_users'
2629
import { removeCandidateTagTool } from '@/tools/ashby/remove_candidate_tag'
2730
import { searchCandidatesTool } from '@/tools/ashby/search_candidates'
31+
import { setCustomFieldValueTool } from '@/tools/ashby/set_custom_field_value'
32+
import { setCustomFieldValuesTool } from '@/tools/ashby/set_custom_field_values'
2833
import { updateCandidateTool } from '@/tools/ashby/update_candidate'
2934

3035
export const ashbyAddCandidateTagTool = addCandidateTagTool
36+
export const ashbyAnonymizeCandidateTool = anonymizeCandidateTool
37+
export const ashbyChangeApplicationSourceTool = changeApplicationSourceTool
3138
export const ashbyChangeApplicationStageTool = changeApplicationStageTool
3239
export const ashbyCreateApplicationTool = createApplicationTool
3340
export const ashbyCreateCandidateTool = createCandidateTool
3441
export const ashbyCreateNoteTool = createNoteTool
42+
export const ashbyDeleteApplicationTool = deleteApplicationTool
3543
export const ashbyGetApplicationTool = getApplicationTool
3644
export const ashbyGetCandidateTool = getCandidateTool
37-
export const ashbyGetJobTool = getJobTool
3845
export const ashbyGetJobPostingTool = getJobPostingTool
46+
export const ashbyGetJobTool = getJobTool
3947
export const ashbyGetOfferTool = getOfferTool
4048
export const ashbyListApplicationsTool = listApplicationsTool
4149
export const ashbyListArchiveReasonsTool = listArchiveReasonsTool
@@ -54,6 +62,8 @@ export const ashbyListSourcesTool = listSourcesTool
5462
export const ashbyListUsersTool = listUsersTool
5563
export const ashbyRemoveCandidateTagTool = removeCandidateTagTool
5664
export const ashbySearchCandidatesTool = searchCandidatesTool
65+
export const ashbySetCustomFieldValueTool = setCustomFieldValueTool
66+
export const ashbySetCustomFieldValuesTool = setCustomFieldValuesTool
5767
export const ashbyUpdateCandidateTool = updateCandidateTool
5868

5969
export * from './types'
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { AshbyCustomField } from '@/tools/ashby/types'
2+
import {
3+
ashbyAuthHeaders,
4+
ashbyErrorMessage,
5+
CUSTOM_FIELD_ON_OBJECT_OUTPUT,
6+
mapCustomFieldOnObject,
7+
} from '@/tools/ashby/utils'
8+
import type { ToolConfig, ToolResponse } from '@/tools/types'
9+
10+
interface AshbySetCustomFieldValueParams {
11+
apiKey: string
12+
objectId: string
13+
objectType: string
14+
fieldId: string
15+
fieldValue: unknown
16+
}
17+
18+
interface AshbySetCustomFieldValueResponse extends ToolResponse {
19+
output: {
20+
customField: AshbyCustomField
21+
}
22+
}
23+
24+
export const setCustomFieldValueTool: ToolConfig<
25+
AshbySetCustomFieldValueParams,
26+
AshbySetCustomFieldValueResponse
27+
> = {
28+
id: 'ashby_set_custom_field_value',
29+
name: 'Ashby Set Custom Field Value',
30+
description:
31+
'Sets the value of a single custom field on an Ashby Application, Candidate, Job, or Opening. Custom fields are the only way to annotate a job or req, since Ashby has no job notes and no job tags. Requires the candidatesWrite permission.',
32+
version: '1.0.0',
33+
34+
params: {
35+
apiKey: {
36+
type: 'string',
37+
required: true,
38+
visibility: 'user-only',
39+
description: 'Ashby API Key',
40+
},
41+
objectId: {
42+
type: 'string',
43+
required: true,
44+
visibility: 'user-or-llm',
45+
description:
46+
'UUID of the object to set the field on (application, candidate, job, or opening)',
47+
},
48+
objectType: {
49+
type: 'string',
50+
required: true,
51+
visibility: 'user-or-llm',
52+
description: 'Type of the object: Application, Candidate, Job, or Opening',
53+
},
54+
fieldId: {
55+
type: 'string',
56+
required: true,
57+
visibility: 'user-or-llm',
58+
description:
59+
'UUID of the custom field definition to set, as returned by List Custom Fields. This is the field definition ID, not the ID of a value already on the object.',
60+
},
61+
/**
62+
* Not marked required even though Ashby always expects the key: the shared
63+
* post-merge validator rejects a required `user-or-llm` param whose value is
64+
* `null`, and `null` is exactly how a custom field is cleared. The block
65+
* keeps its own required marker on the subblock, so a blank field is still
66+
* caught in the editor. See `validateRequiredParametersAfterMerge`.
67+
*/
68+
fieldValue: {
69+
type: 'json',
70+
required: false,
71+
visibility: 'user-or-llm',
72+
description:
73+
'Value to write, matching the field type: boolean, number, string (String, LongText, Date, Url, or a ValueSelect option), string array (MultiValueSelect), or an object for Currency ({value, currencyCode}), NumberRange ({type, minValue, maxValue}), CompensationRange, and Location ({country, region, city}). Pass null to clear the value, which makes the annotation reversible.',
74+
},
75+
},
76+
77+
request: {
78+
url: 'https://api.ashbyhq.com/customField.setValue',
79+
method: 'POST',
80+
headers: (params) => ashbyAuthHeaders(params.apiKey),
81+
body: (params) => ({
82+
objectId: params.objectId,
83+
objectType: params.objectType,
84+
fieldId: params.fieldId,
85+
fieldValue: params.fieldValue ?? null,
86+
}),
87+
},
88+
89+
transformResponse: async (response: Response) => {
90+
const data = await response.json()
91+
92+
if (!data.success) {
93+
throw new Error(ashbyErrorMessage(data, 'Failed to set custom field value'))
94+
}
95+
96+
return {
97+
success: true,
98+
output: {
99+
customField: mapCustomFieldOnObject(data.results),
100+
},
101+
}
102+
},
103+
104+
outputs: {
105+
customField: {
106+
type: 'object',
107+
description: 'The custom field as stored on the object after the write',
108+
properties: CUSTOM_FIELD_ON_OBJECT_OUTPUT,
109+
},
110+
},
111+
}

0 commit comments

Comments
 (0)