Skip to content

Commit 98bab16

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. Because null clears, every one of these operations requires explicit intent before it can destroy data. The block's required markers do not cover the agent path - a model calls the tool directly, so tools.config.params never runs and validateRequiredParametersAfterMerge skips a param marked not-required: - set_custom_field_value rejects an absent or blank fieldValue; an explicit null still clears - change_application_source requires unsetSource to clear, and rejects a source id and an unset request together, since preferring either one silently discards the other. Ashby has no 'leave unchanged' mode, so setting and clearing are the only two intents and exactly one must be expressed - set_custom_field_values rejects an empty array locally rather than relying on Ashby to reject it application.delete needs candidatesDelete, a module permission separate from candidatesWrite. candidate.anonymize strips PII but leaves the record; Ashby exposes no candidate deletion endpoint.
1 parent 7f987ce commit 98bab16

10 files changed

Lines changed: 500 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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
unsetSource?: boolean
15+
}
16+
17+
interface AshbyChangeApplicationSourceResponse extends ToolResponse {
18+
output: AshbyApplication
19+
}
20+
21+
export const changeApplicationSourceTool: ToolConfig<
22+
AshbyChangeApplicationSourceParams,
23+
AshbyChangeApplicationSourceResponse
24+
> = {
25+
id: 'ashby_change_application_source',
26+
name: 'Ashby Change Application Source',
27+
description:
28+
'Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.',
29+
version: '1.0.0',
30+
31+
params: {
32+
apiKey: {
33+
type: 'string',
34+
required: true,
35+
visibility: 'user-only',
36+
description: 'Ashby API Key',
37+
},
38+
applicationId: {
39+
type: 'string',
40+
required: true,
41+
visibility: 'user-or-llm',
42+
description: 'UUID of the application whose source should change',
43+
},
44+
sourceId: {
45+
type: 'string',
46+
required: false,
47+
visibility: 'user-or-llm',
48+
description:
49+
'UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true.',
50+
},
51+
unsetSource: {
52+
type: 'boolean',
53+
required: false,
54+
visibility: 'user-or-llm',
55+
description:
56+
'Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident.',
57+
},
58+
},
59+
60+
request: {
61+
url: 'https://api.ashbyhq.com/application.changeSource',
62+
method: 'POST',
63+
headers: (params) => ashbyAuthHeaders(params.apiKey),
64+
/**
65+
* Ashby requires `sourceId` to be present even when unsetting, so it always
66+
* serializes - but the value has to say what the caller actually meant. The
67+
* endpoint has no "leave unchanged" mode, so setting and clearing are the
68+
* only two intents, and exactly one of them must be expressed:
69+
*
70+
* - neither given: an omitted sourceId would otherwise wipe attribution on a
71+
* real application and still report success
72+
* - both given: preferring either one silently discards the other, which is
73+
* how "clear this" turns into a set nobody asked for
74+
*
75+
* Both are caller errors rather than something to resolve by precedence.
76+
*/
77+
body: (params) => {
78+
const sourceId = params.sourceId?.trim()
79+
if (sourceId && params.unsetSource) {
80+
throw new Error(
81+
'Ashby source ID and unsetSource are mutually exclusive. Provide a source ID to attribute the application, or set unsetSource on its own to clear it.'
82+
)
83+
}
84+
if (!sourceId && !params.unsetSource) {
85+
throw new Error(
86+
'Ashby source ID is required. Set unsetSource to true to deliberately clear the application source.'
87+
)
88+
}
89+
return {
90+
applicationId: params.applicationId.trim(),
91+
sourceId: sourceId ? sourceId : null,
92+
}
93+
},
94+
},
95+
96+
transformResponse: async (response: Response) => {
97+
const data = await response.json()
98+
99+
if (!data.success) {
100+
throw new Error(ashbyErrorMessage(data, 'Failed to change application source'))
101+
}
102+
103+
return {
104+
success: true,
105+
output: mapApplication(data.results),
106+
}
107+
},
108+
109+
outputs: APPLICATION_OUTPUTS,
110+
}
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'

0 commit comments

Comments
 (0)