feat(ashby): incremental job sync, custom field writes, and application lifecycle ops (#6703)

* feat(tools): add incremental job sync and draft postings to Ashby reads

list_jobs accepts Ashby's syncToken and returns it as nextSyncCursor, so a
scheduled sync costs O(changed reqs) instead of rescanning every req. Ashby only
returns the token once the last page is drained, which the param description
states.

The output is named as a cursor deliberately. It is an opaque resumption marker,
not a credential, so it belongs with nextCursor - and a field literally named
syncToken matches the /^.*token$/i deny-list in redaction and renders as
[REDACTED], which makes an incremental sync unusable since the operator cannot
read the value the next run needs. The wire name stays syncToken.

list_job_postings gains includeUnpublishedJobPostings, plus the posting status
field - without status a caller cannot tell a returned draft from a published
posting, which makes the flag useless.

Also widens the custom field valueLabel type, which MultiValueSelect returns as
an array, for the write operations that follow.

* fix(tools): render Ashby object-shaped API errors readably

Ashby documents two error shapes and uses both. The `errors` array form carries
`{ message, parameter }` objects, which stringified to '[object Object]' and hid
the real cause - including the 403 a key gets when it lacks a module permission.

Also adds the shared pieces the new write operations need: one definition of the
custom field value shape for the read and write paths to agree on, and a
normalizer for Ashby's case-sensitive objectType enum so a model emitting
'candidate' fails here with the allowed values rather than at the API.

* 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.

* test(tools): cover the new Ashby request and response shapes

Includes a gated live harness (ASHBY_LIVE=1) alongside the mocked tests.
vitest.setup.ts stubs global fetch for every file in the app, so the live file
restores the real implementation and asserts the restore worked - without that
guard the whole suite silently passes against a mock.

* feat(blocks): expose the new Ashby operations in the block

fieldValue is polymorphic (boolean, number, string, array, object, null), so it
decodes structured input and otherwise passes text through. The decoding is
deliberately narrow rather than a blanket JSON.parse, which corrupts real text:
1e999 becomes Infinity and serializes back out as null, which CLEARS the field;
a long numeric id loses precision past 2^53; and prose starting with { turns into
an object. Only the literal keywords, {, [ or " prefixes, and exactly
round-tripping numbers decode.

fieldValue carries no wand generationType: json-object forces braces and
json-array forces brackets, and both would wrap a value that must stay bare.
fieldValues, whose contract really is an array, uses json-array.

Setting and clearing an application source are mutually exclusive, so the Source
ID field is conditioned off while the clear switch is on and the params mapping
sends only the intent the switch selects. A value typed before the switch was
flipped cannot reach the tool and surface as an error with no visible cause.

* docs(ashby): document the new operations, permissions, and limitations

Ashby scopes permissions per module and they fail at runtime, not build time, so
the block docs now carry the permission table. Also records the hard API limits
worth designing around: no note or tag on a job, no pagination on
jobPosting.list, and no delete for jobs, candidates, or custom field definitions.

* fix(blocks): stop a stale create-path source id leaking into a source change

The executor merges { ...inputs, ...transformedParams }, so any key the params
mapping leaves unset inherits whatever inputs held. The shared create-path
sourceId subblock reaches inputs even on change_application_source: it is mode
'advanced', and the serializer includes an advanced subblock whenever its value
is non-empty without ever evaluating its condition (serializer/index.ts).

So a source id typed while on Create Application survived into a source change.
With both fields blank it silently attributed a source nobody asked for, and
with the clear switch on it collided with the unset request and failed with no
visible cause, because the field producing it is hidden in that state.

sourceId is now always assigned for this operation rather than conditionally,
so it can never inherit. The regression test asserts the merged result rather
than the mapping alone, since the gap between them is where the bug lived.
This commit is contained in:
mzxchandra
2026-08-14 14:37:23 -07:00
committed by GitHub
parent 3d4e3d26dd
commit 5a88ce22d1
21 changed files with 2326 additions and 43 deletions
+272 -12
View File
@@ -21,16 +21,51 @@ With Ashby, you can:
- **Add notes to candidates**: Attach notes to candidate records to capture feedback, context, or follow-up items
- **List and view jobs**: Browse all open, closed, and archived job postings with location and department info
- **List applications**: View all applications across your organization with candidate and job details, status tracking, and pagination
- **Sync jobs incrementally**: Pass the sync token from a previous List Jobs run to fetch only the reqs that changed, instead of rescanning every req on each run
- **Annotate jobs and reqs**: Set custom field values on a job, application, candidate, or opening, one field at a time or several at once
- **Delete and anonymize**: Remove an application, or strip personal information from a candidate
The Ashby block also supports **webhook triggers** that automatically start workflows in response to Ashby events. Available triggers include Application Submitted, Candidate Stage Change, Candidate Hired, Candidate Deleted, Job Created, and Offer Created. Webhooks are fully managed — Sim automatically creates the webhook in Ashby when you save the trigger and deletes it when you remove it, so there's no manual webhook configuration needed. Just provide your Ashby API key (with `apiKeysWrite` permission) and select the event type.
In Sim, the Ashby integration enables your agents to programmatically manage your recruiting pipeline. Agents can search for candidates, create new candidate records, add notes after interviews, and monitor applications across jobs. This allows you to automate recruiting workflows like candidate intake, interview follow-ups, pipeline reporting, and cross-referencing candidates across roles.
### API key permissions
Ashby grants permissions per module rather than per endpoint, and a missing permission fails at **runtime**, not when you build the workflow.
A key without the right scope returns a 403 in the middle of a run, so check the key before scheduling anything against it.
| Permission | Covers |
| --- | --- |
| `jobsRead` | List Jobs, Get Job, List Job Postings, Get Job Posting |
| `candidatesRead` | Application and candidate reads |
| `candidatesWrite` | Create Candidate, Create Application, Set Custom Field Value, Set Custom Field Values, Change Application Source, Anonymize Candidate |
| `candidatesDelete` | Delete Application. This is separate from `candidatesWrite` - a read and write key returns 403 here |
| `apiKeysWrite` | Managed webhook triggers |
`candidatesDelete` is a module permission, not an endpoint one, and Delete Application sits under the Candidates module.
Two more settings on the API key are checkboxes rather than module scopes, and both are easy to miss:
- **Allow access to confidential jobs and projects.** Without it, confidential reqs and the candidates on them are invisible to the key entirely - they do not appear in List Jobs at all, rather than appearing with fields hidden.
- **Allow access to non-offer private fields.** This gates custom fields marked private outside of offers.
Every job returned by List Jobs carries a `confidential` flag, so you can filter confidential reqs out at sync time before they reach a prompt or a downstream surface.
### Ashby API limitations
These are constraints of the Ashby API itself, not of the Sim block:
- **No notes or tags on a job or req.** Both are candidate-scoped in Ashby. Custom field values are the only way to annotate a job, which is what Set Custom Field Value is for. Writing `null` clears a value, so the annotation is reversible.
- **No pagination on List Job Postings.** The endpoint returns every posting in one response, with no cursor, page limit, or sync token. Paginate through List Jobs instead when you need to page.
- **No way to delete, archive, or update a custom field definition.** Creating one is irreversible, so never create throwaway field definitions against a shared organization.
- **No note deletion endpoint.** Notes can be created and listed but never removed.
- **No candidate deletion endpoint.** Anonymize Candidate strips personal information but leaves the record and its applications in place. Real deletion is available only in the Ashby UI, is limited to a 10-day window, and is restricted to certain roles.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).
Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag, anonymize), applications (list, get, create, delete, change stage, change source), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), custom field values (set one or many), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).
@@ -59,7 +94,8 @@ Adds a tag to a candidate in Ashby and returns the updated candidate.
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -80,9 +116,113 @@ Adds a tag to a candidate in Ashby and returns the updated candidate.
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Anonymize Candidate
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.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `candidateId` | string | Yes | UUID of the candidate to anonymize |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) |
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) |
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
| `id` | string | Resource UUID |
| `name` | string | Resource name |
| `title` | string | Job title or job posting title |
| `status` | string | Status |
| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) |
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Change Application Source
Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `applicationId` | string | Yes | UUID of the application whose source should change |
| `sourceId` | string | No | UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true. |
| `unsetSource` | boolean | No | Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) |
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) |
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
| `id` | string | Resource UUID |
| `name` | string | Resource name |
| `title` | string | Job title or job posting title |
| `status` | string | Status |
| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) |
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Change Application Stage
@@ -108,7 +248,8 @@ Moves an application to a different interview stage. Requires an archive reason
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -129,9 +270,11 @@ Moves an application to a different interview stage. Requires an archive reason
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Create Application
@@ -161,7 +304,8 @@ Creates a new application for a candidate on a job. Optionally specify interview
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -182,9 +326,11 @@ Creates a new application for a candidate on a job. Optionally specify interview
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Create Candidate
@@ -217,7 +363,8 @@ Creates a new candidate record in Ashby.
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -238,9 +385,11 @@ Creates a new candidate record in Ashby.
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Create Note
@@ -272,6 +421,23 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo
| ↳ `lastName` | string | Author last name |
| ↳ `email` | string | Author email |
### Ashby Delete Application
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.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `applicationId` | string | Yes | UUID of the application to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `applicationId` | string | UUID of the deleted application |
### Ashby Get Application
Retrieves full details about a single application by its ID.
@@ -294,7 +460,8 @@ Retrieves full details about a single application by its ID.
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -315,9 +482,11 @@ Retrieves full details about a single application by its ID.
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Get Candidate
@@ -341,7 +510,8 @@ Retrieves full details about a single candidate by their ID.
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -362,9 +532,11 @@ Retrieves full details about a single candidate by their ID.
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Get Job
@@ -388,7 +560,8 @@ Retrieves full details about a single job by its ID.
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -409,9 +582,11 @@ Retrieves full details about a single job by its ID.
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Get Job Posting
@@ -506,7 +681,8 @@ Retrieves full details about a single offer by its ID.
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -527,9 +703,11 @@ Retrieves full details about a single offer by its ID.
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby List Applications
@@ -741,6 +919,7 @@ Lists all job postings in Ashby.
| `location` | string | No | Filter by location name \(case sensitive\) |
| `department` | string | No | Filter by department name \(case sensitive\) |
| `listedOnly` | boolean | No | When true, only returns listed \(publicly visible\) job postings \(default false\) |
| `includeUnpublishedJobPostings` | boolean | No | When true, also returns unpublished \(Draft\) job postings. The endpoint already returns both listed and unlisted published postings by default, so this only adds drafts. |
| `jobBoardId` | string | No | UUID of a specific job board to filter postings to. If omitted, returns postings on the primary external job board. |
#### Output
@@ -759,6 +938,7 @@ Lists all job postings in Ashby.
| ↳ `secondaryLocationIds` | array | Secondary location UUIDs |
| ↳ `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) |
| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
| ↳ `status` | string | Posting status \(Draft or Published\) |
| ↳ `isListed` | boolean | Whether the posting is publicly listed |
| ↳ `publishedDate` | string | ISO 8601 published date |
| ↳ `applicationDeadline` | string | ISO 8601 application deadline |
@@ -778,7 +958,8 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
| `perPage` | number | No | Number of results per page \(default 100\) |
| `perPage` | number | No | Number of results per page \(default and max 100\). Ashby silently caps larger values rather than erroring. |
| `syncToken` | string | No | Opaque token from a prior sync to fetch only jobs changed since then. Ashby only returns a new syncToken on the last page, so drain moreDataAvailable/nextCursor before persisting it. |
| `status` | string | No | Filter by job status: Open, Closed, Archived, or Draft |
| `createdAfter` | string | No | Only return jobs created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) |
| `openedAfter` | string | No | Only return jobs opened after this ISO 8601 timestamp |
@@ -793,6 +974,7 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
| `jobs` | array | List of jobs |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental run, returned only once the last page is drained. Named as a cursor because that is what it is - an opaque resumption marker, not a credential - so it stays readable in block output alongside nextCursor. |
### Ashby List Locations
@@ -974,7 +1156,8 @@ Removes a tag from a candidate in Ashby and returns the updated candidate.
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -995,9 +1178,11 @@ Removes a tag from a candidate in Ashby and returns the updated candidate.
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Search Candidates
@@ -1017,6 +1202,78 @@ Searches for candidates by name and/or email with AND logic. Results are limited
| --------- | ---- | ----------- |
| `candidates` | array | Matching candidates \(max 100 results\) |
### Ashby Set Custom Field Value
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.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `objectId` | string | Yes | UUID of the object to set the field on \(application, candidate, job, or opening\) |
| `objectType` | string | Yes | Type of the object: Application, Candidate, Job, or Opening |
| `fieldId` | string | Yes | 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. |
| `fieldValue` | json | No | 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. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `customField` | object | The custom field as stored on the object after the write |
### Ashby Set Custom Field Values
Sets several custom field values on one Ashby Application, Candidate, Job, or Opening in a single call. Prefer this over repeated single-field writes to the same object - Ashby recommends it because concurrent single-field calls can race and overwrite each other. Requires the candidatesWrite permission.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `objectId` | string | Yes | UUID of the object to set the fields on \(application, candidate, job, or opening\) |
| `objectType` | string | Yes | Type of the object: Application, Candidate, Job, or Opening |
| `values` | json | Yes | Array of at least one \{ fieldId, fieldValue \} pair. fieldId is a custom field definition UUID from List Custom Fields. fieldValue matches the field type: boolean, number, string, string array \(MultiValueSelect\), or an object for Currency, NumberRange, CompensationRange, and Location. Pass null as a fieldValue to clear that field. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | json | List of candidates with rich fields \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], linkedInUrl, githubUrl, profileUrl, position, company, school, timezone, location with locationComponents\[\], tags\[\], applicationIds\[\], customFields\[\], resumeFileHandle, fileHandles\[\], source with sourceType, creditedToUser, fraudStatus, createdAt, updatedAt\) |
| `jobs` | json | List of jobs \(id, title, confidential, status, employmentType, locationId, departmentId, defaultInterviewPlanId, interviewPlanIds\[\], customFields\[\], jobPostingIds\[\], customRequisitionId, brandId, hiringTeam\[\], author, createdAt, updatedAt, openedAt, closedAt, location with address, openings\[\] with latestVersion\) |
| `applications` | json | List of applications \(id, status, customFields\[\], candidate summary, currentInterviewStage, source with sourceType, archiveReason with customFields\[\], archivedAt, job summary, creditedToUser, hiringTeam\[\], appliedViaJobPostingId, submitterClientIp, submitterUserAgent, createdAt, updatedAt\) |
| `notes` | json | List of notes \(id, content, author, isPrivate, createdAt\) |
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
| `openings` | json | List of openings \(id, openedAt, closedAt, isArchived, archivedAt, closeReasonId, openingState, latestVersion with identifier/description/authorId/createdAt/teamId/jobIds\[\]/targetHireDate/targetStartDate/isBackfill/employmentType/locationIds\[\]/hiringTeam\[\]/customFields\[\]\) |
| `users` | json | List of users \(id, firstName, lastName, email, globalRole, isEnabled, updatedAt\) |
| `interviewSchedules` | json | List of interview schedules \(id, applicationId, interviewStageId, interviewEvents\[\] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt\) |
| `tags` | json | List of candidate tags \(id, title, isArchived\) |
| `id` | string | Resource UUID |
| `name` | string | Resource name |
| `title` | string | Job title or job posting title |
| `status` | string | Status |
| `candidate` | json | Candidate summary \(id, name, primaryEmailAddress, primaryPhoneNumber\). For full candidate fields use the candidates list output or the get/create/update candidate operations. |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], createdAt, updatedAt\) |
| `application` | json | Application details \(id, status, customFields\[\], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam\[\], createdAt, updatedAt\) |
| `offer` | json | Offer details \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion\) |
| `jobPosting` | json | Job posting details \(id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy\[\], jobId, locationName, locationIds, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt, job \[included when expandJob=true\]\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
### Ashby Update Candidate
Updates an existing candidate record in Ashby. Only provided fields are changed.
@@ -1051,7 +1308,8 @@ Updates an existing candidate record in Ashby. Only provided fields are changed.
| `offers` | json | List of offers \(id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields\[\]/fileHandles\[\]/author/approvalStatus\) |
| `archiveReasons` | json | List of archive reasons \(id, text, reasonType \[RejectedByCandidate/RejectedByOrg/Other\], isArchived\) |
| `sources` | json | List of sources \(id, title, isArchived, sourceType \{id, title, isArchived\}\) |
| `customFields` | json | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `customFields` | json | For List Custom Fields, the field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\). For Set Custom Field Values, the field values written to the object \(id, title, isPrivate, valueLabel, value\) |
| `customField` | json | A single custom field value after a write \(id, title, isPrivate, valueLabel, value\) |
| `departments` | json | List of departments \(id, name, externalName, isArchived, parentId, createdAt, updatedAt\) |
| `locations` | json | List of locations \(id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress\) |
| `jobPostings` | json | List of job postings \(id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt\) |
@@ -1072,9 +1330,11 @@ Updates an existing candidate record in Ashby. Only provided fields are changed.
| `author` | json | Note author \(id, firstName, lastName, email\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `applicationId` | string | UUID of the deleted application |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
| `nextSyncCursor` | string | Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output |
+246
View File
@@ -78,6 +78,252 @@ describe('AshbyBlock', () => {
expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object')
expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object')
})
it('does not force braces or brackets on the polymorphic fieldValue', () => {
// fieldValue legitimately takes a bare boolean, number, string, or null,
// so neither the 'json-object' nor the 'json-array' reinforcement applies -
// both would make the wand emit a wrapper the field must not receive.
const fieldValue = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValue')
expect(fieldValue?.wandConfig?.enabled).toBe(true)
expect(fieldValue?.wandConfig?.generationType).toBeUndefined()
})
it('requests array output for fieldValues, whose contract is a JSON array', () => {
const fieldValues = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValues')
expect(fieldValues?.wandConfig?.generationType).toBe('json-array')
})
})
describe('fieldValue parsing (set_custom_field_value)', () => {
const parse = (fieldValue: unknown) =>
AshbyBlock.tools.config.params!(buildParams('set_custom_field_value', { fieldValue }))
.fieldValue
it('decodes null so the annotation can be cleared', () => {
// Ashby clears a custom field when it receives an explicit null, which is
// what makes a written annotation reversible.
expect(parse('null')).toBeNull()
})
it('decodes booleans and numbers for Boolean and Number fields', () => {
expect(parse('true')).toBe(true)
expect(parse('42')).toBe(42)
})
it('decodes a JSON array for MultiValueSelect fields', () => {
expect(parse('["Remote","Hybrid"]')).toEqual(['Remote', 'Hybrid'])
})
it('decodes a JSON object for Currency and range fields', () => {
expect(parse('{"value":150000,"currencyCode":"USD"}')).toEqual({
value: 150000,
currencyCode: 'USD',
})
})
it('passes unparseable text through as a plain string', () => {
// A bare option name is the most common input for String, LongText, and
// ValueSelect fields, so it must not be rejected as invalid JSON.
expect(parse('Senior Engineer')).toBe('Senior Engineer')
})
it('decodes a quoted numeric string back to a string', () => {
// The escape hatch for a String field whose value looks like a number.
expect(parse('"123"')).toBe('123')
})
it('does not let an overflowing number become a field clear', () => {
// 1e999 parses to Infinity, which JSON.stringify emits as null - and null
// clears the field. The user typed a number, not a clear.
expect(parse('1e999')).toBe('1e999')
})
it('does not silently lose precision on long numeric ids', () => {
expect(parse('12345678901234567890')).toBe('12345678901234567890')
expect(parse('0123')).toBe('0123')
})
it('leaves prose that merely starts like JSON alone when it does not parse', () => {
expect(parse('{not really json')).toBe('{not really json')
})
it('passes an already-parsed value through untouched', () => {
// An upstream block reference resolves to a real value, not to text.
expect(parse({ value: 1 })).toEqual({ value: 1 })
expect(parse(false)).toBe(false)
})
it('leaves fieldValue alone for other operations', () => {
const result = AshbyBlock.tools.config.params!(
buildParams('list_jobs', { fieldValue: 'Senior Engineer' })
)
expect(result.fieldValue).toBeUndefined()
})
})
describe('fieldValues parsing (set_custom_field_values)', () => {
it('maps the fieldValues subBlock onto the tool’s values param', () => {
const result = AshbyBlock.tools.config.params!(
buildParams('set_custom_field_values', {
fieldValues: '[{"fieldId":"abc","fieldValue":"High"}]',
})
)
expect(result.values).toEqual([{ fieldId: 'abc', fieldValue: 'High' }])
expect(result.fieldValues).toBeUndefined()
})
it('throws instead of silently dropping the writes when the JSON is malformed', () => {
expect(() =>
AshbyBlock.tools.config.params!(
buildParams('set_custom_field_values', { fieldValues: 'not json' })
)
).toThrow(/Invalid JSON in Ashby custom field values/)
})
it('throws when the parsed JSON is not an array', () => {
expect(() =>
AshbyBlock.tools.config.params!(
buildParams('set_custom_field_values', { fieldValues: '{"fieldId":"abc"}' })
)
).toThrow(/expected a JSON array/)
})
})
describe('change_application_source', () => {
it('emits sourceId as undefined when the field is left blank', () => {
// The key must be PRESENT and undefined, not absent. The executor merges
// `{ ...inputs, ...transformedParams }`, so an absent key inherits whatever
// inputs held - which is exactly how a stale create-path sourceId used to
// leak in. Presence is what overrides it.
const result = AshbyBlock.tools.config.params!(
buildParams('change_application_source', { applicationId: 'app-1', changeSourceId: '' })
)
expect(result).toHaveProperty('sourceId')
expect(result.sourceId).toBeUndefined()
expect(result).not.toHaveProperty('unsetSource')
})
it('passes unsetSource through only when the switch is on', () => {
const result = AshbyBlock.tools.config.params!(
buildParams('change_application_source', { changeSourceId: '', unsetSource: 'true' })
)
expect(result.unsetSource).toBe(true)
})
it('never sends a stale source id alongside a clear request', () => {
// The Source ID field is hidden once the clear switch is on, but a value
// typed beforehand is still stored. Sending both would trip the tool's
// exclusivity guard and surface as an error the user cannot see the cause of.
const result = AshbyBlock.tools.config.params!(
buildParams('change_application_source', {
changeSourceId: 'src-left-over',
unsetSource: 'true',
})
)
expect(result.unsetSource).toBe(true)
expect(result).toHaveProperty('sourceId')
expect(result.sourceId).toBeUndefined()
})
it('never inherits a stale create-path source id through the executor merge', () => {
// The executor runs `{ ...inputs, ...transformedParams }`, so any key this
// mapping leaves unset inherits whatever inputs held. The shared
// create-path `sourceId` subblock reaches inputs even on this operation:
// it is mode 'advanced', and the serializer includes an advanced subblock
// on a non-empty value without evaluating its condition. Assert the merged
// result, not just the mapping, since that gap is where the bug lived.
const merge = (inputs: Record<string, unknown>) => ({
...inputs,
...AshbyBlock.tools.config.params!(inputs),
})
const cleared = merge(
buildParams('change_application_source', {
applicationId: 'app-1',
sourceId: 'stale-from-create-application',
changeSourceId: '',
unsetSource: 'true',
})
)
expect(cleared.sourceId).toBeUndefined()
expect(cleared.unsetSource).toBe(true)
const untouched = merge(
buildParams('change_application_source', {
applicationId: 'app-1',
sourceId: 'stale-from-create-application',
changeSourceId: '',
})
)
expect(untouched.sourceId).toBeUndefined()
const explicit = merge(
buildParams('change_application_source', {
applicationId: 'app-1',
sourceId: 'stale-from-create-application',
changeSourceId: 'src-intended',
})
)
expect(explicit.sourceId).toBe('src-intended')
})
it('hides the source id field while the clear switch is on', () => {
const sourceField = AshbyBlock.subBlocks.find((s) => s.id === 'changeSourceId')
const condition = sourceField?.condition as { and?: { field: string; not?: boolean } }
expect(condition.and).toEqual({ field: 'unsetSource', value: true, not: true })
})
it('maps a provided source id onto sourceId', () => {
const result = AshbyBlock.tools.config.params!(
buildParams('change_application_source', { changeSourceId: 'src-1' })
)
expect(result.sourceId).toBe('src-1')
})
it('does not emit a null sourceId for other operations', () => {
// create_candidate treats an absent source as "no source", so a null here
// would turn an omitted optional field into an explicit write.
const result = AshbyBlock.tools.config.params!(buildParams('create_candidate', {}))
expect(result).not.toHaveProperty('sourceId')
})
})
describe('operation and tool registration stay in sync', () => {
it('has a matching ashby_<operation> tool in access for every dropdown option', () => {
// tools.config.tool is a bare `ashby_${operation}` concat, so a dropdown
// option without a matching tool id resolves to a tool that does not exist.
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id)
const access = new Set(AshbyBlock.tools.access)
const missing = optionIds.filter((id) => !access.has(`ashby_${id}`))
expect(missing).toEqual([])
})
it('has a dropdown option for every tool listed in access', () => {
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
const optionIds = new Set(
(operation?.options as Array<{ id: string }>).map((o) => `ashby_${o.id}`)
)
const unreachable = AshbyBlock.tools.access!.filter((id) => !optionIds.has(id))
expect(unreachable).toEqual([])
})
it('has a canvas sentence for every dropdown option', () => {
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id)
const sentences = AshbyBlock.canvasPresentation?.sentences?.byOperation ?? {}
const missing = optionIds.filter((id) => !(id in sentences))
expect(missing).toEqual([])
})
})
describe('list_jobs incremental sync', () => {
it('offers the syncToken field on list_jobs', () => {
// Without a sync token every scheduled run rescans the full req set.
const syncToken = AshbyBlock.subBlocks.find((s) => s.id === 'syncToken')
const condition = syncToken?.condition as { value: string[] }
expect(condition.value).toContain('list_jobs')
})
})
describe('list_applications candidateId filter', () => {
+299 -4
View File
@@ -39,12 +39,78 @@ function parseSocialLinksInput(value: unknown): Array<{ type: string; url: strin
return parsed
}
/**
* Parses an Ashby custom field value from the block input. Ashby custom fields
* are polymorphic, so structured input is decoded to give Currency, NumberRange,
* MultiValueSelect, Boolean, Number, and cleared fields the right wire type,
* while everything else passes through as a plain string, which String,
* LongText, Date, Url, and ValueSelect fields accept.
*
* Decoding is deliberately narrow rather than a blanket `JSON.parse`. Parsing
* every string that happens to be valid JSON corrupts real text: `1e999` becomes
* Infinity and serializes back out as `null`, which CLEARS the field; a long
* numeric id loses precision past 2^53; and pasted prose that starts with `{`
* turns into an object. So only these forms decode:
*
* - `null`, `true`, `false` - the literal keywords
* - text starting with `{`, `[`, or `"` - objects, arrays, and quoted strings
* - numbers that survive a round trip exactly, which excludes Infinity,
* precision loss, and leading zeros
*
* The remaining trade-off is that the text `123` becomes the number 123. A field
* that needs the literal string can be quoted (`"123"`), which decodes back to it.
*/
function parseCustomFieldValueInput(value: unknown): unknown {
if (typeof value !== 'string') return value
const trimmed = value.trim()
if (!trimmed) return value
if (trimmed === 'null') return null
if (trimmed === 'true') return true
if (trimmed === 'false') return false
const first = trimmed[0]
if (first === '{' || first === '[' || first === '"') {
try {
return JSON.parse(trimmed)
} catch {
return value
}
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const asNumber = Number(trimmed)
if (Number.isFinite(asNumber) && String(asNumber) === trimmed) return asNumber
}
return value
}
function parseCustomFieldValuesInput(value: unknown): unknown[] {
if (Array.isArray(value)) return value
if (typeof value !== 'string' || !value.trim()) return []
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch (error) {
throw new Error(
`Invalid JSON in Ashby custom field values: ${getErrorMessage(error)}. Expected a JSON array like [{"fieldId":"<uuid>","fieldValue":"High"}].`
)
}
if (!Array.isArray(parsed)) {
throw new Error(
'Invalid Ashby custom field values: expected a JSON array like [{"fieldId":"<uuid>","fieldValue":"High"}].'
)
}
return parsed
}
export const AshbyBlock: BlockConfig = {
type: 'ashby',
name: 'Ashby',
description: 'Manage candidates, jobs, and applications in Ashby',
longDescription:
'Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).',
'Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag, anonymize), applications (list, get, create, delete, change stage, change source), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), custom field values (set one or many), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).',
docsLink: 'https://docs.sim.ai/integrations/ashby',
category: 'tools',
integrationType: IntegrationType.HR,
@@ -101,11 +167,17 @@ export const AshbyBlock: BlockConfig = {
{ text: ', for application', field: 'offerApplicationId' },
{ text: ', created after', field: 'createdAfter' },
],
delete_application: [{ text: 'Delete application', field: 'applicationId', core: true }],
change_application_stage: [
{ text: 'Move application', field: 'applicationId', core: true },
{ text: 'to stage', field: 'interviewStageId' },
{ text: ', with archive reason', field: 'archiveReasonId' },
],
change_application_source: [
{ text: 'Attribute application', field: 'applicationId', core: true },
{ text: 'to source', field: 'changeSourceId' },
],
anonymize_candidate: [{ text: 'Anonymize candidate', field: 'candidateId', core: true }],
add_candidate_tag: [
{ text: 'Add tag', field: 'tagId', core: true },
{ text: 'to candidate', field: 'candidateId', core: true },
@@ -119,6 +191,15 @@ export const AshbyBlock: BlockConfig = {
list_candidate_tags: ['List candidate tags'],
list_archive_reasons: ['List archive reasons'],
list_custom_fields: ['List custom field definitions'],
set_custom_field_value: [
{ text: 'Set custom field', field: 'fieldId', core: true },
{ text: 'on', field: 'objectType' },
{ text: 'to', field: 'fieldValue' },
],
set_custom_field_values: [
{ text: 'Set custom fields on', field: 'objectType', core: true },
{ text: 'record', field: 'objectId', core: true },
],
list_departments: ['List departments'],
list_locations: ['List locations'],
list_job_postings: [
@@ -169,8 +250,11 @@ export const AshbyBlock: BlockConfig = {
{ label: 'List Applications', id: 'list_applications' },
{ label: 'Get Application', id: 'get_application' },
{ label: 'Create Application', id: 'create_application' },
{ label: 'Delete Application', id: 'delete_application' },
{ label: 'List Offers', id: 'list_offers' },
{ label: 'Change Application Stage', id: 'change_application_stage' },
{ label: 'Change Application Source', id: 'change_application_source' },
{ label: 'Anonymize Candidate', id: 'anonymize_candidate' },
{ label: 'Add Candidate Tag', id: 'add_candidate_tag' },
{ label: 'Remove Candidate Tag', id: 'remove_candidate_tag' },
{ label: 'Get Offer', id: 'get_offer' },
@@ -178,6 +262,8 @@ export const AshbyBlock: BlockConfig = {
{ label: 'List Candidate Tags', id: 'list_candidate_tags' },
{ label: 'List Archive Reasons', id: 'list_archive_reasons' },
{ label: 'List Custom Fields', id: 'list_custom_fields' },
{ label: 'Set Custom Field Value', id: 'set_custom_field_value' },
{ label: 'Set Custom Field Values', id: 'set_custom_field_values' },
{ label: 'List Departments', id: 'list_departments' },
{ label: 'List Locations', id: 'list_locations' },
{ label: 'List Job Postings', id: 'list_job_postings' },
@@ -209,6 +295,7 @@ export const AshbyBlock: BlockConfig = {
'update_candidate',
'add_candidate_tag',
'remove_candidate_tag',
'anonymize_candidate',
],
},
placeholder: 'Enter candidate UUID',
@@ -221,6 +308,7 @@ export const AshbyBlock: BlockConfig = {
'update_candidate',
'add_candidate_tag',
'remove_candidate_tag',
'anonymize_candidate',
],
},
},
@@ -352,12 +440,23 @@ Output only the ISO 8601 timestamp string, nothing else.`,
type: 'short-input',
required: {
field: 'operation',
value: ['get_application', 'change_application_stage'],
value: [
'get_application',
'change_application_stage',
'change_application_source',
'delete_application',
],
},
placeholder: 'Enter application UUID',
condition: {
field: 'operation',
value: ['get_application', 'change_application_stage', 'list_interviews'],
value: [
'get_application',
'change_application_stage',
'change_application_source',
'delete_application',
'list_interviews',
],
},
},
{
@@ -652,6 +751,7 @@ Output only the ISO 8601 timestamp string, nothing else.`,
'list_departments',
'list_custom_fields',
'list_offers',
'list_jobs',
],
},
mode: 'advanced',
@@ -756,6 +856,131 @@ Output only the JSON array, nothing else.`,
condition: { field: 'operation', value: 'list_job_postings' },
mode: 'advanced',
},
{
id: 'includeUnpublishedJobPostings',
title: 'Include Draft Postings',
type: 'switch',
condition: { field: 'operation', value: 'list_job_postings' },
mode: 'advanced',
},
{
id: 'objectType',
title: 'Object Type',
type: 'dropdown',
options: [
{ label: 'Job', id: 'Job' },
{ label: 'Application', id: 'Application' },
{ label: 'Candidate', id: 'Candidate' },
{ label: 'Opening', id: 'Opening' },
],
value: () => 'Job',
required: {
field: 'operation',
value: ['set_custom_field_value', 'set_custom_field_values'],
},
condition: {
field: 'operation',
value: ['set_custom_field_value', 'set_custom_field_values'],
},
},
{
id: 'objectId',
title: 'Object ID',
type: 'short-input',
required: {
field: 'operation',
value: ['set_custom_field_value', 'set_custom_field_values'],
},
placeholder: 'Enter the UUID of the job, application, candidate, or opening',
condition: {
field: 'operation',
value: ['set_custom_field_value', 'set_custom_field_values'],
},
},
{
id: 'fieldId',
title: 'Custom Field ID',
type: 'short-input',
required: { field: 'operation', value: 'set_custom_field_value' },
placeholder: 'Custom field definition UUID from List Custom Fields',
condition: { field: 'operation', value: 'set_custom_field_value' },
},
{
id: 'fieldValue',
title: 'Custom Field Value',
type: 'long-input',
required: { field: 'operation', value: 'set_custom_field_value' },
placeholder: 'Plain value, or JSON for structured field types. Use null to clear.',
condition: { field: 'operation', value: 'set_custom_field_value' },
wandConfig: {
enabled: true,
prompt: `Generate an Ashby custom field value matching the field's type.
Rules:
- Boolean: true or false
- Number: a bare number, e.g. 42
- String, LongText, Date, Url, ValueSelect: the plain text, e.g. Senior Engineer or 2026-03-01
- MultiValueSelect: a JSON array of option values, e.g. ["Remote","Hybrid"]
- Currency: {"value":150000,"currencyCode":"USD"}
- NumberRange: {"type":"number-range","minValue":1,"maxValue":5}
- CompensationRange: {"type":"compensation-range","minValue":120000,"maxValue":160000,"currencyCode":"USD","interval":"YEAR"}
- Location: {"country":"United States","region":"California","city":"San Francisco"}
- To clear the field, output exactly: null
Output only the value. Do not wrap it in an object or add commentary.`,
placeholder: 'Describe the value to write...',
},
},
{
id: 'fieldValues',
title: 'Custom Field Values',
type: 'code',
required: { field: 'operation', value: 'set_custom_field_values' },
placeholder: '[{ "fieldId": "<uuid>", "fieldValue": "High" }]',
condition: { field: 'operation', value: 'set_custom_field_values' },
wandConfig: {
enabled: true,
generationType: 'json-array',
prompt: `Generate a JSON array of Ashby custom field writes for one object.
Each element is {"fieldId": "<custom field definition UUID>", "fieldValue": <value>}.
fieldValue follows the field's type: boolean, number, plain string, a string array for
MultiValueSelect, an object for Currency/NumberRange/CompensationRange/Location, or null to clear.
Example:
[{"fieldId":"11111111-1111-1111-1111-111111111111","fieldValue":"High"},{"fieldId":"22222222-2222-2222-2222-222222222222","fieldValue":true}]
Output only the JSON array.`,
placeholder: 'Describe the fields to write...',
},
},
{
id: 'unsetSource',
title: 'Clear the source instead',
type: 'switch',
condition: { field: 'operation', value: 'change_application_source' },
},
{
/**
* Hidden while the clear switch is on, so the editor cannot hold a source
* ID and a clear request at the same time. The two are mutually exclusive
* intents and the tool rejects the pair rather than picking a winner.
*/
id: 'changeSourceId',
title: 'Source ID',
type: 'short-input',
required: {
field: 'operation',
value: 'change_application_source',
and: { field: 'unsetSource', value: true, not: true },
},
placeholder: 'Source UUID from List Sources',
condition: {
field: 'operation',
value: 'change_application_source',
and: { field: 'unsetSource', value: true, not: true },
},
},
{
id: 'expandJob',
title: 'Include Job',
@@ -812,10 +1037,13 @@ Output only the JSON array, nothing else.`,
tools: {
access: [
'ashby_add_candidate_tag',
'ashby_anonymize_candidate',
'ashby_change_application_source',
'ashby_change_application_stage',
'ashby_create_application',
'ashby_create_candidate',
'ashby_create_note',
'ashby_delete_application',
'ashby_get_application',
'ashby_get_candidate',
'ashby_get_job',
@@ -838,6 +1066,8 @@ Output only the JSON array, nothing else.`,
'ashby_list_users',
'ashby_remove_candidate_tag',
'ashby_search_candidates',
'ashby_set_custom_field_value',
'ashby_set_custom_field_values',
'ashby_update_candidate',
],
config: {
@@ -865,6 +1095,12 @@ Output only the JSON array, nothing else.`,
if (params.listedOnly === 'true' || params.listedOnly === true) {
result.listedOnly = true
}
if (
params.includeUnpublishedJobPostings === 'true' ||
params.includeUnpublishedJobPostings === true
) {
result.includeUnpublishedJobPostings = true
}
if (params.expandJob === 'true' || params.expandJob === true) {
result.expandJob = true
}
@@ -906,6 +1142,28 @@ Output only the JSON array, nothing else.`,
const socialLinks = parseSocialLinksInput(params.socialLinks)
if (socialLinks.length > 0) result.socialLinks = socialLinks
}
if (params.operation === 'set_custom_field_value') {
result.fieldValue = parseCustomFieldValueInput(params.fieldValue)
}
if (params.operation === 'set_custom_field_values') {
result.values = parseCustomFieldValuesInput(params.fieldValues)
}
if (params.operation === 'change_application_source') {
// sourceId is always assigned, never conditionally, because the executor
// merges `{ ...inputs, ...transformedParams }` and a key this mapping
// leaves unset simply inherits whatever was in inputs. The create-path
// `sourceId` subblock leaks into exactly that gap: it is mode
// 'advanced', and the serializer includes an advanced subblock whenever
// its value is non-empty without ever evaluating its condition, so a
// value typed while on Create Application survives into this operation.
// Inheriting it would attribute a source nobody asked for, or collide
// with a clear request and fail with no visible cause.
const unsetSource = params.unsetSource === 'true' || params.unsetSource === true
const changeSourceId =
typeof params.changeSourceId === 'string' ? params.changeSourceId.trim() : ''
result.sourceId = unsetSource || !changeSourceId ? undefined : changeSourceId
if (unsetSource) result.unsetSource = true
}
return result
},
},
@@ -981,6 +1239,32 @@ Output only the JSON array, nothing else.`,
type: 'string',
description: 'Social links as JSON array',
},
includeUnpublishedJobPostings: {
type: 'boolean',
description: 'Also return unpublished (draft) job postings',
},
objectType: {
type: 'string',
description: 'Custom field target object type (Application, Candidate, Job, or Opening)',
},
objectId: { type: 'string', description: 'UUID of the object to set custom fields on' },
fieldId: { type: 'string', description: 'Custom field definition UUID' },
fieldValue: {
type: 'string',
description: 'Custom field value (plain value, or JSON for structured types; null clears it)',
},
fieldValues: {
type: 'string',
description: 'Custom field writes as a JSON array of { fieldId, fieldValue }',
},
changeSourceId: {
type: 'string',
description: 'Source UUID to attribute an application to',
},
unsetSource: {
type: 'boolean',
description: 'Deliberately clear an application source instead of setting one',
},
},
outputs: {
@@ -1020,7 +1304,12 @@ Output only the JSON array, nothing else.`,
customFields: {
type: 'json',
description:
'List of custom field definitions (id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues[] {label, value, isArchived})',
'For List Custom Fields, the field definitions (id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues[] {label, value, isArchived}). For Set Custom Field Values, the field values written to the object (id, title, isPrivate, valueLabel, value)',
},
customField: {
type: 'json',
description:
'A single custom field value after a write (id, title, isPrivate, valueLabel, value)',
},
departments: {
type: 'json',
@@ -1092,9 +1381,15 @@ Output only the JSON array, nothing else.`,
},
isPrivate: { type: 'boolean', description: 'Whether the note is private' },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
applicationId: { type: 'string', description: 'UUID of the deleted application' },
moreDataAvailable: { type: 'boolean', description: 'Whether more pages exist' },
nextCursor: { type: 'string', description: 'Pagination cursor for next page' },
syncToken: { type: 'string', description: 'Sync token for incremental updates' },
nextSyncCursor: {
type: 'string',
description:
"Ashby's syncToken for the next incremental List Jobs run, exposed as a cursor so it stays readable in block output",
},
},
}
+22 -2
View File
@@ -1093,7 +1093,7 @@
"slug": "ashby",
"name": "Ashby",
"description": "Manage candidates, jobs, and applications in Ashby",
"longDescription": "Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag), applications (list, get, create, change stage), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).",
"longDescription": "Integrate Ashby into the workflow. Manage candidates (list, get, create, update, search, tag, anonymize), applications (list, get, create, delete, change stage, change source), jobs (list, get), job postings (list, get), offers (list, get), notes (list, create), interviews (list), custom field values (set one or many), and reference data (sources, tags, archive reasons, custom fields, departments, locations, openings, users).",
"bgColor": "#5D4ED6",
"iconName": "AshbyIcon",
"docsUrl": "https://docs.sim.ai/integrations/ashby",
@@ -1146,6 +1146,10 @@
"name": "Create Application",
"description": "Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user."
},
{
"name": "Delete Application",
"description": "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."
},
{
"name": "List Offers",
"description": "Lists all offers with their latest version in an Ashby organization."
@@ -1154,6 +1158,14 @@
"name": "Change Application Stage",
"description": "Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage."
},
{
"name": "Change Application Source",
"description": "Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission."
},
{
"name": "Anonymize Candidate",
"description": "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."
},
{
"name": "Add Candidate Tag",
"description": "Adds a tag to a candidate in Ashby and returns the updated candidate."
@@ -1182,6 +1194,14 @@
"name": "List Custom Fields",
"description": "Lists all custom field definitions configured in Ashby."
},
{
"name": "Set Custom Field Value",
"description": "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."
},
{
"name": "Set Custom Field Values",
"description": "Sets several custom field values on one Ashby Application, Candidate, Job, or Opening in a single call. Prefer this over repeated single-field writes to the same object - Ashby recommends it because concurrent single-field calls can race and overwrite each other. Requires the candidatesWrite permission."
},
{
"name": "List Departments",
"description": "Lists all departments in Ashby."
@@ -1211,7 +1231,7 @@
"description": "Lists interview schedules in Ashby, optionally filtered by application or interview stage."
}
],
"operationCount": 28,
"operationCount": 33,
"triggers": [
{
"id": "ashby_application_submit",
@@ -0,0 +1,65 @@
import type { AshbyCandidate } from '@/tools/ashby/types'
import {
ashbyAuthHeaders,
ashbyErrorMessage,
CANDIDATE_OUTPUTS,
mapCandidate,
} from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyAnonymizeCandidateParams {
apiKey: string
candidateId: string
}
interface AshbyAnonymizeCandidateResponse extends ToolResponse {
output: AshbyCandidate
}
export const anonymizeCandidateTool: ToolConfig<
AshbyAnonymizeCandidateParams,
AshbyAnonymizeCandidateResponse
> = {
id: 'ashby_anonymize_candidate',
name: 'Ashby Anonymize Candidate',
description:
'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.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
candidateId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'UUID of the candidate to anonymize',
},
},
request: {
url: 'https://api.ashbyhq.com/candidate.anonymize',
method: 'POST',
headers: (params) => ashbyAuthHeaders(params.apiKey),
body: (params) => ({ candidateId: params.candidateId.trim() }),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(ashbyErrorMessage(data, 'Failed to anonymize candidate'))
}
return {
success: true,
output: mapCandidate(data.results),
}
},
outputs: CANDIDATE_OUTPUTS,
}
+517
View File
@@ -0,0 +1,517 @@
/**
* Live verification of the Ashby connector against a real Ashby organization.
*
* Skipped unless `ASHBY_LIVE=1` and `ASHBY_API_KEY` are set, so it is inert in
* CI and for anyone without credentials. Writes are gated separately behind
* `ASHBY_LIVE_WRITES=1` because every Ashby call is a production write - there
* is no sandbox, no test mode, and no dry-run flag.
*
* Read-only: ASHBY_LIVE=1 ASHBY_API_KEY=... bunx vitest run tools/ashby/ashby.live.test.ts
* With writes: add ASHBY_LIVE_WRITES=1 ASHBY_FIXTURE_JOB_ID=<uuid>
*
* The Ashby tools have a static URL, pure `headers(params)`/`body(params)`, and
* a `transformResponse(Response)` with no postProcess or execution context, so
* driving them directly here reproduces exactly what `executeTool` does.
*
* @vitest-environment node
*/
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { anonymizeCandidateTool } from '@/tools/ashby/anonymize_candidate'
import { changeApplicationSourceTool } from '@/tools/ashby/change_application_source'
import { createApplicationTool } from '@/tools/ashby/create_application'
import { createCandidateTool } from '@/tools/ashby/create_candidate'
import { deleteApplicationTool } from '@/tools/ashby/delete_application'
import { getApplicationTool } from '@/tools/ashby/get_application'
import { getCandidateTool } from '@/tools/ashby/get_candidate'
import { listCustomFieldsTool } from '@/tools/ashby/list_custom_fields'
import { listJobPostingsTool } from '@/tools/ashby/list_job_postings'
import { listJobsTool } from '@/tools/ashby/list_jobs'
import { listSourcesTool } from '@/tools/ashby/list_sources'
import { setCustomFieldValueTool } from '@/tools/ashby/set_custom_field_value'
import { setCustomFieldValuesTool } from '@/tools/ashby/set_custom_field_values'
import type { ToolConfig } from '@/tools/types'
const LIVE = process.env.ASHBY_LIVE === '1' && Boolean(process.env.ASHBY_API_KEY)
const WRITES = LIVE && process.env.ASHBY_LIVE_WRITES === '1'
const apiKey = process.env.ASHBY_API_KEY ?? ''
/** Live API calls with pagination comfortably exceed the 10s default. */
const TIMEOUT = 120_000
type AnyTool = ToolConfig<never, never>
/** Drive a tool exactly as executeTool does for this family: build, fetch, transform. */
async function call(tool: AnyTool, params: Record<string, unknown>): Promise<Record<string, any>> {
const request = tool.request
if (!request) throw new Error(`${tool.id} has no request config`)
const url = typeof request.url === 'function' ? request.url(params as never) : request.url
const headers = request.headers ? request.headers(params as never) : {}
const buildBody = request.body as ((p: unknown) => unknown) | undefined
const response = await fetch(url as string, {
method: (request.method as string) ?? 'POST',
headers: headers as Record<string, string>,
body: buildBody ? JSON.stringify(buildBody(params)) : undefined,
})
const transform = tool.transformResponse as (r: Response, p?: unknown) => Promise<any>
const result = await transform(response, params)
return result.output
}
/** Report a finding to stdout so the run doubles as a readable verification log. */
function note(label: string, value: unknown) {
const rendered = typeof value === 'string' ? value : JSON.stringify(value)
console.info(` [live] ${label}: ${rendered}`)
}
/** Facts discovered in the read-only phase and reused by the write phase. */
const discovered: {
syncToken?: string | null
fullScanJobCount?: number
candidateFieldId?: string
candidateFieldType?: string
candidateFieldOptions?: string[]
hasCandidatesDelete?: boolean
} = {}
/**
* `vitest.setup.ts` calls `setupGlobalFetchMock()`, which does
* `vi.stubGlobal('fetch', ...)` for every test file in the app. Without
* restoring the real implementation, every request below would quietly hit the
* mock and the whole suite would be a false pass - so restore it, then assert
* the restore actually worked.
*/
function useRealFetch() {
vi.unstubAllGlobals()
expect(vi.isMockFunction(globalThis.fetch)).toBe(false)
}
describe.skipIf(!LIVE)('ashby live (read-only)', () => {
beforeAll(useRealFetch)
it(
'probes candidatesDelete without creating anything',
async () => {
// application.delete against a random UUID has no side effects and
// distinguishes a missing scope from a missing record. This runs before
// any fixture exists so we never create an application we cannot delete.
let message = ''
try {
await call(deleteApplicationTool, {
apiKey,
applicationId: '00000000-0000-4000-8000-000000000000',
})
message = '(unexpectedly succeeded)'
} catch (error) {
message = (error as Error).message
}
note('delete scope probe error', message)
// Whatever Ashby returns, our error extraction must render it readably.
expect(message).not.toContain('[object Object]')
discovered.hasCandidatesDelete = !/permission/i.test(message)
note('candidatesDelete present', discovered.hasCandidatesDelete)
},
TIMEOUT
)
it(
'probes candidatesWrite without creating anything',
async () => {
// Same zero-side-effect trick: a random UUID cannot exist, so a
// permission error and a not-found error are cleanly distinguishable.
const probe = async (fn: () => Promise<unknown>) => {
try {
await fn()
return '(unexpectedly succeeded)'
} catch (error) {
return (error as Error).message
}
}
const random = '00000000-0000-4000-8000-000000000000'
const anonymizeError = await probe(() =>
call(anonymizeCandidateTool, { apiKey, candidateId: random })
)
const setValueError = await probe(() =>
call(setCustomFieldValueTool, {
apiKey,
objectId: random,
objectType: 'Candidate',
fieldId: random,
fieldValue: null,
})
)
note('anonymize probe error', anonymizeError)
note('setValue probe error', setValueError)
note('candidatesWrite present', !/permission/i.test(anonymizeError))
expect(anonymizeError).not.toContain('[object Object]')
expect(setValueError).not.toContain('[object Object]')
},
TIMEOUT
)
it(
'returns a syncToken only on the last page',
async () => {
// The core P0 claim, asserted in both our docs and the param description
// but never checked against the API.
let cursor: string | undefined
let page = 0
let total = 0
let lastPageToken: string | null = null
const midPageTokens: Array<string | null> = []
do {
const output = await call(listJobsTool, { apiKey, perPage: 5, cursor })
page += 1
total += output.jobs.length
if (output.moreDataAvailable) {
midPageTokens.push(output.nextSyncCursor)
cursor = output.nextCursor
} else {
lastPageToken = output.nextSyncCursor
cursor = undefined
}
expect(page).toBeLessThan(60)
} while (cursor)
note('pages walked', page)
note('jobs seen', total)
note('syncToken on non-final pages', midPageTokens)
note('syncToken on final page', lastPageToken ? 'present' : 'absent')
for (const token of midPageTokens) expect(token).toBeNull()
discovered.syncToken = lastPageToken
discovered.fullScanJobCount = total
},
TIMEOUT
)
it(
'replays the syncToken for an incremental scan',
async () => {
if (!discovered.syncToken) {
note('incremental replay', 'SKIPPED - no syncToken was returned')
return
}
const output = await call(listJobsTool, { apiKey, syncToken: discovered.syncToken })
note('full scan job count', discovered.fullScanJobCount)
note('incremental job count', output.jobs.length)
note('incremental syncToken', output.nextSyncCursor ? 'present' : 'absent')
expect(output.jobs.length).toBeLessThanOrEqual(discovered.fullScanJobCount ?? 0)
},
TIMEOUT
)
it(
'exposes confidential as a boolean on every job',
async () => {
const output = await call(listJobsTool, { apiKey, perPage: 100 })
expect(output.jobs.length).toBeGreaterThan(0)
for (const job of output.jobs) expect(typeof job.confidential).toBe('boolean')
const confidential = output.jobs.filter((j: { confidential: boolean }) => j.confidential)
note('jobs sampled', output.jobs.length)
note('confidential jobs visible to this key', confidential.length)
},
TIMEOUT
)
it(
'includes draft postings only when asked',
async () => {
const withoutDrafts = await call(listJobPostingsTool, { apiKey })
const withDrafts = await call(listJobPostingsTool, {
apiKey,
includeUnpublishedJobPostings: true,
})
const baseIds = new Set(withoutDrafts.jobPostings.map((p: { id: string }) => p.id))
const extra = withDrafts.jobPostings.filter((p: { id: string }) => !baseIds.has(p.id))
const statuses = [...new Set(withDrafts.jobPostings.map((p: { status: string }) => p.status))]
note('postings without drafts', withoutDrafts.jobPostings.length)
note('postings with drafts', withDrafts.jobPostings.length)
note('postings added by the flag', extra.length)
note('statuses seen', statuses)
// Superset, never a different set.
for (const id of baseIds) {
expect(withDrafts.jobPostings.some((p: { id: string }) => p.id === id)).toBe(true)
}
expect(withDrafts.jobPostings.length).toBeGreaterThanOrEqual(withoutDrafts.jobPostings.length)
},
TIMEOUT
)
it(
'finds a Candidate-scoped custom field to write to',
async () => {
const output = await call(listCustomFieldsTool, { apiKey, perPage: 100 })
const candidateFields = output.customFields.filter(
(f: { objectType: string; isArchived: boolean; isRequired: boolean }) =>
f.objectType === 'Candidate' && !f.isArchived && !f.isRequired
)
note('candidate custom fields available', candidateFields.length)
note(
'candidate fields',
candidateFields.map(
(f: { title: string; fieldType: string }) => `${f.title}:${f.fieldType}`
)
)
// Prefer a free-text field; a ValueSelect only accepts its own options.
const preferred =
candidateFields.find((f: { fieldType: string }) =>
['String', 'LongText'].includes(f.fieldType)
) ?? candidateFields[0]
if (preferred) {
discovered.candidateFieldId = preferred.id
discovered.candidateFieldType = preferred.fieldType
discovered.candidateFieldOptions = (preferred.selectableValues ?? []).map(
(v: { value: string }) => v.value
)
note('chosen field', `${preferred.title} (${preferred.fieldType}) ${preferred.id}`)
} else {
note('chosen field', 'NONE - no writable Candidate custom field exists')
}
expect(output.customFields.length).toBeGreaterThan(0)
},
TIMEOUT
)
})
describe.skipIf(!WRITES)('ashby live (writes on fixtures)', () => {
beforeAll(useRealFetch)
const stamp = new Date().toISOString()
const fixtureName = `ZZ SIM E2E TEST ${stamp}`
const state: { candidateId?: string; applicationId?: string } = {}
it(
'creates the fixture candidate',
async () => {
const output = await call(createCandidateTool, {
apiKey,
name: fixtureName,
email: `zz-sim-e2e-${Date.now()}@example.invalid`,
})
state.candidateId = output.id
note('fixture candidate id', output.id)
note('fixture candidate name', output.name)
expect(output.id).toBeTruthy()
},
TIMEOUT
)
it(
'writes a custom field value and reads it back',
async () => {
const fieldId = discovered.candidateFieldId
if (!fieldId || !state.candidateId) {
note('custom field write', 'SKIPPED - no field or candidate')
return
}
const value =
discovered.candidateFieldType === 'Number'
? 42
: discovered.candidateFieldType === 'Boolean'
? true
: discovered.candidateFieldType === 'MultiValueSelect'
? discovered.candidateFieldOptions?.slice(0, 1)
: discovered.candidateFieldType === 'ValueSelect'
? discovered.candidateFieldOptions?.[0]
: 'sim-e2e'
const written = await call(setCustomFieldValueTool, {
apiKey,
objectId: state.candidateId,
objectType: 'Candidate',
fieldId,
fieldValue: value,
})
note('written value', written.customField.value)
const candidate = await call(getCandidateTool, { apiKey, candidateId: state.candidateId })
const stored = candidate.customFields.find((f: { id: string }) => f.id === fieldId)
note('value read back from candidate', stored?.value)
expect(stored).toBeDefined()
},
TIMEOUT
)
it(
'clears the custom field with null',
async () => {
const fieldId = discovered.candidateFieldId
if (!fieldId || !state.candidateId) {
note('custom field clear', 'SKIPPED')
return
}
const cleared = await call(setCustomFieldValueTool, {
apiKey,
objectId: state.candidateId,
objectType: 'Candidate',
fieldId,
fieldValue: null,
})
note('value after null write', cleared.customField.value)
const candidate = await call(getCandidateTool, { apiKey, candidateId: state.candidateId })
const stored = candidate.customFields.find((f: { id: string }) => f.id === fieldId)
note('value read back after clear', stored ? stored.value : '(field absent)')
expect(stored?.value ?? null).toBeNull()
},
TIMEOUT
)
it(
'writes several fields at once and gets an array back',
async () => {
const fieldId = discovered.candidateFieldId
if (!fieldId || !state.candidateId) {
note('setValues', 'SKIPPED')
return
}
const output = await call(setCustomFieldValuesTool, {
apiKey,
objectId: state.candidateId,
objectType: 'Candidate',
values: [{ fieldId, fieldValue: 'sim-e2e-plural' }],
})
note('setValues returned', output.customFields)
expect(Array.isArray(output.customFields)).toBe(true)
// Leave the fixture clean.
await call(setCustomFieldValueTool, {
apiKey,
objectId: state.candidateId,
objectType: 'Candidate',
fieldId,
fieldValue: null,
})
},
TIMEOUT
)
it(
'creates the fixture application on the approved job',
async () => {
const jobId = process.env.ASHBY_FIXTURE_JOB_ID
if (!jobId || !state.candidateId || !discovered.hasCandidatesDelete) {
note('fixture application', 'SKIPPED - no approved job or no candidatesDelete')
return
}
const output = await call(createApplicationTool, {
apiKey,
candidateId: state.candidateId,
jobId,
})
state.applicationId = output.id
note('fixture application id', output.id)
expect(output.id).toBeTruthy()
},
TIMEOUT
)
it(
'changes the application source, then unsets it with an explicit null',
async () => {
if (!state.applicationId) {
note('changeSource', 'SKIPPED - no fixture application')
return
}
const sources = await call(listSourcesTool, { apiKey })
const source = sources.sources?.find((s: { isArchived: boolean }) => !s.isArchived)
note('source used', source ? `${source.title} ${source.id}` : 'none available')
if (source) {
const set = await call(changeApplicationSourceTool, {
apiKey,
applicationId: state.applicationId,
sourceId: source.id,
})
note('source after set', set.source?.title ?? null)
expect(set.source?.id).toBe(source.id)
}
// The spec detail most likely to be wrong: sourceId must be PRESENT and
// null to unset. A dropped key is a 400, not a clear.
const unset = await call(changeApplicationSourceTool, {
apiKey,
applicationId: state.applicationId,
unsetSource: true,
})
note('source after explicit null', unset.source)
expect(unset.source ?? null).toBeNull()
},
TIMEOUT
)
it(
'deletes the fixture application',
async () => {
if (!state.applicationId) {
note('delete', 'SKIPPED - no fixture application')
return
}
const output = await call(deleteApplicationTool, {
apiKey,
applicationId: state.applicationId,
})
note('deleted application id', output.applicationId)
expect(output.applicationId).toBe(state.applicationId)
let stillThere = true
try {
await call(getApplicationTool, { apiKey, applicationId: state.applicationId })
} catch (error) {
stillThere = false
note('get after delete', (error as Error).message)
}
expect(stillThere).toBe(false)
state.applicationId = undefined
},
TIMEOUT
)
it(
'anonymizes the fixture candidate last',
async () => {
if (!state.candidateId) {
note('anonymize', 'SKIPPED')
return
}
const output = await call(anonymizeCandidateTool, {
apiKey,
candidateId: state.candidateId,
})
note('name after anonymize', output.name)
note('email after anonymize', output.primaryEmailAddress?.value ?? null)
// Our documented limitation: the record survives, only the PII is gone.
const after = await call(getCandidateTool, { apiKey, candidateId: state.candidateId })
note('record still exists after anonymize', Boolean(after.id))
expect(after.id).toBe(state.candidateId)
expect(after.name).not.toBe(fixtureName)
},
TIMEOUT
)
it(
'leaves the fixture in the expected final state',
async () => {
// Teardown is asserted, not assumed: no application survives, and the
// custom field value we wrote is cleared rather than left behind.
if (!state.candidateId) return
const after = await call(getCandidateTool, { apiKey, candidateId: state.candidateId })
const field = after.customFields.find(
(f: { id: string }) => f.id === discovered.candidateFieldId
)
note('final custom field value', field ? field.value : '(field absent)')
note('final application ids on fixture', after.applicationIds)
expect(field?.value ?? null).toBeNull()
expect(after.applicationIds).toEqual([])
},
TIMEOUT
)
})
+303
View File
@@ -0,0 +1,303 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { isSensitiveKey } from '@/lib/core/security/redaction'
import { anonymizeCandidateTool } from '@/tools/ashby/anonymize_candidate'
import { changeApplicationSourceTool } from '@/tools/ashby/change_application_source'
import { deleteApplicationTool } from '@/tools/ashby/delete_application'
import { listJobPostingsTool } from '@/tools/ashby/list_job_postings'
import { listJobsTool } from '@/tools/ashby/list_jobs'
import { setCustomFieldValueTool } from '@/tools/ashby/set_custom_field_value'
import { setCustomFieldValuesTool } from '@/tools/ashby/set_custom_field_values'
import type { ToolConfig } from '@/tools/types'
const respond = (body: unknown) => new Response(JSON.stringify(body), { status: 200 })
function requestBody(tool: ToolConfig<never, never>, params: unknown): Record<string, unknown> {
const build = tool.request?.body as (p: unknown) => Record<string, unknown>
return build(params)
}
async function transform(tool: ToolConfig<never, never>, body: unknown) {
const run = tool.transformResponse as (r: Response) => Promise<{ output: Record<string, never> }>
return run(respond(body))
}
describe('ashby request bodies', () => {
it('list_jobs forwards a sync token and omits the key when absent', () => {
expect(requestBody(listJobsTool, { apiKey: 'k', syncToken: 'tok' }).syncToken).toBe('tok')
expect(requestBody(listJobsTool, { apiKey: 'k' })).not.toHaveProperty('syncToken')
})
it('list_job_postings uses the jobPosting.list spelling, not the job.list one', () => {
// job.list takes `includeUnpublishedJobPostingsIds`, a different parameter
// with a different meaning. Sending that name here is silently ignored.
const body = requestBody(listJobPostingsTool, {
apiKey: 'k',
includeUnpublishedJobPostings: true,
})
expect(body.includeUnpublishedJobPostings).toBe(true)
expect(body).not.toHaveProperty('includeUnpublishedJobPostingsIds')
})
it('change_application_source sends sourceId when one is provided', () => {
// Ashby requires the key to be present; omitting it is a 400 rather than
// the intended write.
expect(
requestBody(changeApplicationSourceTool, { applicationId: ' a ', sourceId: ' s ' })
).toEqual({
applicationId: 'a',
sourceId: 's',
})
})
it('does not mark fieldValue required, so a null clear survives validation', () => {
// validateRequiredParametersAfterMerge rejects a required `user-or-llm`
// param whose value is null, which would make clearing a custom field
// impossible - the exact operation the null value exists for.
expect(setCustomFieldValueTool.params.fieldValue.required).toBe(false)
expect(setCustomFieldValueTool.params.objectId.required).toBe(true)
expect(setCustomFieldValueTool.params.fieldId.required).toBe(true)
})
it('set_custom_field_value preserves an explicit null so a field can be cleared', () => {
expect(
requestBody(setCustomFieldValueTool, {
objectId: 'o',
objectType: 'Job',
fieldId: 'f',
fieldValue: null,
})
).toEqual({ objectId: 'o', objectType: 'Job', fieldId: 'f', fieldValue: null })
})
it('refuses to clear the field when the value was merely omitted', () => {
// null clears the field, so an absent value must not become one: a dropped
// variable, an unresolved reference, or a model call that forgot the
// argument would otherwise silently destroy the stored value.
expect(() =>
requestBody(setCustomFieldValueTool, { objectId: 'o', objectType: 'Job', fieldId: 'f' })
).toThrow(/required. Pass null to clear/)
expect(() =>
requestBody(setCustomFieldValueTool, {
objectId: 'o',
objectType: 'Job',
fieldId: 'f',
fieldValue: ' ',
})
).toThrow(/required. Pass null to clear/)
})
it('set_custom_field_values sends the values array', () => {
const values = [{ fieldId: 'f', fieldValue: 'High' }]
expect(
requestBody(setCustomFieldValuesTool, { objectId: 'o', objectType: 'Job', values }).values
).toEqual(values)
})
it('rejects an empty values array before it reaches Ashby', () => {
expect(() =>
requestBody(setCustomFieldValuesTool, { objectId: 'o', objectType: 'Job', values: [] })
).toThrow(/non-empty array/)
})
it('refuses to unset an application source unless asked explicitly', () => {
// The endpoint has no "leave unchanged" mode, so a dropped variable or a
// model call that omits sourceId would otherwise wipe attribution and still
// report success.
expect(() => requestBody(changeApplicationSourceTool, { applicationId: 'a' })).toThrow(
/unsetSource/
)
expect(
requestBody(changeApplicationSourceTool, { applicationId: 'a', unsetSource: true }).sourceId
).toBeNull()
})
it('rejects a source id and an unset request together', () => {
// Preferring either one silently discards the other, which is how an
// intentional clear turns into a set nobody asked for.
expect(() =>
requestBody(changeApplicationSourceTool, {
applicationId: 'a',
sourceId: 's',
unsetSource: true,
})
).toThrow(/mutually exclusive/)
})
it('rejects an object type Ashby would not accept', () => {
// objectType is user-or-llm and Ashby's enum is case-sensitive.
expect(() =>
requestBody(setCustomFieldValueTool, {
objectId: 'o',
objectType: 'Sandwich',
fieldId: 'f',
fieldValue: 'x',
})
).toThrow(/Expected one of/)
expect(
requestBody(setCustomFieldValueTool, {
objectId: ' o ',
objectType: 'candidate',
fieldId: ' f ',
fieldValue: 'x',
})
).toEqual({ objectId: 'o', objectType: 'Candidate', fieldId: 'f', fieldValue: 'x' })
})
it('trims ids on the single-id operations', () => {
expect(requestBody(deleteApplicationTool, { applicationId: ' app-1 ' }).applicationId).toBe(
'app-1'
)
expect(requestBody(anonymizeCandidateTool, { candidateId: ' cand-1 ' }).candidateId).toBe(
'cand-1'
)
})
})
describe('ashby response transforms', () => {
it('exposes the sync cursor under a name redaction does not treat as a secret', async () => {
// `syncToken` matches the /^.*token$/i deny-list, so surfacing it under that
// name renders it [REDACTED] in block output - and an incremental sync is
// useless if the operator cannot read the cursor for the next run. It is an
// opaque resumption marker, so it belongs with nextCursor, not with API keys.
expect(isSensitiveKey('syncToken')).toBe(true)
expect(isSensitiveKey('nextSyncCursor')).toBe(false)
expect(isSensitiveKey('nextCursor')).toBe(false)
})
it('list_jobs surfaces the sync cursor and the confidential flag', async () => {
const result = await transform(listJobsTool, {
success: true,
results: [{ id: 'j1', title: 'Engineer', confidential: true }],
moreDataAvailable: false,
syncToken: 'next-token',
})
expect(result.output.nextSyncCursor).toBe('next-token')
expect(result.output.jobs[0].confidential).toBe(true)
})
it('list_jobs reports a null sync token on a non-final page', async () => {
// Ashby only returns a sync token once the last page is drained, so callers
// must not persist what comes back mid-pagination.
const result = await transform(listJobsTool, {
success: true,
results: [],
moreDataAvailable: true,
nextCursor: 'cursor-1',
})
expect(result.output.nextSyncCursor).toBeNull()
})
it('set_custom_field_value maps the single result object', async () => {
const result = await transform(setCustomFieldValueTool, {
success: true,
results: { id: 'cf', title: 'Priority', isPrivate: false, value: 'High', valueLabel: 'High' },
})
expect(result.output.customField).toEqual({
id: 'cf',
title: 'Priority',
isPrivate: false,
value: 'High',
valueLabel: 'High',
})
})
it('set_custom_field_values maps the result array', async () => {
// The plural endpoint returns an array where the singular returns an object.
const result = await transform(setCustomFieldValuesTool, {
success: true,
results: [
{ id: 'a', title: 'A', value: 1 },
{ id: 'b', title: 'B', value: null },
],
})
expect(result.output.customFields).toHaveLength(2)
expect(result.output.customFields[1].value).toBeNull()
})
it('delete_application surfaces the deleted id', async () => {
const result = await transform(deleteApplicationTool, {
success: true,
results: { applicationId: 'app-1' },
})
expect(result.output.applicationId).toBe('app-1')
})
it('delete_application reports a missing permission readably', async () => {
// candidatesDelete is a separate module permission, so this is the most
// likely failure for this operation and must not surface as [object Object].
await expect(
transform(deleteApplicationTool, {
success: false,
errors: [{ message: 'missing_endpoint_permission' }],
})
).rejects.toThrow('missing_endpoint_permission')
})
it('every new tool surfaces an Ashby failure readably', async () => {
// The object-shaped errors array is the form Ashby's own spec documents, and
// it is what these messages are built from - each tool must unwrap it rather
// than stringify the entry.
const failure = { success: false, errors: [{ message: 'invalid_input', parameter: 'x' }] }
for (const tool of [
setCustomFieldValueTool,
setCustomFieldValuesTool,
anonymizeCandidateTool,
changeApplicationSourceTool,
]) {
await expect(transform(tool, failure)).rejects.toThrow('invalid_input (x)')
}
})
it('anonymize_candidate maps the returned candidate', async () => {
const result = await transform(anonymizeCandidateTool, {
success: true,
results: { id: 'cand-1', name: 'Anonymous cand-1', primaryEmailAddress: null },
})
expect(result.output.id).toBe('cand-1')
expect(result.output.name).toBe('Anonymous cand-1')
})
it('change_application_source maps the returned application', async () => {
const result = await transform(changeApplicationSourceTool, {
success: true,
results: { id: 'app-1', status: 'Active', source: null },
})
expect(result.output.id).toBe('app-1')
expect(result.output.source).toBeNull()
})
it('keeps an array valueLabel intact for MultiValueSelect fields', async () => {
// This is why AshbyCustomField.valueLabel widened to string | string[];
// a mapper that coerced it would silently drop the labels.
const result = await transform(setCustomFieldValueTool, {
success: true,
results: { id: 'cf', title: 'Modes', value: ['a', 'b'], valueLabel: ['A', 'B'] },
})
expect(result.output.customField.valueLabel).toEqual(['A', 'B'])
expect(result.output.customField.value).toEqual(['a', 'b'])
})
it('list_job_postings surfaces draft status on the new field', async () => {
const result = await transform(listJobPostingsTool, {
success: true,
results: [
{ id: 'p1', title: 'A', status: 'Published' },
{ id: 'p2', title: 'B', status: 'Draft' },
],
})
expect(result.output.jobPostings.map((p: { status: string }) => p.status)).toEqual([
'Published',
'Draft',
])
})
it('defaults missing result payloads instead of throwing', async () => {
const deleted = await transform(deleteApplicationTool, { success: true, results: {} })
expect(deleted.output.applicationId).toBe('')
const plural = await transform(setCustomFieldValuesTool, { success: true })
expect(plural.output.customFields).toEqual([])
})
})
@@ -0,0 +1,110 @@
import type { AshbyApplication } from '@/tools/ashby/types'
import {
APPLICATION_OUTPUTS,
ashbyAuthHeaders,
ashbyErrorMessage,
mapApplication,
} from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyChangeApplicationSourceParams {
apiKey: string
applicationId: string
sourceId?: string
unsetSource?: boolean
}
interface AshbyChangeApplicationSourceResponse extends ToolResponse {
output: AshbyApplication
}
export const changeApplicationSourceTool: ToolConfig<
AshbyChangeApplicationSourceParams,
AshbyChangeApplicationSourceResponse
> = {
id: 'ashby_change_application_source',
name: 'Ashby Change Application Source',
description:
'Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
applicationId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'UUID of the application whose source should change',
},
sourceId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true.',
},
unsetSource: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident.',
},
},
request: {
url: 'https://api.ashbyhq.com/application.changeSource',
method: 'POST',
headers: (params) => ashbyAuthHeaders(params.apiKey),
/**
* Ashby requires `sourceId` to be present even when unsetting, so it always
* serializes - but the value has to say what the caller actually meant. The
* endpoint has no "leave unchanged" mode, so setting and clearing are the
* only two intents, and exactly one of them must be expressed:
*
* - neither given: an omitted sourceId would otherwise wipe attribution on a
* real application and still report success
* - both given: preferring either one silently discards the other, which is
* how "clear this" turns into a set nobody asked for
*
* Both are caller errors rather than something to resolve by precedence.
*/
body: (params) => {
const sourceId = params.sourceId?.trim()
if (sourceId && params.unsetSource) {
throw new Error(
'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.'
)
}
if (!sourceId && !params.unsetSource) {
throw new Error(
'Ashby source ID is required. Set unsetSource to true to deliberately clear the application source.'
)
}
return {
applicationId: params.applicationId.trim(),
sourceId: sourceId ? sourceId : null,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(ashbyErrorMessage(data, 'Failed to change application source'))
}
return {
success: true,
output: mapApplication(data.results),
}
},
outputs: APPLICATION_OUTPUTS,
}
@@ -0,0 +1,70 @@
import { ashbyAuthHeaders, ashbyErrorMessage } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyDeleteApplicationParams {
apiKey: string
applicationId: string
}
interface AshbyDeleteApplicationResponse extends ToolResponse {
output: {
applicationId: string
}
}
export const deleteApplicationTool: ToolConfig<
AshbyDeleteApplicationParams,
AshbyDeleteApplicationResponse
> = {
id: 'ashby_delete_application',
name: 'Ashby Delete Application',
description:
'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.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
applicationId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'UUID of the application to delete',
},
},
request: {
url: 'https://api.ashbyhq.com/application.delete',
method: 'POST',
headers: (params) => ashbyAuthHeaders(params.apiKey),
body: (params) => ({ applicationId: params.applicationId.trim() }),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(ashbyErrorMessage(data, 'Failed to delete application'))
}
const result = (data.results ?? {}) as Record<string, unknown>
return {
success: true,
output: {
applicationId: (result.applicationId as string) ?? '',
},
}
},
outputs: {
applicationId: {
type: 'string',
description: 'UUID of the deleted application',
},
},
}
+11 -1
View File
@@ -1,8 +1,11 @@
import { addCandidateTagTool } from '@/tools/ashby/add_candidate_tag'
import { anonymizeCandidateTool } from '@/tools/ashby/anonymize_candidate'
import { changeApplicationSourceTool } from '@/tools/ashby/change_application_source'
import { changeApplicationStageTool } from '@/tools/ashby/change_application_stage'
import { createApplicationTool } from '@/tools/ashby/create_application'
import { createCandidateTool } from '@/tools/ashby/create_candidate'
import { createNoteTool } from '@/tools/ashby/create_note'
import { deleteApplicationTool } from '@/tools/ashby/delete_application'
import { getApplicationTool } from '@/tools/ashby/get_application'
import { getCandidateTool } from '@/tools/ashby/get_candidate'
import { getJobTool } from '@/tools/ashby/get_job'
@@ -25,17 +28,22 @@ import { listSourcesTool } from '@/tools/ashby/list_sources'
import { listUsersTool } from '@/tools/ashby/list_users'
import { removeCandidateTagTool } from '@/tools/ashby/remove_candidate_tag'
import { searchCandidatesTool } from '@/tools/ashby/search_candidates'
import { setCustomFieldValueTool } from '@/tools/ashby/set_custom_field_value'
import { setCustomFieldValuesTool } from '@/tools/ashby/set_custom_field_values'
import { updateCandidateTool } from '@/tools/ashby/update_candidate'
export const ashbyAddCandidateTagTool = addCandidateTagTool
export const ashbyAnonymizeCandidateTool = anonymizeCandidateTool
export const ashbyChangeApplicationSourceTool = changeApplicationSourceTool
export const ashbyChangeApplicationStageTool = changeApplicationStageTool
export const ashbyCreateApplicationTool = createApplicationTool
export const ashbyCreateCandidateTool = createCandidateTool
export const ashbyCreateNoteTool = createNoteTool
export const ashbyDeleteApplicationTool = deleteApplicationTool
export const ashbyGetApplicationTool = getApplicationTool
export const ashbyGetCandidateTool = getCandidateTool
export const ashbyGetJobTool = getJobTool
export const ashbyGetJobPostingTool = getJobPostingTool
export const ashbyGetJobTool = getJobTool
export const ashbyGetOfferTool = getOfferTool
export const ashbyListApplicationsTool = listApplicationsTool
export const ashbyListArchiveReasonsTool = listArchiveReasonsTool
@@ -54,6 +62,8 @@ export const ashbyListSourcesTool = listSourcesTool
export const ashbyListUsersTool = listUsersTool
export const ashbyRemoveCandidateTagTool = removeCandidateTagTool
export const ashbySearchCandidatesTool = searchCandidatesTool
export const ashbySetCustomFieldValueTool = setCustomFieldValueTool
export const ashbySetCustomFieldValuesTool = setCustomFieldValuesTool
export const ashbyUpdateCandidateTool = updateCandidateTool
export * from './types'
+18
View File
@@ -6,6 +6,7 @@ interface AshbyListJobPostingsParams {
location?: string
department?: string
listedOnly?: boolean
includeUnpublishedJobPostings?: boolean
jobBoardId?: string
}
@@ -22,6 +23,7 @@ interface AshbyJobPostingSummary {
} | null
workplaceType: string | null
employmentType: string | null
status: string | null
isListed: boolean
publishedDate: string | null
applicationDeadline: string | null
@@ -72,6 +74,13 @@ export const listJobPostingsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'When true, only returns listed (publicly visible) job postings (default false)',
},
includeUnpublishedJobPostings: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'When true, also returns unpublished (Draft) job postings. The endpoint already returns both listed and unlisted published postings by default, so this only adds drafts.',
},
jobBoardId: {
type: 'string',
required: false,
@@ -90,6 +99,9 @@ export const listJobPostingsTool: ToolConfig<
if (params.location) body.location = params.location
if (params.department) body.department = params.department
if (params.listedOnly !== undefined) body.listedOnly = params.listedOnly
if (params.includeUnpublishedJobPostings !== undefined) {
body.includeUnpublishedJobPostings = params.includeUnpublishedJobPostings
}
if (params.jobBoardId) body.jobBoardId = params.jobBoardId.trim()
return body
},
@@ -127,6 +139,7 @@ export const listJobPostingsTool: ToolConfig<
: null,
workplaceType: (jp.workplaceType as string) ?? null,
employmentType: (jp.employmentType as string) ?? null,
status: (jp.status as string) ?? null,
isListed: (jp.isListed as boolean) ?? false,
publishedDate: (jp.publishedDate as string) ?? null,
applicationDeadline: (jp.applicationDeadline as string) ?? null,
@@ -186,6 +199,11 @@ export const listJobPostingsTool: ToolConfig<
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
optional: true,
},
status: {
type: 'string',
description: 'Posting status (Draft or Published)',
optional: true,
},
isListed: { type: 'boolean', description: 'Whether the posting is publicly listed' },
publishedDate: {
type: 'string',
+17 -1
View File
@@ -26,7 +26,15 @@ export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results per page (default 100)',
description:
'Number of results per page (default and max 100). Ashby silently caps larger values rather than erroring.',
},
syncToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Opaque token from a prior sync to fetch only jobs changed since then. Ashby only returns a new syncToken on the last page, so drain moreDataAvailable/nextCursor before persisting it.',
},
status: {
type: 'string',
@@ -75,6 +83,7 @@ export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse
const body: Record<string, unknown> = { expand: ['openings', 'location'] }
if (params.cursor) body.cursor = params.cursor
if (params.perPage) body.limit = params.perPage
if (params.syncToken) body.syncToken = params.syncToken
if (params.status) body.status = [params.status]
const isoToMs = (iso: string): number | null => {
const ms = new Date(iso).getTime()
@@ -117,6 +126,7 @@ export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse
jobs: (data.results ?? []).map(mapJob),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
nextSyncCursor: data.syncToken ?? null,
},
}
},
@@ -139,5 +149,11 @@ export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse
description: 'Opaque cursor for fetching the next page',
optional: true,
},
nextSyncCursor: {
type: 'string',
description:
"Ashby's syncToken for the next incremental run, returned only once the last page is drained. Named as a cursor because that is what it is - an opaque resumption marker, not a credential - so it stays readable in block output alongside nextCursor.",
optional: true,
},
},
}
@@ -0,0 +1,128 @@
import type { AshbyCustomField } from '@/tools/ashby/types'
import {
ashbyAuthHeaders,
ashbyErrorMessage,
CUSTOM_FIELD_ON_OBJECT_OUTPUT,
mapCustomFieldOnObject,
normalizeObjectType,
} from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbySetCustomFieldValueParams {
apiKey: string
objectId: string
objectType: string
fieldId: string
fieldValue: unknown
}
interface AshbySetCustomFieldValueResponse extends ToolResponse {
output: {
customField: AshbyCustomField
}
}
export const setCustomFieldValueTool: ToolConfig<
AshbySetCustomFieldValueParams,
AshbySetCustomFieldValueResponse
> = {
id: 'ashby_set_custom_field_value',
name: 'Ashby Set Custom Field Value',
description:
'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.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
objectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'UUID of the object to set the field on (application, candidate, job, or opening)',
},
objectType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Type of the object: Application, Candidate, Job, or Opening',
},
fieldId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'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.',
},
/**
* Not marked required even though Ashby always expects the key: the shared
* post-merge validator rejects a required `user-or-llm` param whose value is
* `null`, and `null` is exactly how a custom field is cleared. The block
* keeps its own required marker on the subblock, so a blank field is still
* caught in the editor. See `validateRequiredParametersAfterMerge`.
*/
fieldValue: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description:
'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.',
},
},
request: {
url: 'https://api.ashbyhq.com/customField.setValue',
method: 'POST',
headers: (params) => ashbyAuthHeaders(params.apiKey),
/**
* `null` clears the field, so an omitted or blank value must not be allowed
* to reach Ashby as one - that would turn a dropped variable, an unresolved
* block reference, or a model call that forgot the argument into silent
* data loss. Clearing has to be asked for explicitly.
*/
body: (params) => {
const isBlank =
params.fieldValue === undefined ||
(typeof params.fieldValue === 'string' && params.fieldValue.trim() === '')
if (isBlank) {
throw new Error(
'Ashby custom field value is required. Pass null to clear the field, or provide a value to set.'
)
}
return {
objectId: params.objectId.trim(),
objectType: normalizeObjectType(params.objectType),
fieldId: params.fieldId.trim(),
fieldValue: params.fieldValue,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(ashbyErrorMessage(data, 'Failed to set custom field value'))
}
return {
success: true,
output: {
customField: mapCustomFieldOnObject(data.results),
},
}
},
outputs: {
customField: {
type: 'object',
description: 'The custom field as stored on the object after the write',
properties: CUSTOM_FIELD_ON_OBJECT_OUTPUT,
},
},
}
@@ -0,0 +1,103 @@
import type { AshbyCustomField } from '@/tools/ashby/types'
import {
ashbyAuthHeaders,
ashbyErrorMessage,
CUSTOM_FIELDS_OUTPUT,
mapCustomFieldOnObject,
normalizeObjectType,
} from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbySetCustomFieldValuesParams {
apiKey: string
objectId: string
objectType: string
values: unknown
}
interface AshbySetCustomFieldValuesResponse extends ToolResponse {
output: {
customFields: AshbyCustomField[]
}
}
export const setCustomFieldValuesTool: ToolConfig<
AshbySetCustomFieldValuesParams,
AshbySetCustomFieldValuesResponse
> = {
id: 'ashby_set_custom_field_values',
name: 'Ashby Set Custom Field Values',
description:
'Sets several custom field values on one Ashby Application, Candidate, Job, or Opening in a single call. Prefer this over repeated single-field writes to the same object - Ashby recommends it because concurrent single-field calls can race and overwrite each other. Requires the candidatesWrite permission.',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Ashby API Key',
},
objectId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'UUID of the object to set the fields on (application, candidate, job, or opening)',
},
objectType: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Type of the object: Application, Candidate, Job, or Opening',
},
values: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description:
'Array of at least one { fieldId, fieldValue } pair. fieldId is a custom field definition UUID from List Custom Fields. fieldValue matches the field type: boolean, number, string, string array (MultiValueSelect), or an object for Currency, NumberRange, CompensationRange, and Location. Pass null as a fieldValue to clear that field.',
},
},
request: {
url: 'https://api.ashbyhq.com/customField.setValues',
method: 'POST',
headers: (params) => ashbyAuthHeaders(params.apiKey),
/** Ashby rejects an empty array; fail here with a message naming the field. */
body: (params) => {
if (!Array.isArray(params.values) || params.values.length === 0) {
throw new Error(
'Ashby custom field values must be a non-empty array of { fieldId, fieldValue } pairs.'
)
}
return {
objectId: params.objectId.trim(),
objectType: normalizeObjectType(params.objectType),
values: params.values,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
throw new Error(ashbyErrorMessage(data, 'Failed to set custom field values'))
}
return {
success: true,
output: {
customFields: (data.results ?? []).map(mapCustomFieldOnObject),
},
}
},
outputs: {
customFields: {
...CUSTOM_FIELDS_OUTPUT,
description: 'The custom fields as stored on the object after the write',
},
},
}
+3 -1
View File
@@ -31,7 +31,7 @@ export interface AshbyCustomField {
id: string | null
title: string
isPrivate: boolean
valueLabel: string | null
valueLabel: string | string[] | null
value: unknown
}
@@ -122,6 +122,7 @@ export interface AshbySearchCandidatesParams extends AshbyBaseParams {
export interface AshbyListJobsParams extends AshbyBaseParams {
cursor?: string
perPage?: number
syncToken?: string
status?: string
createdAfter?: string
openedAfter?: string
@@ -266,6 +267,7 @@ export interface AshbyListJobsResponse extends ToolResponse {
jobs: AshbyJob[]
moreDataAvailable: boolean
nextCursor: string | null
nextSyncCursor: string | null
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { ashbyErrorMessage } from '@/tools/ashby/utils'
describe('ashbyErrorMessage', () => {
it('reads the message out of the documented { message, parameter } entries', () => {
// This is the shape Ashby's OpenAPI definition declares, and the one a 403
// for a missing module permission arrives in. Stringifying the entry
// directly yields '[object Object]' and hides the real cause.
expect(
ashbyErrorMessage(
{ success: false, errors: [{ message: 'missing_endpoint_permission' }] },
'fallback'
)
).toBe('missing_endpoint_permission')
})
it('names the offending parameter when Ashby supplies one', () => {
expect(
ashbyErrorMessage(
{ success: false, errors: [{ message: 'Invalid value', parameter: 'fieldValue' }] },
'fallback'
)
).toBe('Invalid value (fieldValue)')
})
it('joins multiple errors', () => {
expect(
ashbyErrorMessage(
{ success: false, errors: [{ message: 'a' }, { message: 'b' }] },
'fallback'
)
).toBe('a; b')
})
it('still handles the plain string array form', () => {
expect(ashbyErrorMessage({ success: false, errors: ['boom'] }, 'fallback')).toBe('boom')
})
it('prefers errorInfo.message, the other documented shape', () => {
expect(
ashbyErrorMessage({ success: false, errorInfo: { message: 'rate limited' } }, 'fallback')
).toBe('rate limited')
})
it('falls back when the entries carry no usable message', () => {
expect(ashbyErrorMessage({ success: false, errors: [{ parameter: 'x' }] }, 'fallback')).toBe(
'fallback'
)
expect(ashbyErrorMessage({ success: false, errors: [] }, 'fallback')).toBe('fallback')
expect(ashbyErrorMessage(null, 'fallback')).toBe('fallback')
})
})
+74 -19
View File
@@ -31,7 +31,11 @@ export function ashbyAuthHeaders(apiKey: string): Record<string, string> {
/**
* Extract a human-readable error message from an Ashby error response. Ashby
* returns errors as either `errorInfo.message` or an `errors` string array.
* documents two shapes and uses both: `errorInfo.message`, and an `errors`
* array whose entries are either plain strings or `{ message, parameter }`
* objects. An object entry stringifies to `[object Object]` unless its message
* is read explicitly, which is the form a 403 for a missing module permission
* arrives in.
*/
export function ashbyErrorMessage(data: unknown, fallback: string): string {
if (!data || typeof data !== 'object') return fallback
@@ -39,7 +43,20 @@ export function ashbyErrorMessage(data: unknown, fallback: string): string {
const info = d.errorInfo as Unknown | undefined
if (info && typeof info.message === 'string' && info.message) return info.message
if (Array.isArray(d.errors) && d.errors.length > 0) {
return d.errors.map((e) => String(e)).join('; ')
const messages = d.errors
.map((e) => {
if (typeof e === 'string') return e
if (e && typeof e === 'object') {
const entry = e as Unknown
const message = typeof entry.message === 'string' ? entry.message : ''
const parameter = typeof entry.parameter === 'string' ? entry.parameter : ''
if (message && parameter) return `${message} (${parameter})`
if (message) return message
}
return ''
})
.filter(Boolean)
if (messages.length > 0) return messages.join('; ')
}
return fallback
}
@@ -59,18 +76,44 @@ function mapContactArray(raw: unknown): AshbyContactInfo[] {
return raw.map((c) => mapContact(c)).filter((c): c is AshbyContactInfo => c !== null)
}
const CUSTOM_FIELD_OBJECT_TYPES = ['Application', 'Candidate', 'Job', 'Opening'] as const
/**
* Normalize and validate the objectType a custom field write targets. Ashby's
* enum is case-sensitive, and this param is `user-or-llm` - a model emitting
* `candidate` instead of `Candidate` would otherwise fail at the API with a
* generic error instead of here with a message naming the allowed values.
*/
export function normalizeObjectType(value: string): string {
const trimmed = (value ?? '').trim()
const match = CUSTOM_FIELD_OBJECT_TYPES.find((t) => t.toLowerCase() === trimmed.toLowerCase())
if (!match) {
throw new Error(
`Invalid Ashby object type "${value}". Expected one of: ${CUSTOM_FIELD_OBJECT_TYPES.join(', ')}.`
)
}
return match
}
/**
* Map a single custom field value as returned on an object. Ashby returns
* `valueLabel` as a string for ValueSelect fields and an array of strings for
* MultiValueSelect, and omits it entirely for every other field type.
*/
export function mapCustomFieldOnObject(raw: unknown): AshbyCustomField {
const cf = (raw ?? {}) as Unknown
return {
id: (cf.id as string) ?? null,
title: (cf.title as string) ?? '',
isPrivate: (cf.isPrivate as boolean) ?? false,
valueLabel: (cf.valueLabel as string | string[]) ?? null,
value: cf.value ?? null,
}
}
function mapCustomFields(raw: unknown): AshbyCustomField[] {
if (!Array.isArray(raw)) return []
return raw.map((f) => {
const cf = f as Unknown
return {
id: (cf.id as string) ?? null,
title: (cf.title as string) ?? '',
isPrivate: (cf.isPrivate as boolean) ?? false,
valueLabel: (cf.valueLabel as string) ?? null,
value: cf.value ?? null,
}
})
return raw.map(mapCustomFieldOnObject)
}
function mapFileHandle(raw: unknown): AshbyFileHandle | null {
@@ -373,18 +416,30 @@ export const CONTACT_INFO_OUTPUT = {
},
} as const satisfies OutputProperty
/**
* Shape of a custom field as it exists on an Application, Candidate, Job, or
* Opening - the value, not the field definition. Shared by every tool that
* reads or writes custom field values.
*/
export const CUSTOM_FIELD_ON_OBJECT_OUTPUT = {
id: { type: 'string', description: 'Custom field UUID' },
title: { type: 'string', description: 'Field title' },
isPrivate: { type: 'boolean', description: 'Whether the field is private' },
valueLabel: {
type: 'json',
description:
'Human-readable value label, present only for ValueSelect and MultiValueSelect fields. A string for ValueSelect, an array of strings for MultiValueSelect.',
optional: true,
},
value: { type: 'string', description: 'Raw field value (type depends on fieldType)' },
} as const satisfies Record<string, OutputProperty>
export const CUSTOM_FIELDS_OUTPUT = {
type: 'array',
description: 'Custom field values',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Custom field UUID' },
title: { type: 'string', description: 'Field title' },
isPrivate: { type: 'boolean', description: 'Whether the field is private' },
valueLabel: { type: 'string', description: 'Human-readable value label', optional: true },
value: { type: 'string', description: 'Raw field value (type depends on fieldType)' },
},
properties: CUSTOM_FIELD_ON_OBJECT_OUTPUT,
},
} as const satisfies OutputProperty
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10
View File
@@ -222,10 +222,13 @@ import {
} from '@/tools/asana'
import {
ashbyAddCandidateTagTool,
ashbyAnonymizeCandidateTool,
ashbyChangeApplicationSourceTool,
ashbyChangeApplicationStageTool,
ashbyCreateApplicationTool,
ashbyCreateCandidateTool,
ashbyCreateNoteTool,
ashbyDeleteApplicationTool,
ashbyGetApplicationTool,
ashbyGetCandidateTool,
ashbyGetJobPostingTool,
@@ -248,6 +251,8 @@ import {
ashbyListUsersTool,
ashbyRemoveCandidateTagTool,
ashbySearchCandidatesTool,
ashbySetCustomFieldValuesTool,
ashbySetCustomFieldValueTool,
ashbyUpdateCandidateTool,
} from '@/tools/ashby'
import {
@@ -5147,10 +5152,13 @@ export const tools: Record<string, ToolConfig> = {
asana_list_sections: asanaListSectionsTool,
asana_list_workspaces: asanaListWorkspacesTool,
ashby_add_candidate_tag: ashbyAddCandidateTagTool,
ashby_anonymize_candidate: ashbyAnonymizeCandidateTool,
ashby_change_application_source: ashbyChangeApplicationSourceTool,
ashby_change_application_stage: ashbyChangeApplicationStageTool,
ashby_create_application: ashbyCreateApplicationTool,
ashby_create_candidate: ashbyCreateCandidateTool,
ashby_create_note: ashbyCreateNoteTool,
ashby_delete_application: ashbyDeleteApplicationTool,
ashby_get_application: ashbyGetApplicationTool,
ashby_get_candidate: ashbyGetCandidateTool,
ashby_get_job: ashbyGetJobTool,
@@ -5173,6 +5181,8 @@ export const tools: Record<string, ToolConfig> = {
ashby_list_users: ashbyListUsersTool,
ashby_remove_candidate_tag: ashbyRemoveCandidateTagTool,
ashby_search_candidates: ashbySearchCandidatesTool,
ashby_set_custom_field_value: ashbySetCustomFieldValueTool,
ashby_set_custom_field_values: ashbySetCustomFieldValuesTool,
ashby_update_candidate: ashbyUpdateCandidateTool,
athena_batch_get_query_execution: athenaBatchGetQueryExecutionTool,
athena_create_named_query: athenaCreateNamedQueryTool,