Merge pull request #4293 from simstudioai/staging

v0.6.57: mothership reliability, ashby refactor, tables row count, copilot id fix, bun upgrade
This commit is contained in:
Theodore Li
2026-04-24 16:59:47 -07:00
committed by GitHub
76 changed files with 19404 additions and 1653 deletions

View File

@@ -1,4 +1,4 @@
FROM oven/bun:1.3.11-alpine
FROM oven/bun:1.3.13-alpine
# Install necessary packages for development
RUN apk add --no-cache \

View File

@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
bun-version: 1.3.13
- name: Setup Node
uses: actions/setup-node@v4

View File

@@ -23,7 +23,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
bun-version: 1.3.13
- name: Cache Bun dependencies
uses: actions/cache@v4
@@ -122,7 +122,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
bun-version: 1.3.13
- name: Cache Bun dependencies
uses: actions/cache@v4

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
bun-version: 1.3.13
- name: Cache Bun dependencies
uses: actions/cache@v4

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
bun-version: 1.3.13
- name: Setup Node.js for npm publishing
uses: actions/setup-node@v4

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
bun-version: 1.3.13
- name: Setup Node.js for npm publishing
uses: actions/setup-node@v4

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11
bun-version: 1.3.13
- name: Setup Node
uses: actions/setup-node@v4

View File

@@ -38,7 +38,7 @@ Integrate Ashby into the workflow. Manage candidates (list, get, create, update,
### `ashby_add_candidate_tag`
Adds a tag to a candidate in Ashby.
Adds a tag to a candidate in Ashby and returns the updated candidate.
#### Input
@@ -52,7 +52,37 @@ Adds a tag to a candidate in Ashby.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the tag was successfully added |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_change_application_stage`
@@ -71,8 +101,37 @@ Moves an application to a different interview stage. Requires an archive reason
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `applicationId` | string | Application UUID |
| `stageId` | string | New interview stage UUID |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_create_application`
@@ -95,7 +154,37 @@ Creates a new application for a candidate on a job. Optionally specify interview
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `applicationId` | string | Created application UUID |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_create_candidate`
@@ -107,7 +196,7 @@ Creates a new candidate record in Ashby.
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `name` | string | Yes | The candidate full name |
| `email` | string | Yes | Primary email address for the candidate |
| `email` | string | No | Primary email address for the candidate |
| `phoneNumber` | string | No | Primary phone number for the candidate |
| `linkedInUrl` | string | No | LinkedIn profile URL |
| `githubUrl` | string | No | GitHub profile URL |
@@ -117,17 +206,37 @@ Creates a new candidate record in Ashby.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Created candidate UUID |
| `name` | string | Full name |
| `primaryEmailAddress` | object | Primary email contact info |
| ↳ `value` | string | Email address |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary email |
| `primaryPhoneNumber` | object | Primary phone contact info |
| ↳ `value` | string | Phone number |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_create_note`
@@ -147,7 +256,15 @@ Creates a note on a candidate in Ashby. Supports plain text and HTML content (bo
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `noteId` | string | Created note UUID |
| `id` | string | Created note UUID |
| `createdAt` | string | ISO 8601 creation timestamp |
| `isPrivate` | boolean | Whether the note is private |
| `content` | string | Note content |
| `author` | object | Author of the note |
| ↳ `id` | string | Author user UUID |
| ↳ `firstName` | string | Author first name |
| ↳ `lastName` | string | Author last name |
| ↳ `email` | string | Author email |
### `ashby_get_application`
@@ -164,28 +281,37 @@ Retrieves full details about a single application by its ID.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Application UUID |
| `status` | string | Application status \(Active, Hired, Archived, Lead\) |
| `candidate` | object | Associated candidate |
| ↳ `id` | string | Candidate UUID |
| ↳ `name` | string | Candidate name |
| `job` | object | Associated job |
| ↳ `id` | string | Job UUID |
| ↳ `title` | string | Job title |
| `currentInterviewStage` | object | Current interview stage |
| ↳ `id` | string | Stage UUID |
| ↳ `title` | string | Stage title |
| ↳ `type` | string | Stage type |
| `source` | object | Application source |
| ↳ `id` | string | Source UUID |
| ↳ `title` | string | Source title |
| `archiveReason` | object | Reason for archival |
| ↳ `id` | string | Reason UUID |
| ↳ `text` | string | Reason text |
| ↳ `reasonType` | string | Reason type |
| `archivedAt` | string | ISO 8601 archive timestamp |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 last update timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_get_candidate`
@@ -202,27 +328,37 @@ Retrieves full details about a single candidate by their ID.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Candidate UUID |
| `name` | string | Full name |
| `primaryEmailAddress` | object | Primary email contact info |
| ↳ `value` | string | Email address |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary email |
| `primaryPhoneNumber` | object | Primary phone contact info |
| ↳ `value` | string | Phone number |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
| `profileUrl` | string | URL to the candidate Ashby profile |
| `position` | string | Current position or title |
| `company` | string | Current company |
| `linkedInUrl` | string | LinkedIn profile URL |
| `githubUrl` | string | GitHub profile URL |
| `tags` | array | Tags applied to the candidate |
| ↳ `id` | string | Tag UUID |
| `title` | string | Tag title |
| `applicationIds` | array | IDs of associated applications |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 last update timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_get_job`
@@ -239,16 +375,37 @@ Retrieves full details about a single job by its ID.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Job UUID |
| `title` | string | Job title |
| `status` | string | Job status \(Open, Closed, Draft, Archived\) |
| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
| `departmentId` | string | Department UUID |
| `locationId` | string | Location UUID |
| `descriptionPlain` | string | Job description in plain text |
| `isArchived` | boolean | Whether the job is archived |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 last update timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_get_job_posting`
@@ -260,6 +417,8 @@ Retrieves full details about a single job posting by its ID.
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `jobPostingId` | string | Yes | The UUID of the job posting to fetch |
| `expandApplicationFormDefinition` | boolean | No | Include application form definition in the response |
| `expandSurveyFormDefinitions` | boolean | No | Include survey form definitions in the response |
#### Output
@@ -267,14 +426,56 @@ Retrieves full details about a single job posting by its ID.
| --------- | ---- | ----------- |
| `id` | string | Job posting UUID |
| `title` | string | Job posting title |
| `jobId` | string | Associated job UUID |
| `locationName` | string | Location name |
| `descriptionPlain` | string | Full description in plain text |
| `descriptionHtml` | string | Full description in HTML |
| `descriptionSocial` | string | Shortened description for social sharing \(max 200 chars\) |
| `descriptionParts` | object | Description broken into opening, body, and closing sections |
| ↳ `descriptionOpening` | object | Opening \(from Job Boards theme settings\) |
| ↳ `html` | string | HTML content |
| ↳ `plain` | string | Plain text content |
| ↳ `descriptionBody` | object | Main description body |
| ↳ `html` | string | HTML content |
| ↳ `plain` | string | Plain text content |
| ↳ `descriptionClosing` | object | Closing \(from Job Boards theme settings\) |
| ↳ `html` | string | HTML content |
| ↳ `plain` | string | Plain text content |
| `departmentName` | string | Department name |
| `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) |
| `descriptionPlain` | string | Job posting description in plain text |
| `isListed` | boolean | Whether the posting is publicly listed |
| `teamName` | string | Team name |
| `teamNameHierarchy` | array | Hierarchy of team names from root to team |
| `jobId` | string | Associated job UUID |
| `locationName` | string | Primary location name |
| `locationIds` | object | Primary and secondary location UUIDs |
| ↳ `primaryLocationId` | string | Primary location UUID |
| ↳ `secondaryLocationIds` | array | Secondary location UUIDs |
| `address` | object | Postal address of the posting location |
| ↳ `postalAddress` | object | Structured postal address |
| ↳ `addressCountry` | string | Country |
| ↳ `addressRegion` | string | State or region |
| ↳ `addressLocality` | string | City or locality |
| ↳ `postalCode` | string | Postal code |
| ↳ `streetAddress` | string | Street address |
| `isRemote` | boolean | Whether the posting is remote |
| `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) |
| `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
| `isListed` | boolean | Whether publicly listed on the job board |
| `suppressDescriptionOpening` | boolean | Whether the theme opening is hidden on this posting |
| `suppressDescriptionClosing` | boolean | Whether the theme closing is hidden on this posting |
| `publishedDate` | string | ISO 8601 published date |
| `applicationDeadline` | string | ISO 8601 application deadline |
| `externalLink` | string | External link to the job posting |
| `applyLink` | string | Direct apply link |
| `compensation` | object | Compensation details for the posting |
| ↳ `compensationTierSummary` | string | Human-readable tier summary |
| ↳ `summaryComponents` | array | Structured compensation components |
| ↳ `summary` | string | Component summary |
| ↳ `compensationTypeLabel` | string | Component type label \(Salary, Commission, Bonus, Equity, etc.\) |
| ↳ `interval` | string | Payment interval \(e.g. annual, hourly\) |
| ↳ `currencyCode` | string | ISO 4217 currency code |
| ↳ `minValue` | number | Minimum value |
| ↳ `maxValue` | number | Maximum value |
| ↳ `shouldDisplayCompensationOnJobBoard` | boolean | Whether compensation is shown on the job board |
| `applicationLimitCalloutHtml` | string | HTML callout shown when application limit is reached |
| `updatedAt` | string | ISO 8601 last update timestamp |
### `ashby_get_offer`
@@ -291,20 +492,41 @@ Retrieves full details about a single offer by its ID.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Offer UUID |
| `offerStatus` | string | Offer status \(e.g. WaitingOnCandidateResponse, CandidateAccepted\) |
| `acceptanceStatus` | string | Acceptance status \(e.g. Accepted, Declined, Pending\) |
| `applicationId` | string | Associated application UUID |
| `startDate` | string | Offer start date |
| `salary` | object | Salary details |
| ↳ `currencyCode` | string | ISO 4217 currency code |
| ↳ `value` | number | Salary amount |
| `openingId` | string | Associated opening UUID |
| `createdAt` | string | ISO 8601 creation timestamp \(from latest version\) |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_list_applications`
Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date.
Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date.
#### Input
@@ -315,7 +537,6 @@ Lists all applications in an Ashby organization with pagination and optional fil
| `perPage` | number | No | Number of results per page \(default 100\) |
| `status` | string | No | Filter by application status: Active, Hired, Archived, or Lead |
| `jobId` | string | No | Filter applications by a specific job UUID |
| `candidateId` | string | No | Filter applications by a specific candidate UUID |
| `createdAfter` | string | No | Filter to applications created after this ISO 8601 timestamp \(e.g. 2024-01-01T00:00:00Z\) |
#### Output
@@ -323,23 +544,6 @@ Lists all applications in an Ashby organization with pagination and optional fil
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `applications` | array | List of applications |
| ↳ `id` | string | Application UUID |
| ↳ `status` | string | Application status \(Active, Hired, Archived, Lead\) |
| ↳ `candidate` | object | Associated candidate |
| ↳ `id` | string | Candidate UUID |
| ↳ `name` | string | Candidate name |
| ↳ `job` | object | Associated job |
| ↳ `id` | string | Job UUID |
| ↳ `title` | string | Job title |
| ↳ `currentInterviewStage` | object | Current interview stage |
| ↳ `id` | string | Stage UUID |
| ↳ `title` | string | Stage title |
| ↳ `type` | string | Stage type |
| ↳ `source` | object | Application source |
| ↳ `id` | string | Source UUID |
| ↳ `title` | string | Source title |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
@@ -352,6 +556,7 @@ Lists all archive reasons configured in Ashby.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `includeArchived` | boolean | No | Whether to include archived archive reasons in the response \(default false\) |
#### Output
@@ -360,7 +565,7 @@ Lists all archive reasons configured in Ashby.
| `archiveReasons` | array | List of archive reasons |
| ↳ `id` | string | Archive reason UUID |
| ↳ `text` | string | Archive reason text |
| ↳ `reasonType` | string | Reason type |
| ↳ `reasonType` | string | Reason type \(RejectedByCandidate, RejectedByOrg, Other\) |
| ↳ `isArchived` | boolean | Whether the reason is archived |
### `ashby_list_candidate_tags`
@@ -372,6 +577,10 @@ Lists all candidate tags configured in Ashby.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Ashby API Key |
| `includeArchived` | boolean | No | Whether to include archived candidate tags \(default false\) |
| `cursor` | string | No | Opaque pagination cursor from a previous response nextCursor value |
| `syncToken` | string | No | Sync token from a previous response to fetch only changed results |
| `perPage` | number | No | Number of results per page \(default 100\) |
#### Output
@@ -381,6 +590,9 @@ Lists all candidate tags configured in Ashby.
| ↳ `id` | string | Tag UUID |
| ↳ `title` | string | Tag title |
| ↳ `isArchived` | boolean | Whether the tag is archived |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
| `syncToken` | string | Sync token to use for incremental updates in future requests |
### `ashby_list_candidates`
@@ -399,18 +611,6 @@ Lists all candidates in an Ashby organization with cursor-based pagination.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | array | List of candidates |
| ↳ `id` | string | Candidate UUID |
| ↳ `name` | string | Full name |
| ↳ `primaryEmailAddress` | object | Primary email contact info |
| ↳ `value` | string | Email address |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary email |
| ↳ `primaryPhoneNumber` | object | Primary phone contact info |
| ↳ `value` | string | Phone number |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
@@ -431,9 +631,15 @@ Lists all custom field definitions configured in Ashby.
| `customFields` | array | List of custom field definitions |
| ↳ `id` | string | Custom field UUID |
| ↳ `title` | string | Custom field title |
| ↳ `fieldType` | string | Field type \(e.g. String, Number, Boolean\) |
| ↳ `objectType` | string | Object type the field applies to \(e.g. Candidate, Application, Job\) |
| ↳ `isPrivate` | boolean | Whether the custom field is private |
| ↳ `fieldType` | string | Field data type \(MultiValueSelect, NumberRange, String, Date, ValueSelect, Number, Currency, Boolean, LongText, CompensationRange\) |
| ↳ `objectType` | string | Object type the field applies to \(Application, Candidate, Employee, Job, Offer, Opening, Talent_Project\) |
| ↳ `isArchived` | boolean | Whether the custom field is archived |
| ↳ `isRequired` | boolean | Whether a value is required |
| ↳ `selectableValues` | array | Selectable values for MultiValueSelect fields \(empty for other field types\) |
| ↳ `label` | string | Display label |
| ↳ `value` | string | Stored value |
| ↳ `isArchived` | boolean | Whether archived |
### `ashby_list_departments`
@@ -452,8 +658,11 @@ Lists all departments in Ashby.
| `departments` | array | List of departments |
| ↳ `id` | string | Department UUID |
| ↳ `name` | string | Department name |
| ↳ `externalName` | string | Candidate-facing name used on job boards |
| ↳ `isArchived` | boolean | Whether the department is archived |
| ↳ `parentId` | string | Parent department UUID |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
### `ashby_list_interviews`
@@ -475,10 +684,24 @@ Lists interview schedules in Ashby, optionally filtered by application or interv
| --------- | ---- | ----------- |
| `interviewSchedules` | array | List of interview schedules |
| ↳ `id` | string | Interview schedule UUID |
| ↳ `status` | string | Schedule status \(NeedsScheduling, WaitingOnCandidateBooking, Scheduled, Complete, Cancelled, OnHold, etc.\) |
| ↳ `applicationId` | string | Associated application UUID |
| ↳ `interviewStageId` | string | Interview stage UUID |
| ↳ `status` | string | Schedule status |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
| ↳ `interviewEvents` | array | Scheduled interview events on this schedule |
| ↳ `id` | string | Event UUID |
| ↳ `interviewId` | string | Interview template UUID |
| ↳ `interviewScheduleId` | string | Parent schedule UUID |
| ↳ `interviewerUserIds` | array | User UUIDs of interviewers assigned to the event |
| ↳ `createdAt` | string | Event creation timestamp |
| ↳ `updatedAt` | string | Event last updated timestamp |
| ↳ `startTime` | string | Event start time |
| ↳ `endTime` | string | Event end time |
| ↳ `feedbackLink` | string | URL to submit feedback for the event |
| ↳ `location` | string | Physical location |
| ↳ `meetingLink` | string | Virtual meeting URL |
| ↳ `hasSubmittedFeedback` | boolean | Whether any feedback has been submitted |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
@@ -500,11 +723,22 @@ Lists all job postings in Ashby.
| ↳ `id` | string | Job posting UUID |
| ↳ `title` | string | Job posting title |
| ↳ `jobId` | string | Associated job UUID |
| ↳ `locationName` | string | Location name |
| ↳ `departmentName` | string | Department name |
| ↳ `employmentType` | string | Employment type \(e.g. FullTime, PartTime, Contract\) |
| ↳ `teamName` | string | Team name |
| ↳ `locationName` | string | Primary location display name |
| ↳ `locationIds` | object | Primary and secondary location UUIDs |
| ↳ `primaryLocationId` | string | Primary location UUID |
| ↳ `secondaryLocationIds` | array | Secondary location UUIDs |
| ↳ `workplaceType` | string | Workplace type \(OnSite, Remote, Hybrid\) |
| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
| ↳ `isListed` | boolean | Whether the posting is publicly listed |
| ↳ `publishedDate` | string | ISO 8601 published date |
| ↳ `applicationDeadline` | string | ISO 8601 application deadline |
| ↳ `externalLink` | string | External link to the job posting |
| ↳ `applyLink` | string | Direct apply link for the job posting |
| ↳ `compensationTierSummary` | string | Compensation tier summary for job boards |
| ↳ `shouldDisplayCompensationOnJobBoard` | boolean | Whether compensation is shown on the job board |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
### `ashby_list_jobs`
@@ -524,14 +758,6 @@ Lists all jobs in an Ashby organization. By default returns Open, Closed, and Ar
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `jobs` | array | List of jobs |
| ↳ `id` | string | Job UUID |
| ↳ `title` | string | Job title |
| ↳ `status` | string | Job status \(Open, Closed, Archived, Draft\) |
| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract, Temporary\) |
| ↳ `departmentId` | string | Department UUID |
| ↳ `locationId` | string | Location UUID |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
@@ -552,12 +778,18 @@ Lists all locations configured in Ashby.
| `locations` | array | List of locations |
| ↳ `id` | string | Location UUID |
| ↳ `name` | string | Location name |
| ↳ `externalName` | string | Candidate-facing name used on job boards |
| ↳ `isArchived` | boolean | Whether the location is archived |
| ↳ `isRemote` | boolean | Whether this is a remote location |
| ↳ `address` | object | Location address |
| ↳ `city` | string | City |
| ↳ `region` | string | State or region |
| ↳ `country` | string | Country |
| ↳ `isRemote` | boolean | Whether the location is remote \(use workplaceType instead\) |
| ↳ `workplaceType` | string | Workplace type \(OnSite, Hybrid, Remote\) |
| ↳ `parentLocationId` | string | Parent location UUID |
| ↳ `type` | string | Location component type \(Location, LocationHierarchy\) |
| ↳ `address` | object | Location postal address |
| ↳ `addressCountry` | string | Country |
| ↳ `addressRegion` | string | State or region |
| ↳ `addressLocality` | string | City or locality |
| ↳ `postalCode` | string | Postal code |
| ↳ `streetAddress` | string | Street address |
### `ashby_list_notes`
@@ -579,6 +811,7 @@ Lists all notes on a candidate with pagination support.
| `notes` | array | List of notes on the candidate |
| ↳ `id` | string | Note UUID |
| ↳ `content` | string | Note content |
| ↳ `isPrivate` | boolean | Whether the note is private |
| ↳ `author` | object | Note author |
| ↳ `id` | string | Author user UUID |
| ↳ `firstName` | string | First name |
@@ -605,16 +838,6 @@ Lists all offers with their latest version in an Ashby organization.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `offers` | array | List of offers |
| ↳ `id` | string | Offer UUID |
| ↳ `offerStatus` | string | Offer status |
| ↳ `acceptanceStatus` | string | Acceptance status |
| ↳ `applicationId` | string | Associated application UUID |
| ↳ `startDate` | string | Offer start date |
| ↳ `salary` | object | Salary details |
| ↳ `currencyCode` | string | ISO 4217 currency code |
| ↳ `value` | number | Salary amount |
| ↳ `openingId` | string | Associated opening UUID |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
@@ -634,12 +857,6 @@ Lists all openings in Ashby with pagination.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `openings` | array | List of openings |
| ↳ `id` | string | Opening UUID |
| ↳ `openingState` | string | Opening state \(Approved, Closed, Draft, Filled, Open\) |
| ↳ `isArchived` | boolean | Whether the opening is archived |
| ↳ `openedAt` | string | ISO 8601 opened timestamp |
| ↳ `closedAt` | string | ISO 8601 closed timestamp |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
@@ -661,6 +878,10 @@ Lists all candidate sources configured in Ashby.
| ↳ `id` | string | Source UUID |
| ↳ `title` | string | Source title |
| ↳ `isArchived` | boolean | Whether the source is archived |
| ↳ `sourceType` | object | Source type grouping |
| ↳ `id` | string | Source type UUID |
| ↳ `title` | string | Source type title |
| ↳ `isArchived` | boolean | Whether archived |
### `ashby_list_users`
@@ -679,18 +900,12 @@ Lists all users in Ashby with pagination.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `users` | array | List of users |
| ↳ `id` | string | User UUID |
| ↳ `firstName` | string | First name |
| ↳ `lastName` | string | Last name |
| ↳ `email` | string | Email address |
| ↳ `isEnabled` | boolean | Whether the user account is enabled |
| ↳ `globalRole` | string | User role \(Organization Admin, Elevated Access, Limited Access, External Recruiter\) |
| `moreDataAvailable` | boolean | Whether more pages of results exist |
| `nextCursor` | string | Opaque cursor for fetching the next page |
### `ashby_remove_candidate_tag`
Removes a tag from a candidate in Ashby.
Removes a tag from a candidate in Ashby and returns the updated candidate.
#### Input
@@ -704,7 +919,37 @@ Removes a tag from a candidate in Ashby.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether the tag was successfully removed |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |
### `ashby_search_candidates`
@@ -723,18 +968,6 @@ Searches for candidates by name and/or email with AND logic. Results are limited
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `candidates` | array | Matching candidates \(max 100 results\) |
| ↳ `id` | string | Candidate UUID |
| ↳ `name` | string | Full name |
| ↳ `primaryEmailAddress` | object | Primary email contact info |
| ↳ `value` | string | Email address |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary email |
| ↳ `primaryPhoneNumber` | object | Primary phone contact info |
| ↳ `value` | string | Phone number |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
| ↳ `createdAt` | string | ISO 8601 creation timestamp |
| ↳ `updatedAt` | string | ISO 8601 last update timestamp |
### `ashby_update_candidate`
@@ -758,26 +991,36 @@ Updates an existing candidate record in Ashby. Only provided fields are changed.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Candidate UUID |
| `name` | string | Full name |
| `primaryEmailAddress` | object | Primary email contact info |
| ↳ `value` | string | Email address |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary email |
| `primaryPhoneNumber` | object | Primary phone contact info |
| ↳ `value` | string | Phone number |
| ↳ `type` | string | Contact type \(Personal, Work, Other\) |
| ↳ `isPrimary` | boolean | Whether this is the primary phone |
| `profileUrl` | string | URL to the candidate Ashby profile |
| `position` | string | Current position or title |
| `company` | string | Current company |
| `linkedInUrl` | string | LinkedIn profile URL |
| `githubUrl` | string | GitHub profile URL |
| `tags` | array | Tags applied to the candidate |
| ↳ `id` | string | Tag UUID |
| `title` | string | Tag title |
| `applicationIds` | array | IDs of associated applications |
| `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, compensation with compensationTiers\[\]\) |
| `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 | List of custom field definitions \(id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues\[\] \{label, value, isArchived\}\) |
| `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, managerId\) |
| `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 details \(id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses\[\], phoneNumbers\[\], socialLinks\[\], customFields\[\], source, creditedToUser, createdAt, updatedAt\) |
| `job` | json | Job details \(id, title, status, employmentType, locationId, departmentId, hiringTeam\[\], author, location, openings\[\], compensation, 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, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt\) |
| `content` | string | Note content |
| `author` | json | Note author \(id, firstName, lastName, email, globalRole, isEnabled\) |
| `isPrivate` | boolean | Whether the note is private |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 last update timestamp |
| `moreDataAvailable` | boolean | Whether more pages exist |
| `nextCursor` | string | Pagination cursor for next page |
| `syncToken` | string | Sync token for incremental updates |

View File

@@ -97,6 +97,14 @@ Trigger workflow when a candidate is hired
| ↳ `job` | object | job output from the tool |
| ↳ `id` | string | Job UUID |
| ↳ `title` | string | Job title |
| `offer` | object | offer output from the tool |
| ↳ `id` | string | Accepted offer UUID |
| ↳ `applicationId` | string | Associated application UUID |
| ↳ `acceptanceStatus` | string | Offer acceptance status |
| ↳ `offerStatus` | string | Offer process status |
| ↳ `decidedAt` | string | Offer decision timestamp \(ISO 8601\) |
| ↳ `latestVersion` | object | latestVersion output from the tool |
| ↳ `id` | string | Latest offer version UUID |
---

View File

@@ -1031,7 +1031,7 @@
},
{
"name": "List Applications",
"description": "Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date."
"description": "Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date."
},
{
"name": "Get Application",
@@ -1051,11 +1051,11 @@
},
{
"name": "Add Candidate Tag",
"description": "Adds a tag to a candidate in Ashby."
"description": "Adds a tag to a candidate in Ashby and returns the updated candidate."
},
{
"name": "Remove Candidate Tag",
"description": "Removes a tag from a candidate in Ashby."
"description": "Removes a tag from a candidate in Ashby and returns the updated candidate."
},
{
"name": "Get Offer",

View File

@@ -38,6 +38,7 @@ vi.mock('@/lib/copilot/request/session', () => ({
}),
encodeSSEEnvelope: (event: Record<string, unknown>) =>
new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`),
encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`),
SSE_RESPONSE_HEADERS: {
'Content-Type': 'text/event-stream',
},
@@ -132,6 +133,7 @@ describe('copilot chat stream replay route', () => {
)
const chunks = await readAllChunks(response)
expect(chunks[0]).toBe(': accepted\n\n')
expect(chunks.join('')).toContain(
JSON.stringify({
status: MothershipStreamV1CompletionStatus.cancelled,

View File

@@ -19,6 +19,7 @@ import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel'
import {
checkForReplayGap,
createEvent,
encodeSSEComment,
encodeSSEEnvelope,
readEvents,
readFilePreviewSessions,
@@ -31,6 +32,7 @@ export const maxDuration = 3600
const logger = createLogger('CopilotChatStreamAPI')
const POLL_INTERVAL_MS = 250
const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000
const MAX_STREAM_MS = 60 * 60 * 1000
function extractCanonicalRequestId(value: unknown): string {
@@ -266,6 +268,7 @@ async function handleResumeRequestBody({
let controllerClosed = false
let sawTerminalEvent = false
let currentRequestId = extractRunRequestId(run)
let lastWriteTime = Date.now()
// Stamp the logical request id + chat id on the resume root as soon
// as we resolve them from the run row, so TraceQL joins work on
// resume legs the same way they do on the original POST.
@@ -291,6 +294,19 @@ async function handleResumeRequestBody({
if (controllerClosed) return false
try {
controller.enqueue(encodeSSEEnvelope(payload))
lastWriteTime = Date.now()
return true
} catch {
controllerClosed = true
return false
}
}
const enqueueComment = (comment: string) => {
if (controllerClosed) return false
try {
controller.enqueue(encodeSSEComment(comment))
lastWriteTime = Date.now()
return true
} catch {
controllerClosed = true
@@ -306,7 +322,6 @@ async function handleResumeRequestBody({
const flushEvents = async () => {
const events = await readEvents(streamId, cursor)
if (events.length > 0) {
totalEventsFlushed += events.length
logger.debug('[Resume] Flushing events', {
streamId,
afterCursor: cursor,
@@ -314,14 +329,15 @@ async function handleResumeRequestBody({
})
}
for (const envelope of events) {
if (!enqueueEvent(envelope)) {
break
}
totalEventsFlushed += 1
cursor = envelope.stream.cursor ?? String(envelope.seq)
currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId
if (envelope.type === MothershipStreamV1EventType.complete) {
sawTerminalEvent = true
}
if (!enqueueEvent(envelope)) {
break
}
}
}
@@ -341,21 +357,30 @@ async function handleResumeRequestBody({
reason: options?.reason,
requestId: currentRequestId,
})) {
if (!enqueueEvent(envelope)) {
break
}
cursor = envelope.stream.cursor ?? String(envelope.seq)
if (envelope.type === MothershipStreamV1EventType.complete) {
sawTerminalEvent = true
}
if (!enqueueEvent(envelope)) {
break
}
}
}
try {
enqueueComment('accepted')
const gap = await checkForReplayGap(streamId, afterCursor, currentRequestId)
if (gap) {
for (const envelope of gap.envelopes) {
enqueueEvent(envelope)
if (!enqueueEvent(envelope)) {
break
}
cursor = envelope.stream.cursor ?? String(envelope.seq)
currentRequestId = extractEnvelopeRequestId(envelope) || currentRequestId
if (envelope.type === MothershipStreamV1EventType.complete) {
sawTerminalEvent = true
}
}
return
}
@@ -408,6 +433,10 @@ async function handleResumeRequestBody({
break
}
if (Date.now() - lastWriteTime >= REPLAY_KEEPALIVE_INTERVAL_MS) {
enqueueComment('keepalive')
}
await sleep(POLL_INTERVAL_MS)
}
if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) {

View File

@@ -66,6 +66,12 @@ const QueryRowsSchema = z.object({
.min(0, 'Offset must be 0 or greater')
.optional()
.default(0),
includeTotal: z
.preprocess(
(val) => (val === null || val === undefined || val === '' ? undefined : val === 'true'),
z.boolean().optional()
)
.default(true),
})
const nonEmptyFilter = z
@@ -328,6 +334,7 @@ export const GET = withRouteHandler(
const sortParam = searchParams.get('sort')
const limit = searchParams.get('limit')
const offset = searchParams.get('offset')
const includeTotalParam = searchParams.get('includeTotal')
let filter: Record<string, unknown> | undefined
let sort: Sort | undefined
@@ -349,6 +356,7 @@ export const GET = withRouteHandler(
sort,
limit,
offset,
includeTotal: includeTotalParam,
})
const accessResult = await checkAccess(tableId, authResult.userId, 'read')
@@ -398,17 +406,19 @@ export const GET = withRouteHandler(
query = query.orderBy(userTableRows.position) as typeof query
}
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(userTableRows)
.where(and(...baseConditions))
const [{ count: totalCount }] = await countQuery
let totalCount: number | null = null
if (validated.includeTotal) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(userTableRows)
.where(and(...baseConditions))
totalCount = Number(count)
}
const rows = await query.limit(validated.limit).offset(validated.offset)
logger.info(
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})`
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount ?? 'n/a'})`
)
return NextResponse.json({
@@ -424,7 +434,7 @@ export const GET = withRouteHandler(
r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
rowCount: rows.length,
totalCount: Number(totalCount),
totalCount,
limit: validated.limit,
offset: validated.offset,
},

View File

@@ -71,6 +71,12 @@ const QueryRowsSchema = z.object({
.optional()
)
.default(0),
includeTotal: z
.preprocess(
(val) => (val === null || val === undefined || val === '' ? undefined : val === 'true'),
z.boolean().optional()
)
.default(true),
})
const nonEmptyFilter = z
@@ -219,6 +225,7 @@ export const GET = withRouteHandler(
sort,
limit: searchParams.get('limit'),
offset: searchParams.get('offset'),
includeTotal: searchParams.get('includeTotal'),
})
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
@@ -268,16 +275,37 @@ export const GET = withRouteHandler(
query = query.orderBy(userTableRows.position) as typeof query
}
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(userTableRows)
.where(and(...baseConditions))
const rowsPromise = query.limit(validated.limit).offset(validated.offset)
const [countResult, rows] = await Promise.all([
countQuery,
query.limit(validated.limit).offset(validated.offset),
])
const totalCount = countResult[0].count
let totalCount: number | null = null
if (validated.includeTotal) {
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(userTableRows)
.where(and(...baseConditions))
const [countResult, rows] = await Promise.all([countQuery, rowsPromise])
totalCount = Number(countResult[0].count)
return NextResponse.json({
success: true,
data: {
rows: rows.map((r) => ({
id: r.id,
data: r.data,
position: r.position,
createdAt:
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt:
r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
rowCount: rows.length,
totalCount,
limit: validated.limit,
offset: validated.offset,
},
})
}
const rows = await rowsPromise
return NextResponse.json({
success: true,
@@ -292,7 +320,7 @@ export const GET = withRouteHandler(
r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
rowCount: rows.length,
totalCount: Number(totalCount),
totalCount,
limit: validated.limit,
offset: validated.offset,
},

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useLayoutEffect, useRef } from 'react'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
@@ -22,6 +22,7 @@ import type {
QueuedMessage,
} from '@/app/workspace/[workspaceId]/home/types'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import { useProgressiveList } from '@/hooks/use-progressive-list'
import type { ChatContext } from '@/stores/panel'
import { MothershipChatSkeleton } from './mothership-chat-skeleton'
@@ -104,6 +105,21 @@ export function MothershipChat({
scrollOnMount: true,
})
const hasMessages = messages.length > 0
const stagingKey = chatId ?? 'pending-chat'
const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey)
const stagedMessageCount = stagedMessages.length
const stagedOffset = messages.length - stagedMessages.length
const precedingUserContentByIndex = useMemo(() => {
const contentByIndex: Array<string | undefined> = []
let lastUserContent: string | undefined
for (const [index, message] of messages.entries()) {
contentByIndex[index] = lastUserContent
if (message.role === 'user') {
lastUserContent = message.content
}
}
return contentByIndex
}, [messages])
const initialScrollDoneRef = useRef(false)
const userInputRef = useRef<UserInputHandle>(null)
const handleSendQueuedHead = useCallback(() => {
@@ -134,6 +150,11 @@ export function MothershipChat({
scrollToBottom()
}, [hasMessages, initialScrollBlocked, scrollToBottom])
useLayoutEffect(() => {
if (!isStaging || initialScrollBlocked || !initialScrollDoneRef.current) return
scrollToBottom()
}, [isStaging, stagedMessageCount, initialScrollBlocked, scrollToBottom])
return (
<div className={cn('flex h-full min-h-0 flex-col', className)}>
<div ref={scrollContainerRef} className={styles.scrollContainer}>
@@ -141,7 +162,8 @@ export function MothershipChat({
<MothershipChatSkeleton layout={layout} />
) : (
<div className={styles.content}>
{messages.map((msg, index) => {
{stagedMessages.map((msg, localIndex) => {
const index = stagedOffset + localIndex
if (msg.role === 'user') {
const hasAttachments = Boolean(msg.attachments?.length)
return (
@@ -177,10 +199,7 @@ export function MothershipChat({
}
const isLastMessage = index === messages.length - 1
const precedingUserMsg = [...messages]
.slice(0, index)
.reverse()
.find((m) => m.role === 'user')
const precedingUserContent = precedingUserContentByIndex[index]
return (
<div key={msg.id} className={styles.assistantRow}>
@@ -196,7 +215,7 @@ export function MothershipChat({
<MessageActions
content={msg.content}
chatId={chatId}
userQuery={precedingUserMsg?.content}
userQuery={precedingUserContent}
requestId={msg.requestId}
/>
</div>

View File

@@ -34,6 +34,7 @@ export function useTableData({
offset: 0,
filter: queryOptions.filter,
sort: queryOptions.sort,
includeTotal: false,
enabled: Boolean(workspaceId && tableId),
})

View File

@@ -6,6 +6,7 @@ import type { ComboboxOption } from '@/components/emcn'
import { useTableColumns } from '@/lib/table/hooks'
import type { FilterRule } from '@/lib/table/query-builder/constants'
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
import { useCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { FilterRuleRow } from './components/filter-rule-row'
@@ -40,7 +41,7 @@ export function FilterBuilder({
tableIdSubBlockId = 'tableId',
}: FilterBuilderProps) {
const [storeValue, setStoreValue] = useSubBlockValue<FilterRule[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const tableIdValue = useCanonicalSubBlockValue<string>(blockId, tableIdSubBlockId)
const dynamicColumns = useTableColumns({ tableId: tableIdValue })
const columns = useMemo(() => {

View File

@@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id'
import type { ComboboxOption } from '@/components/emcn'
import { useTableColumns } from '@/lib/table/hooks'
import { SORT_DIRECTIONS, type SortRule } from '@/lib/table/query-builder/constants'
import { useCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { SortRuleRow } from './components/sort-rule-row'
@@ -36,7 +37,7 @@ export function SortBuilder({
tableIdSubBlockId = 'tableId',
}: SortBuilderProps) {
const [storeValue, setStoreValue] = useSubBlockValue<SortRule[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const tableIdValue = useCanonicalSubBlockValue<string>(blockId, tableIdSubBlockId)
const dynamicColumns = useTableColumns({ tableId: tableIdValue, includeBuiltIn: true })
const columns = useMemo(() => {

View File

@@ -0,0 +1,49 @@
import { useCallback, useMemo } from 'react'
import { isEqual } from 'es-toolkit'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks/registry'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Read a sub-block value by either its raw subBlockId or its canonicalParamId.
*
* `useSubBlockValue` only looks up the raw subBlockId. For fields that use
* `canonicalParamId` to unify basic/advanced inputs (e.g. `tableSelector` vs
* `manualTableId` both mapping to `tableId`), this hook resolves to whichever
* member of the canonical group currently holds the value.
*/
export function useCanonicalSubBlockValue<T = unknown>(
blockId: string,
canonicalOrSubBlockId: string
): T | null {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockState = useWorkflowStore((state) => state.blocks[blockId])
const blockConfig = blockState?.type ? getBlock(blockState.type) : null
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const canonicalModeOverrides = blockState?.data?.canonicalModes
return useStoreWithEqualityFn(
useSubBlockStore,
useCallback(
(state) => {
if (!activeWorkflowId) return null
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] || {}
const resolved = resolveDependencyValue(
canonicalOrSubBlockId,
blockValues,
canonicalIndex,
canonicalModeOverrides
)
return (resolved ?? null) as T | null
},
[activeWorkflowId, blockId, canonicalOrSubBlockId, canonicalIndex, canonicalModeOverrides]
),
(a, b) => isEqual(a, b)
)
}

View File

@@ -113,7 +113,6 @@ export const AshbyBlock: BlockConfig = {
id: 'email',
title: 'Email',
type: 'short-input',
required: { field: 'operation', value: 'create_candidate' },
placeholder: 'Email address',
condition: { field: 'operation', value: ['create_candidate', 'update_candidate'] },
},
@@ -308,14 +307,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
condition: { field: 'operation', value: 'list_applications' },
mode: 'advanced',
},
{
id: 'filterCandidateId',
title: 'Candidate ID Filter',
type: 'short-input',
placeholder: 'Filter by candidate UUID',
condition: { field: 'operation', value: 'list_applications' },
mode: 'advanced',
},
{
id: 'createdAfter',
title: 'Created After',
@@ -366,6 +357,7 @@ Output only the ISO 8601 timestamp string, nothing else.`,
'list_openings',
'list_users',
'list_interviews',
'list_candidate_tags',
],
},
mode: 'advanced',
@@ -386,10 +378,43 @@ Output only the ISO 8601 timestamp string, nothing else.`,
'list_openings',
'list_users',
'list_interviews',
'list_candidate_tags',
],
},
mode: 'advanced',
},
{
id: 'syncToken',
title: 'Sync Token',
type: 'short-input',
placeholder: 'Sync token for incremental updates',
condition: { field: 'operation', value: 'list_candidate_tags' },
mode: 'advanced',
},
{
id: 'includeArchived',
title: 'Include Archived',
type: 'switch',
condition: {
field: 'operation',
value: ['list_candidate_tags', 'list_archive_reasons'],
},
mode: 'advanced',
},
{
id: 'expandApplicationFormDefinition',
title: 'Include Application Form Definition',
type: 'switch',
condition: { field: 'operation', value: 'get_job_posting' },
mode: 'advanced',
},
{
id: 'expandSurveyFormDefinitions',
title: 'Include Survey Form Definitions',
type: 'switch',
condition: { field: 'operation', value: 'get_job_posting' },
mode: 'advanced',
},
{
id: 'tagId',
title: 'Tag ID',
@@ -476,11 +501,25 @@ Output only the ISO 8601 timestamp string, nothing else.`,
if (params.searchEmail) result.email = params.searchEmail
if (params.filterStatus) result.status = params.filterStatus
if (params.filterJobId) result.jobId = params.filterJobId
if (params.filterCandidateId) result.candidateId = params.filterCandidateId
if (params.jobStatus) result.status = params.jobStatus
if (params.sendNotifications === 'true' || params.sendNotifications === true) {
result.sendNotifications = true
}
if (params.includeArchived === 'true' || params.includeArchived === true) {
result.includeArchived = true
}
if (
params.expandApplicationFormDefinition === 'true' ||
params.expandApplicationFormDefinition === true
) {
result.expandApplicationFormDefinition = true
}
if (
params.expandSurveyFormDefinitions === 'true' ||
params.expandSurveyFormDefinitions === true
) {
result.expandSurveyFormDefinitions = true
}
if (params.appCandidateId) result.candidateId = params.appCandidateId
if (params.appCreatedAt) result.createdAt = params.appCreatedAt
if (params.updateName) result.name = params.updateName
@@ -515,11 +554,20 @@ Output only the ISO 8601 timestamp string, nothing else.`,
sendNotifications: { type: 'boolean', description: 'Send notifications' },
filterStatus: { type: 'string', description: 'Application status filter' },
filterJobId: { type: 'string', description: 'Job UUID filter' },
filterCandidateId: { type: 'string', description: 'Candidate UUID filter' },
createdAfter: { type: 'string', description: 'Filter by creation date' },
jobStatus: { type: 'string', description: 'Job status filter' },
cursor: { type: 'string', description: 'Pagination cursor' },
perPage: { type: 'number', description: 'Results per page' },
syncToken: { type: 'string', description: 'Sync token for incremental updates' },
includeArchived: { type: 'boolean', description: 'Include archived records' },
expandApplicationFormDefinition: {
type: 'boolean',
description: 'Include application form definition in job posting',
},
expandSurveyFormDefinitions: {
type: 'boolean',
description: 'Include survey form definitions in job posting',
},
tagId: { type: 'string', description: 'Tag UUID' },
offerId: { type: 'string', description: 'Offer UUID' },
jobPostingId: { type: 'string', description: 'Job posting UUID' },
@@ -530,93 +578,113 @@ Output only the ISO 8601 timestamp string, nothing else.`,
candidates: {
type: 'json',
description:
'List of candidates (id, name, primaryEmailAddress, primaryPhoneNumber, createdAt, updatedAt)',
'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: {
type: 'json',
description:
'List of jobs (id, title, status, employmentType, departmentId, locationId, createdAt, updatedAt)',
'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, compensation with compensationTiers[])',
},
applications: {
type: 'json',
description:
'List of applications (id, status, candidate, job, currentInterviewStage, source, createdAt, updatedAt)',
'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: {
type: 'json',
description: 'List of notes (id, content, author, createdAt)',
description: 'List of notes (id, content, author, isPrivate, createdAt)',
},
offers: {
type: 'json',
description:
'List of offers (id, offerStatus, acceptanceStatus, applicationId, startDate, salary, openingId)',
'List of offers (id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion with id/startDate/salary/createdAt/openingId/customFields[]/fileHandles[]/author/approvalStatus)',
},
archiveReasons: {
type: 'json',
description: 'List of archive reasons (id, text, reasonType, isArchived)',
description:
'List of archive reasons (id, text, reasonType [RejectedByCandidate/RejectedByOrg/Other], isArchived)',
},
sources: {
type: 'json',
description: 'List of sources (id, title, isArchived)',
description: 'List of sources (id, title, isArchived, sourceType {id, title, isArchived})',
},
customFields: {
type: 'json',
description: 'List of custom fields (id, title, fieldType, objectType, isArchived)',
description:
'List of custom field definitions (id, title, isPrivate, fieldType, objectType, isArchived, isRequired, selectableValues[] {label, value, isArchived})',
},
departments: {
type: 'json',
description: 'List of departments (id, name, isArchived, parentId)',
description:
'List of departments (id, name, externalName, isArchived, parentId, createdAt, updatedAt)',
},
locations: {
type: 'json',
description: 'List of locations (id, name, isArchived, isRemote, address)',
description:
'List of locations (id, name, externalName, isArchived, isRemote, workplaceType, parentLocationId, type, address with addressCountry/Region/Locality/postalCode/streetAddress)',
},
jobPostings: {
type: 'json',
description:
'List of job postings (id, title, jobId, locationName, departmentName, employmentType, isListed, publishedDate)',
'List of job postings (id, title, jobId, departmentName, teamName, locationName, locationIds, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensationTierSummary, shouldDisplayCompensationOnJobBoard, updatedAt)',
},
openings: {
type: 'json',
description: 'List of openings (id, openingState, isArchived, openedAt, closedAt)',
description:
'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: {
type: 'json',
description: 'List of users (id, firstName, lastName, email, isEnabled, globalRole)',
description:
'List of users (id, firstName, lastName, email, globalRole, isEnabled, updatedAt, managerId)',
},
interviewSchedules: {
type: 'json',
description:
'List of interview schedules (id, applicationId, interviewStageId, status, createdAt)',
'List of interview schedules (id, applicationId, interviewStageId, interviewEvents[] with interviewerUserIds/startTime/endTime/feedbackLink/location/meetingLink/hasSubmittedFeedback, status, scheduledBy, createdAt, updatedAt)',
},
tags: {
type: 'json',
description: 'List of candidate tags (id, title, isArchived)',
},
stageId: { type: 'string', description: 'Interview stage UUID after stage change' },
success: { type: 'boolean', description: 'Whether the operation succeeded' },
offerStatus: {
type: 'string',
description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)',
},
acceptanceStatus: {
type: 'string',
description: 'Acceptance status (e.g. Accepted, Declined, Pending)',
},
applicationId: { type: 'string', description: 'Associated application UUID' },
openingId: { type: 'string', description: 'Opening UUID associated with the offer' },
salary: {
type: 'json',
description: 'Salary details from latest version (currencyCode, value)',
},
startDate: { type: 'string', description: 'Offer start date from latest version' },
id: { type: 'string', description: 'Resource UUID' },
name: { type: 'string', description: 'Resource name' },
title: { type: 'string', description: 'Job title' },
title: { type: 'string', description: 'Job title or job posting title' },
status: { type: 'string', description: 'Status' },
noteId: { type: 'string', description: 'Created note UUID' },
candidate: {
type: 'json',
description:
'Candidate details (id, name, primaryEmailAddress, primaryPhoneNumber, emailAddresses[], phoneNumbers[], socialLinks[], customFields[], source, creditedToUser, createdAt, updatedAt)',
},
job: {
type: 'json',
description:
'Job details (id, title, status, employmentType, locationId, departmentId, hiringTeam[], author, location, openings[], compensation, createdAt, updatedAt)',
},
application: {
type: 'json',
description:
'Application details (id, status, customFields[], candidate, currentInterviewStage, source, archiveReason, job, hiringTeam[], createdAt, updatedAt)',
},
offer: {
type: 'json',
description:
'Offer details (id, decidedAt, applicationId, acceptanceStatus, offerStatus, latestVersion)',
},
jobPosting: {
type: 'json',
description:
'Job posting details (id, title, descriptionPlain, descriptionHtml, descriptionSocial, descriptionParts, departmentName, teamName, teamNameHierarchy[], jobId, locationName, locationIds, linkedData, address, isRemote, workplaceType, employmentType, isListed, publishedDate, applicationDeadline, externalLink, applyLink, compensation, updatedAt)',
},
content: { type: 'string', description: 'Note content' },
author: {
type: 'json',
description: 'Note author (id, firstName, lastName, email, globalRole, isEnabled)',
},
isPrivate: { type: 'boolean', description: 'Whether the note is private' },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
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' },
},
}

View File

@@ -38,11 +38,14 @@ interface TableRowsParams {
offset: number
filter?: Filter | null
sort?: Sort | null
/** When `false`, skip the server-side `COUNT(*)` and receive `totalCount: null`. */
includeTotal?: boolean
}
interface TableRowsResponse {
rows: TableRow[]
totalCount: number
/** `null` when the request opted out of the count via `includeTotal: false`. */
totalCount: number | null
}
interface RowMutationContext {
@@ -64,12 +67,14 @@ function createRowsParamsKey({
offset,
filter,
sort,
includeTotal,
}: Omit<TableRowsParams, 'workspaceId' | 'tableId'>): string {
return JSON.stringify({
limit,
offset,
filter: filter ?? null,
sort: sort ?? null,
includeTotal: includeTotal ?? true,
})
}
@@ -98,6 +103,7 @@ async function fetchTableRows({
offset,
filter,
sort,
includeTotal,
signal,
}: TableRowsParams & { signal?: AbortSignal }): Promise<TableRowsResponse> {
const searchParams = new URLSearchParams({
@@ -114,6 +120,10 @@ async function fetchTableRows({
searchParams.set('sort', JSON.stringify(sort))
}
if (includeTotal === false) {
searchParams.set('includeTotal', 'false')
}
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`, { signal })
if (!res.ok) {
const error = await res.json().catch(() => ({}))
@@ -121,15 +131,15 @@ async function fetchTableRows({
}
const json: {
data?: { rows: TableRow[]; totalCount: number }
data?: { rows: TableRow[]; totalCount: number | null }
rows?: TableRow[]
totalCount?: number
totalCount?: number | null
} = await res.json()
const data = json.data || json
return {
rows: (data.rows || []) as TableRow[],
totalCount: data.totalCount || 0,
totalCount: data.totalCount ?? null,
}
}
@@ -209,9 +219,10 @@ export function useTableRows({
offset,
filter,
sort,
includeTotal,
enabled = true,
}: TableRowsParams & { enabled?: boolean }) {
const paramsKey = createRowsParamsKey({ limit, offset, filter, sort })
const paramsKey = createRowsParamsKey({ limit, offset, filter, sort, includeTotal })
return useQuery({
queryKey: tableKeys.rows(tableId, paramsKey),
@@ -223,6 +234,7 @@ export function useTableRows({
offset,
filter,
sort,
includeTotal,
signal,
}),
enabled: Boolean(workspaceId && tableId) && enabled,
@@ -393,7 +405,11 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
r.position >= row.position ? { ...r, position: r.position + 1 } : r
)
const rows: TableRow[] = [...shifted, row].sort((a, b) => a.position - b.position)
return { ...old, rows, totalCount: old.totalCount + 1 }
return {
...old,
rows,
totalCount: old.totalCount === null ? null : old.totalCount + 1,
}
}
)
},

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
interface ProgressiveListOptions {
/** Number of items to render in the initial batch (most recent items) */
@@ -14,15 +14,31 @@ const DEFAULTS = {
batchSize: 5,
} satisfies Required<ProgressiveListOptions>
interface ProgressiveListState {
key: string
count: number
caughtUp: boolean
}
function createInitialState(
key: string,
itemCount: number,
initialBatch: number
): ProgressiveListState {
const count = Math.min(itemCount, initialBatch)
return {
key,
count,
caughtUp: itemCount > 0 && count >= itemCount,
}
}
/**
* Progressively renders a list of items so that first paint is fast.
*
* On mount (or when `key` changes), only the most recent `initialBatch`
* items are rendered. The rest are added in `batchSize` increments via
* `requestAnimationFrame` so the browser never blocks on a large DOM mount.
*
* Once staging completes for a given key it never re-stages -- new items
* appended to the list are rendered immediately.
* `requestAnimationFrame`.
*
* @param items Full list of items to render.
* @param key A session/conversation identifier. When it changes,
@@ -35,67 +51,83 @@ export function useProgressiveList<T>(
key: string,
options?: ProgressiveListOptions
): { staged: T[]; isStaging: boolean } {
const initialBatch = options?.initialBatch ?? DEFAULTS.initialBatch
const batchSize = options?.batchSize ?? DEFAULTS.batchSize
const initialBatch = Math.max(0, options?.initialBatch ?? DEFAULTS.initialBatch)
const batchSize = Math.max(1, options?.batchSize ?? DEFAULTS.batchSize)
const [state, setState] = useState(() => createInitialState(key, items.length, initialBatch))
const latestItemCountRef = useRef(items.length)
const completedKeysRef = useRef(new Set<string>())
const prevKeyRef = useRef(key)
const stagingCountRef = useRef(initialBatch)
const [count, setCount] = useState(() => {
if (items.length <= initialBatch) return items.length
return initialBatch
})
useLayoutEffect(() => {
latestItemCountRef.current = items.length
}, [items.length])
const renderState =
state.key === key && (state.count > 0 || items.length === 0 || state.caughtUp)
? state
: createInitialState(key, items.length, initialBatch)
useEffect(() => {
if (completedKeysRef.current.has(key)) {
setCount(items.length)
return
}
if (items.length <= initialBatch) {
setCount(items.length)
completedKeysRef.current.add(key)
return
}
let current = Math.max(stagingCountRef.current, initialBatch)
setCount(current)
let frame: number | undefined
const step = () => {
const total = items.length
current = Math.min(total, current + batchSize)
stagingCountRef.current = current
setCount(current)
if (current >= total) {
completedKeysRef.current.add(key)
frame = undefined
return
setState((prev) => {
if (prev.key !== key) {
return createInitialState(key, items.length, initialBatch)
}
frame = requestAnimationFrame(step)
if (items.length === 0) {
if (prev.count === 0 && !prev.caughtUp) {
return prev
}
return { key, count: 0, caughtUp: false }
}
if (prev.caughtUp) {
if (prev.count === items.length) {
return prev
}
return { key, count: items.length, caughtUp: true }
}
const minimumCount = Math.min(items.length, initialBatch)
if (prev.count >= minimumCount && prev.count <= items.length) {
return prev
}
const count = Math.min(items.length, Math.max(prev.count, minimumCount))
return {
key,
count,
caughtUp: count >= items.length,
}
})
}, [key, items.length, initialBatch])
useEffect(() => {
if (state.key !== key || state.caughtUp || state.count >= items.length) {
return
}
frame = requestAnimationFrame(step)
const frame = requestAnimationFrame(() => {
setState((prev) => {
if (prev.key !== key || prev.caughtUp) {
return prev
}
return () => {
if (frame !== undefined) cancelAnimationFrame(frame)
}
}, [key, items.length, initialBatch, batchSize])
const itemCount = latestItemCountRef.current
const count = Math.min(itemCount, prev.count + batchSize)
return {
key,
count,
caughtUp: count >= itemCount,
}
})
})
let effectiveCount = count
if (prevKeyRef.current !== key) {
effectiveCount = items.length <= initialBatch ? items.length : initialBatch
stagingCountRef.current = initialBatch
}
prevKeyRef.current = key
return () => cancelAnimationFrame(frame)
}, [state.key, state.count, state.caughtUp, key, items.length, batchSize])
const isCompleted = completedKeysRef.current.has(key)
const isStaging = !isCompleted && effectiveCount < items.length
const staged =
isCompleted || effectiveCount >= items.length
? items
: items.slice(Math.max(0, items.length - effectiveCount))
const effectiveCount = renderState.caughtUp
? items.length
: Math.min(renderState.count, items.length)
const staged = items.slice(Math.max(0, items.length - effectiveCount))
const isStaging = effectiveCount < items.length
return { staged, isStaging }
}

View File

@@ -194,6 +194,64 @@ describe('copilot go stream helpers', () => {
)
})
it('does not retry transient backend statuses because stream requests are not idempotent', async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response('bad gateway', { status: 502 }))
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toMatchObject({
name: 'CopilotBackendError',
status: 502,
body: 'bad gateway',
})
expect(fetch).toHaveBeenCalledTimes(1)
})
it('does not retry non-transient backend statuses before the SSE stream opens', async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response('limit reached', { status: 402 }))
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toThrow('Usage limit reached')
expect(fetch).toHaveBeenCalledTimes(1)
})
it('does not retry network errors because Go may already be executing the request', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed'))
const context = createStreamingContext()
const execContext: ExecutionContext = {
userId: 'user-1',
workflowId: 'workflow-1',
}
await expect(
runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, {
timeout: 1000,
})
).rejects.toThrow('fetch failed')
expect(fetch).toHaveBeenCalledTimes(1)
})
it('fails closed when the shared stream ends before a terminal event', async () => {
const textEvent = createEvent({
streamId: 'stream-1',

View File

@@ -134,17 +134,27 @@ export async function runStreamLoop(
requestBodyBytes,
})
const fetchStart = performance.now()
const response = await fetchGo(fetchUrl, {
...fetchOptions,
signal: abortSignal,
otelContext: options.otelContext,
spanName: `sim → go ${pathname}`,
operation: 'stream',
attributes: {
[TraceAttr.CopilotStream]: true,
...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}),
},
})
let response: Response
try {
response = await fetchGo(fetchUrl, {
...fetchOptions,
signal: abortSignal,
otelContext: options.otelContext,
spanName: `sim → go ${pathname}`,
operation: 'stream',
attributes: {
[TraceAttr.CopilotStream]: true,
...(requestBodyBytes ? { [TraceAttr.HttpRequestContentLength]: requestBodyBytes } : {}),
},
})
} catch (error) {
fetchSpan.attributes = {
...(fetchSpan.attributes ?? {}),
headersMs: Math.round(performance.now() - fetchStart),
}
context.trace.endSpan(fetchSpan, abortSignal?.aborted ? 'cancelled' : 'error')
throw error
}
const headersElapsedMs = Math.round(performance.now() - fetchStart)
fetchSpan.attributes = {
...(fetchSpan.attributes ?? {}),
@@ -561,14 +571,14 @@ function stampSseReadLoopSpan(
const nowWall = Date.now()
const startWall = nowWall - (nowPerf - startPerfMs)
const terminalEventSeen = counters.eventsByType.complete > 0
const terminalEventSeen = counters.eventsByType.complete > 0 || counters.eventsByType.error > 0
// `terminal_event_missing` is the single-attribute dashboard signal
// for the "disappeared response" bug class: the caller considered
// this leg to be the final one (`context.streamComplete === true`)
// but no `complete` event arrived on the wire. Tool-pause legs have
// expectedTerminal=false and never trip this, so dashboards can
// filter on `{ .copilot.sse.terminal_event_missing = true }` without
// false positives.
// but no terminal `complete` or `error` event arrived on the wire.
// Tool-pause legs have expectedTerminal=false and never trip this, so
// dashboards can filter on `{ .copilot.sse.terminal_event_missing = true }`
// without false positives.
const terminalEventMissing = opts.expectedTerminal && !terminalEventSeen
const tracer = getCopilotTracer()

View File

@@ -210,6 +210,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
const abortPoller = startAbortPoller(streamId, abortController, {
requestId,
chatId,
})
publisher.startKeepalive()

View File

@@ -0,0 +1,120 @@
/**
* @vitest-environment node
*/
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.hoisted(() => ({
mockHasAbortMarker: vi.fn().mockResolvedValue(false),
mockClearAbortMarker: vi.fn().mockResolvedValue(undefined),
mockWriteAbortMarker: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
vi.mock('@/lib/copilot/request/session/buffer', () => ({
hasAbortMarker: mockHasAbortMarker,
clearAbortMarker: mockClearAbortMarker,
writeAbortMarker: mockWriteAbortMarker,
}))
vi.mock('@/lib/copilot/request/otel', () => ({
withCopilotSpan: (_span: unknown, _attrs: unknown, fn: (span: unknown) => unknown) =>
fn({ setAttribute: vi.fn() }),
}))
import { startAbortPoller } from '@/lib/copilot/request/session/abort'
describe('startAbortPoller heartbeat', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockHasAbortMarker.mockResolvedValue(false)
redisConfigMockFns.mockExtendLock.mockResolvedValue(true)
})
afterEach(() => {
vi.useRealTimers()
})
it('extends the chat stream lock approximately every heartbeat interval', async () => {
const controller = new AbortController()
const streamId = 'stream-heartbeat-1'
const chatId = 'chat-heartbeat-1'
const interval = startAbortPoller(streamId, controller, { chatId })
try {
await vi.advanceTimersByTimeAsync(15_000)
expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(6_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenLastCalledWith(
`copilot:chat-stream-lock:${chatId}`,
streamId,
60
)
await vi.advanceTimersByTimeAsync(20_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(20_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(3)
} finally {
clearInterval(interval)
}
})
it('does not extend the lock when no chatId is passed (backward compat)', async () => {
const controller = new AbortController()
const interval = startAbortPoller('stream-no-chat', controller, {})
try {
await vi.advanceTimersByTimeAsync(90_000)
expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled()
} finally {
clearInterval(interval)
}
})
it('retries on the next tick when extendLock throws (no 20s backoff)', async () => {
const controller = new AbortController()
const streamId = 'stream-retry'
const chatId = 'chat-retry'
redisConfigMockFns.mockExtendLock.mockRejectedValueOnce(new Error('redis down'))
const interval = startAbortPoller(streamId, controller, { chatId })
try {
await vi.advanceTimersByTimeAsync(20_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2)
} finally {
clearInterval(interval)
}
})
it('stops heartbeating after ownership is lost', async () => {
const controller = new AbortController()
const streamId = 'stream-lost'
const chatId = 'chat-lost'
redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false)
const interval = startAbortPoller(streamId, controller, { chatId })
try {
await vi.advanceTimersByTimeAsync(21_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60_000)
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
} finally {
clearInterval(interval)
}
})
})

View File

@@ -5,7 +5,7 @@ import { AbortBackend } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { withCopilotSpan } from '@/lib/copilot/request/otel'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { acquireLock, extendLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { AbortReason } from './abort-reason'
import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer'
@@ -18,7 +18,22 @@ const pendingChatStreams = new Map<
>()
const DEFAULT_ABORT_POLL_MS = 1000
const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60
/**
* TTL for the per-chat stream lock. Kept short so that if the Sim pod
* holding the lock dies (SIGKILL, OOM, a SIGTERM drain that doesn't
* reach the release path), the lock self-heals inside a minute rather
* than stranding the chat for hours. A live stream keeps the lock alive
* via `CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS` heartbeats.
*/
const CHAT_STREAM_LOCK_TTL_SECONDS = 60
/**
* Heartbeat cadence for extending the per-chat stream lock. Set to a
* third of the TTL so one missed beat still leaves room for recovery
* before the lock expires under a still-live stream.
*/
const CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS = 20_000
function registerPendingChatStream(chatId: string, streamId: string): void {
let resolve!: () => void
@@ -262,10 +277,14 @@ const pollingStreams = new Set<string>()
export function startAbortPoller(
streamId: string,
abortController: AbortController,
options?: { pollMs?: number; requestId?: string }
options?: { pollMs?: number; requestId?: string; chatId?: string }
): ReturnType<typeof setInterval> {
const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS
const requestId = options?.requestId
const chatId = options?.chatId
let lastHeartbeatAt = Date.now()
let heartbeatOwnershipLost = false
return setInterval(() => {
if (pollingStreams.has(streamId)) return
@@ -287,6 +306,33 @@ export function startAbortPoller(
} finally {
pollingStreams.delete(streamId)
}
if (!chatId || heartbeatOwnershipLost) return
if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return
try {
const owned = await extendLock(
getChatStreamLockKey(chatId),
streamId,
CHAT_STREAM_LOCK_TTL_SECONDS
)
lastHeartbeatAt = Date.now()
if (!owned) {
heartbeatOwnershipLost = true
logger.warn('Lost ownership of chat stream lock — stopping heartbeat', {
chatId,
streamId,
...(requestId ? { requestId } : {}),
})
}
} catch (error) {
logger.warn('Failed to extend chat stream lock TTL', {
chatId,
streamId,
...(requestId ? { requestId } : {}),
error: toError(error).message,
})
}
})()
}, pollMs)
}

View File

@@ -165,6 +165,7 @@ function isStreamRef(value: unknown): value is MothershipStreamV1StreamRef {
return (
isRecord(value) &&
typeof value.streamId === 'string' &&
value.streamId.length > 0 &&
isOptionalString(value.chatId) &&
isOptionalString(value.cursor)
)

View File

@@ -15,6 +15,7 @@ vi.mock('ioredis', () => ({
import {
closeRedisConnection,
extendLock,
getRedisClient,
onRedisReconnect,
resetForTesting,
@@ -120,6 +121,48 @@ describe('redis config', () => {
})
})
describe('extendLock', () => {
const lockKey = 'copilot:chat-stream-lock:chat-1'
const value = 'stream-abc'
const ttlSeconds = 60
it('returns true when the caller still owns the lock and EXPIRE succeeds', async () => {
mockRedisInstance.eval.mockResolvedValueOnce(1)
const extended = await extendLock(lockKey, value, ttlSeconds)
expect(extended).toBe(true)
expect(mockRedisInstance.eval).toHaveBeenCalledWith(
expect.stringContaining('expire'),
1,
lockKey,
value,
ttlSeconds
)
})
it('returns false when the value does not match (lock owned by another)', async () => {
mockRedisInstance.eval.mockResolvedValueOnce(0)
const extended = await extendLock(lockKey, value, ttlSeconds)
expect(extended).toBe(false)
})
it('returns true as a no-op when Redis is unavailable', async () => {
vi.resetModules()
vi.doMock('@/lib/core/config/env', () =>
createEnvMock({ REDIS_URL: undefined as unknown as string })
)
const { extendLock: extendLockNoRedis } = await import('@/lib/core/config/redis')
const extended = await extendLockNoRedis(lockKey, value, ttlSeconds)
expect(extended).toBe(true)
vi.doUnmock('@/lib/core/config/env')
})
})
describe('retryStrategy', () => {
function captureRetryStrategy(): (times: number) => number {
let capturedConfig: Record<string, unknown> = {}

View File

@@ -136,6 +136,21 @@ else
end
`
/**
* Lua script for safe lock TTL extension.
* Only refreshes the expiry if the value matches (ownership verification),
* so a stale heartbeat from a prior owner cannot extend a lock currently
* held by someone else after a TTL eviction.
* Returns 1 if the TTL was extended, 0 if not (value mismatch or key gone).
*/
const EXTEND_LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`
/**
* Acquire a distributed lock using Redis SET NX.
* Returns true if lock acquired, false if already held.
@@ -175,6 +190,29 @@ export async function releaseLock(lockKey: string, value: string): Promise<boole
return result === 1
}
/**
* Extend the TTL of a distributed lock if still owned by the caller.
* Returns true if the caller still owns the lock and the TTL was refreshed,
* false if the lock has been taken over by another owner or has expired.
*
* When Redis is not available, returns true (no-op) to match the behavior
* of `acquireLock` / `releaseLock`: single-replica deployments without
* Redis never held a real lock, so heartbeat success is implicit.
*/
export async function extendLock(
lockKey: string,
value: string,
expirySeconds: number
): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
return true
}
const result = await redis.eval(EXTEND_LOCK_SCRIPT, 1, lockKey, value, expirySeconds)
return result === 1
}
/**
* Close the Redis connection.
* Use for graceful shutdown.

View File

@@ -3,11 +3,58 @@
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { updateRow } from '@/lib/table/service'
import {
batchInsertRows,
deleteColumn,
insertRow,
renameColumn,
replaceTableRows,
updateRow,
upsertRow,
} from '@/lib/table/service'
import type { TableDefinition } from '@/lib/table/types'
import { getUniqueColumns } from '@/lib/table/validation'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/table/validation', () => ({
validateRowSize: vi.fn(() => ({ valid: true, errors: [] })),
validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })),
validateTableName: vi.fn(() => ({ valid: true, errors: [] })),
validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })),
getUniqueColumns: vi.fn(() => []),
checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
}))
/**
* Inspects the queued `trx.execute(...)` calls for SQL containing `substring`.
* Works with both `sql\`...\`` (produces `{ strings, values }`) and `sql.raw(...)`
* (produces `{ rawSql }`) from the global drizzle mock.
*/
function findExecutedSqlContaining(substring: string): boolean {
return dbChainMockFns.execute.mock.calls.some(([arg]) => {
if (!arg || typeof arg !== 'object') return false
const a = arg as Record<string, unknown>
if (Array.isArray(a.strings)) {
return (a.strings as string[]).some((s) => typeof s === 'string' && s.includes(substring))
}
if (typeof a.rawSql === 'string') {
return (a.rawSql as string).includes(substring)
}
return false
})
}
function findExecutedRawSql(substring: string): string | undefined {
for (const [arg] of dbChainMockFns.execute.mock.calls) {
if (!arg || typeof arg !== 'object') continue
const raw = (arg as { rawSql?: unknown }).rawSql
if (typeof raw === 'string' && raw.includes(substring)) return raw
}
return undefined
}
const EXISTING_ROW = {
id: 'row-1',
tableId: 'tbl-1',
@@ -106,3 +153,289 @@ describe('updateRow — partial merge', () => {
).rejects.toThrow('Row not found')
})
})
describe('insertRow — position race safety (migration 0198 + advisory lock)', () => {
beforeEach(() => {
vi.resetAllMocks()
resetDbChainMock()
vi.mocked(getUniqueColumns).mockReturnValue([])
})
it('auto-position inserts acquire the per-table advisory lock before reading max(position)', async () => {
await expect(
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
).rejects.toBeDefined()
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
})
it('explicit-position inserts also acquire the advisory lock to serialize position shifts', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
dbChainMockFns.returning.mockResolvedValueOnce([
{
id: 'row-1',
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'a' },
position: 5,
createdAt: new Date(),
updatedAt: new Date(),
},
])
await insertRow(
{ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1', position: 5 },
TABLE,
'req-1'
)
// `(table_id, position)` index is non-unique, so concurrent explicit-position
// inserts at the same slot could both skip the shift and duplicate — lock
// serializes them.
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
})
it('batchInsertRows acquires the advisory lock (always auto-positioned)', async () => {
await expect(
batchInsertRows(
{ tableId: 'tbl-1', rows: [{ name: 'a' }, { name: 'b' }], workspaceId: 'ws-1' },
TABLE,
'req-1'
)
).rejects.toBeDefined()
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
})
it('batchInsertRows with explicit positions acquires the advisory lock', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([
{
id: 'row-1',
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'a' },
position: 3,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'row-2',
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'b' },
position: 4,
createdAt: new Date(),
updatedAt: new Date(),
},
])
await batchInsertRows(
{
tableId: 'tbl-1',
rows: [{ name: 'a' }, { name: 'b' }],
workspaceId: 'ws-1',
positions: [3, 4],
},
TABLE,
'req-1'
)
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
})
it('upsertRow skips the advisory lock on the update path (match found)', async () => {
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'row-1',
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'Alice', age: 30 },
position: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
])
dbChainMockFns.returning.mockResolvedValueOnce([
{
id: 'row-1',
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'Alice', age: 31 },
position: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
])
await upsertRow(
{
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'Alice', age: 31 },
conflictTarget: 'name',
},
TABLE,
'req-1'
)
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(false)
})
it('upsertRow acquires the advisory lock on the insert path (no match)', async () => {
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
// Initial existing-row check + post-lock re-check both find no match.
dbChainMockFns.limit.mockResolvedValueOnce([])
dbChainMockFns.limit.mockResolvedValueOnce([])
await expect(
upsertRow(
{
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'Bob', age: 25 },
conflictTarget: 'name',
},
TABLE,
'req-1'
)
).rejects.toBeDefined()
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
})
it('upsertRow re-checks after acquiring the lock and switches to UPDATE when a racing tx inserted the row', async () => {
vi.mocked(getUniqueColumns).mockReturnValue([{ name: 'name', type: 'string', unique: true }])
// Initial existing-row check: no match (another tx has not committed yet).
dbChainMockFns.limit.mockResolvedValueOnce([])
// Post-lock re-check: a racing tx just inserted the row.
const racedRow = {
id: 'row-raced',
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'Bob', age: 25 },
position: 0,
createdAt: new Date(),
updatedAt: new Date(),
}
dbChainMockFns.limit.mockResolvedValueOnce([racedRow])
// UPDATE returning the patched row.
dbChainMockFns.returning.mockResolvedValueOnce([
{ ...racedRow, data: { name: 'Bob', age: 26 } },
])
const result = await upsertRow(
{
tableId: 'tbl-1',
workspaceId: 'ws-1',
data: { name: 'Bob', age: 26 },
conflictTarget: 'name',
},
TABLE,
'req-1'
)
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
expect(result.operation).toBe('update')
expect(result.row.id).toBe('row-raced')
expect(dbChainMockFns.update).toHaveBeenCalled()
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
})
})
describe('mutation paths — SET LOCAL timeouts', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('insertRow sets the default 10s/3s/5s timeouts', async () => {
await expect(
insertRow({ tableId: 'tbl-1', data: { name: 'a' }, workspaceId: 'ws-1' }, TABLE, 'req-1')
).rejects.toBeDefined()
expect(findExecutedRawSql("SET LOCAL statement_timeout = '10000ms'")).toBeDefined()
expect(findExecutedRawSql("SET LOCAL lock_timeout = '3000ms'")).toBeDefined()
expect(
findExecutedRawSql("SET LOCAL idle_in_transaction_session_timeout = '5000ms'")
).toBeDefined()
})
it('batchInsertRows raises statement_timeout to 60s', async () => {
await expect(
batchInsertRows(
{ tableId: 'tbl-1', rows: [{ name: 'a' }], workspaceId: 'ws-1' },
TABLE,
'req-1'
)
).rejects.toBeDefined()
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
})
it('replaceTableRows scales statement_timeout with (existing + new) row count', async () => {
const bigTable: TableDefinition = { ...TABLE, rowCount: 100_000, maxRows: 1_000_000 }
const payload = Array.from({ length: 50_000 }, (_, i) => ({ name: `row-${i}` }))
await replaceTableRows(
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: payload },
bigTable,
'req-1'
)
// (100_000 + 50_000) × 3ms/row = 450_000ms; above 120_000 floor, below 600_000 cap
expect(findExecutedRawSql("SET LOCAL statement_timeout = '450000ms'")).toBeDefined()
})
it('replaceTableRows caps scaled timeout at 10 minutes for very large tables', async () => {
const hugeTable: TableDefinition = { ...TABLE, rowCount: 10_000_000, maxRows: 20_000_000 }
await replaceTableRows({ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, hugeTable, 'req-1')
// 10M × 3ms = 30M ms, capped at 600_000ms (10 min)
expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined()
})
it('replaceTableRows uses the 120s floor on small tables', async () => {
const smallTable: TableDefinition = { ...TABLE, rowCount: 10 }
await replaceTableRows(
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }, { name: 'b' }] },
smallTable,
'req-1'
)
// 12 × 3ms = 36ms → floored at 120_000ms
expect(findExecutedRawSql("SET LOCAL statement_timeout = '120000ms'")).toBeDefined()
})
it('renameColumn scales statement_timeout with table.rowCount', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 500_000 }])
await renameColumn({ tableId: 'tbl-1', oldName: 'name', newName: 'full_name' }, 'req-1')
// 500_000 × 2ms = 1_000_000 → capped at 600_000
expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined()
})
it('deleteColumn uses the 60s floor on small tables', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 100 }])
await deleteColumn({ tableId: 'tbl-1', columnName: 'age' }, 'req-1')
// 100 × 2ms = 200ms → floored at 60_000ms
expect(findExecutedRawSql("SET LOCAL statement_timeout = '60000ms'")).toBeDefined()
})
it('replaceTableRows acquires the per-table advisory lock to serialize concurrent replaces', async () => {
await replaceTableRows(
{ tableId: 'tbl-1', workspaceId: 'ws-1', rows: [{ name: 'a' }] },
{ ...TABLE, rowCount: 5 },
'req-1'
)
expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true)
expect(findExecutedSqlContaining('hashtextextended')).toBe(true)
})
})

View File

@@ -64,6 +64,80 @@ export class TableConflictError extends Error {
export type TableScope = 'active' | 'archived' | 'all'
type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
/**
* Sets per-transaction Postgres timeouts via `SET LOCAL`.
*
* `lock_timeout` is the critical one: without it, a waiter inherits the full
* `statement_timeout` clock, so one stuck writer can drain the pool.
*
* Safe under pgBouncer transaction pooling — `SET LOCAL` is transaction-scoped
* and cleared at COMMIT/ROLLBACK before the session returns to the pool.
*/
async function setTableTxTimeouts(
trx: DbTransaction,
opts?: { statementMs?: number; lockMs?: number; idleMs?: number }
) {
const s = opts?.statementMs ?? 10_000
const l = opts?.lockMs ?? 3_000
const i = opts?.idleMs ?? 5_000
await trx.execute(sql.raw(`SET LOCAL statement_timeout = '${s}ms'`))
await trx.execute(sql.raw(`SET LOCAL lock_timeout = '${l}ms'`))
await trx.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = '${i}ms'`))
}
/**
* Serializes writers that compute `max(position) + 1` for the same table.
*
* The row-count trigger (migration 0198) serializes capacity via a row lock on
* `user_table_definitions` — but it fires AFTER INSERT, so two concurrent
* auto-positioned inserts can read the same snapshot and assign the same
* position (the `(table_id, position)` index is non-unique). This advisory
* lock restores the pre-trigger serialization scoped to a single table, with
* no cross-table contention. Released automatically at COMMIT/ROLLBACK.
*/
async function acquireTablePositionLock(trx: DbTransaction, tableId: string) {
await trx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_rows_pos:${tableId}`}, 0))`
)
}
/**
* Returns the next auto-assigned `position` for a table (max(position) + 1, or 0
* if empty). Callers must hold `acquireTablePositionLock` to avoid two concurrent
* writers computing the same value against the same snapshot.
*/
async function nextAutoPosition(trx: DbTransaction, tableId: string): Promise<number> {
const [{ maxPos }] = await trx
.select({
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
})
.from(userTableRows)
.where(eq(userTableRows.tableId, tableId))
return maxPos + 1
}
const TIMEOUT_CAP_MS = 10 * 60_000
/**
* Scales `statement_timeout` to the expected row-count work.
*
* Bulk operations that rewrite JSONB or cascade row triggers (e.g.
* `replaceTableRows`, `deleteColumn`, `renameColumn`) scale roughly linearly
* with row count. A fixed cap would regress large-table users who never saw a
* timeout before `SET LOCAL` was introduced. This helper picks
* `max(baseMs, rowCount * perRowMs)`, capped at 10 minutes so a single
* runaway transaction cannot indefinitely pin a pool connection.
*/
function scaledStatementTimeoutMs(
rowCount: number,
opts: { baseMs: number; perRowMs: number }
): number {
const safeRowCount = Math.max(0, rowCount)
return Math.min(TIMEOUT_CAP_MS, Math.max(opts.baseMs, safeRowCount * opts.perRowMs))
}
/**
* Gets a table by ID with full details.
*
@@ -88,16 +162,14 @@ export async function getTableById(
archivedAt: userTableDefinitions.archivedAt,
createdAt: userTableDefinitions.createdAt,
updatedAt: userTableDefinitions.updatedAt,
rowCount: sql<number>`coalesce(${count(userTableRows.id)}, 0)`.mapWith(Number),
rowCount: userTableDefinitions.rowCount,
})
.from(userTableDefinitions)
.leftJoin(userTableRows, eq(userTableRows.tableId, userTableDefinitions.id))
.where(
includeArchived
? eq(userTableDefinitions.id, tableId)
: and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))
)
.groupBy(userTableDefinitions.id)
.limit(1)
if (results.length === 0) return null
@@ -156,10 +228,9 @@ export async function listTables(
archivedAt: userTableDefinitions.archivedAt,
createdAt: userTableDefinitions.createdAt,
updatedAt: userTableDefinitions.updatedAt,
rowCount: sql<number>`coalesce(${count(userTableRows.id)}, 0)`.mapWith(Number),
rowCount: userTableDefinitions.rowCount,
})
.from(userTableDefinitions)
.leftJoin(userTableRows, eq(userTableRows.tableId, userTableDefinitions.id))
.where(
scope === 'all'
? eq(userTableDefinitions.workspaceId, workspaceId)
@@ -173,7 +244,6 @@ export async function listTables(
isNull(userTableDefinitions.archivedAt)
)
)
.groupBy(userTableDefinitions.id)
.orderBy(userTableDefinitions.createdAt)
return tables.map((t) => ({
@@ -240,6 +310,7 @@ export async function createTable(
// to prevent TOCTOU race on the table count limit
try {
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx)
await trx.execute(sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR UPDATE`)
const [{ count: existingCount }] = await trx
@@ -510,6 +581,7 @@ export async function restoreTable(tableId: string, requestId: string): Promise<
attemptedRestoreName = ''
try {
await db.transaction(async (tx) => {
await setTableTxTimeouts(tx)
await tx.execute(sql`SELECT 1 FROM user_table_definitions WHERE id = ${tableId} FOR UPDATE`)
attemptedRestoreName = await generateRestoreName(table.name, async (candidate) => {
@@ -585,25 +657,22 @@ export async function insertRow(
const rowId = `row_${generateId().replace(/-/g, '')}`
const now = new Date()
// Atomic capacity check + insert inside a transaction.
// FOR UPDATE on the table definition row serializes concurrent inserts,
// preventing the TOCTOU race where multiple requests pass the count check.
// Capacity enforcement lives in the `increment_user_table_row_count` trigger
// (migration 0198): a single conditional UPDATE on user_table_definitions
// increments row_count iff row_count < max_rows, taking the row lock
// atomically. No app-level FOR UPDATE / COUNT needed.
const [row] = await db.transaction(async (trx) => {
await trx.execute(
sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE`
)
const [{ count: currentCount }] = await trx
.select({ count: count() })
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
if (Number(currentCount) >= table.maxRows) {
throw new Error(`Table has reached maximum row limit (${table.maxRows})`)
}
await setTableTxTimeouts(trx)
let targetPosition: number
// The `(table_id, position)` index is non-unique, so we serialize all
// position-aware writes (explicit and auto) through the per-table
// advisory lock. Without this, two concurrent explicit-position inserts
// at the same position can both observe an empty slot, both skip the
// shift, and each INSERT a row with a duplicate `(table_id, position)`.
await acquireTablePositionLock(trx, data.tableId)
if (data.position !== undefined) {
targetPosition = data.position
@@ -627,14 +696,7 @@ export async function insertRow(
)
}
} else {
const [{ maxPos }] = await trx
.select({
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
})
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
targetPosition = maxPos + 1
targetPosition = await nextAutoPosition(trx, data.tableId)
}
return trx
@@ -706,24 +768,12 @@ export async function batchInsertRows(
const now = new Date()
// Atomic capacity check + insert inside a transaction.
// FOR UPDATE on the table definition row serializes concurrent inserts.
// Capacity enforcement lives in the `increment_user_table_row_count` trigger
// (migration 0198) — fires per row and raises `Maximum row limit (%) reached ...`
// if the cap is hit mid-batch. The outer transaction means a partial batch
// rolls back cleanly.
const insertedRows = await db.transaction(async (trx) => {
await trx.execute(
sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE`
)
const [{ count: currentCount }] = await trx
.select({ count: count() })
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
const remainingCapacity = table.maxRows - Number(currentCount)
if (remainingCapacity < data.rows.length) {
throw new Error(
`Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${Number(currentCount)}/${table.maxRows} rows)`
)
}
await setTableTxTimeouts(trx, { statementMs: 60_000 })
const buildRow = (rowData: RowData, position: number) => ({
id: `row_${generateId().replace(/-/g, '')}`,
@@ -736,6 +786,10 @@ export async function batchInsertRows(
...(data.userId ? { createdBy: data.userId } : {}),
})
// Serialize position-aware writes per-table. See `acquireTablePositionLock`
// for why both explicit- and auto-position paths take this lock.
await acquireTablePositionLock(trx, data.tableId)
if (data.positions && data.positions.length > 0) {
// Position-aware insert: shift existing rows to create gaps, then insert.
// Process positions ascending so each shift preserves gaps created by prior shifts.
@@ -755,14 +809,8 @@ export async function batchInsertRows(
return trx.insert(userTableRows).values(rowsToInsert).returning()
}
const [{ maxPos }] = await trx
.select({
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
})
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, maxPos + 1 + i))
const startPos = await nextAutoPosition(trx, data.tableId)
const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, startPos + i))
return trx.insert(userTableRows).values(rowsToInsert).returning()
})
@@ -849,10 +897,21 @@ export async function replaceTableRows(
const now = new Date()
const totalRowWork = Math.max(0, table.rowCount ?? 0) + data.rows.length
const statementMs = scaledStatementTimeoutMs(totalRowWork, {
baseMs: 120_000,
perRowMs: 3,
})
const result = await db.transaction(async (trx) => {
await trx.execute(
sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE`
)
await setTableTxTimeouts(trx, { statementMs })
// Serialize concurrent replaces (and concurrent auto-position inserts) on the
// same table. Without this, two concurrent replaces each see their own MVCC
// snapshot for the DELETE; the second's DELETE would not observe rows the
// first inserted, so both transactions commit and the table ends up with
// the union of both row sets instead of only the last caller's rows.
await acquireTablePositionLock(trx, data.tableId)
const deletedRows = await trx
.delete(userTableRows)
@@ -897,8 +956,11 @@ export async function replaceTableRows(
* column, otherwise inserts a new row.
*
* Uses a single unique column for matching (not OR across all unique columns) to avoid
* ambiguous matches when multiple unique columns exist. Capacity checks run inside the
* transaction with a FOR UPDATE lock to prevent TOCTOU races.
* ambiguous matches when multiple unique columns exist. Capacity enforcement lives
* in the `increment_user_table_row_count` trigger (migration 0198). On the insert
* path we acquire the per-table advisory lock and re-check for an existing match
* before inserting, so a concurrent upsert racing on the same conflict target
* cannot produce a duplicate row.
*
* @param data - Upsert data including optional conflictTarget
* @param table - Table definition
@@ -965,12 +1027,10 @@ export async function upsertRow(
? sql`${userTableRows.data}->>${sql.raw(`'${targetColumnName}'`)} = ${String(targetValue)}`
: sql`(${userTableRows.data}->${sql.raw(`'${targetColumnName}'`)})::jsonb = ${JSON.stringify(targetValue)}::jsonb`
// Entire upsert runs in a transaction with FOR UPDATE lock on the table definition.
// This serializes concurrent upserts and prevents the TOCTOU race on row count.
// Capacity enforcement for the insert path lives in the `increment_user_table_row_count`
// trigger (migration 0198). The update path doesn't change row_count, so no check needed.
const result = await db.transaction(async (trx) => {
await trx.execute(
sql`SELECT 1 FROM user_table_definitions WHERE id = ${data.tableId} FOR UPDATE`
)
await setTableTxTimeouts(trx)
// Find existing row by single conflict target column
const [existingRow] = await trx
@@ -998,14 +1058,33 @@ export async function upsertRow(
const now = new Date()
if (existingRow) {
// Resolve which row (if any) we should update. If the initial SELECT missed,
// acquire the lock and re-check — a concurrent upsert may have inserted the
// matching row between our SELECT and the INSERT path, and without the
// re-check both transactions would insert and produce a duplicate that
// bypasses the app-level unique check.
let matchedRowId = existingRow?.id
if (!matchedRowId) {
await acquireTablePositionLock(trx, data.tableId)
const [racedRow] = await trx
.select({ id: userTableRows.id })
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, data.tableId),
eq(userTableRows.workspaceId, data.workspaceId),
matchFilter
)
)
.limit(1)
matchedRowId = racedRow?.id
}
if (matchedRowId) {
const [updatedRow] = await trx
.update(userTableRows)
.set({
data: data.data,
updatedAt: now,
})
.where(eq(userTableRows.id, existingRow.id))
.set({ data: data.data, updatedAt: now })
.where(eq(userTableRows.id, matchedRowId))
.returning()
return {
@@ -1020,23 +1099,6 @@ export async function upsertRow(
}
}
// Check capacity atomically (inside the lock)
const [{ count: currentCount }] = await trx
.select({ count: count() })
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
if (Number(currentCount) >= table.maxRows) {
throw new Error(`Table row limit reached (${table.maxRows} rows max)`)
}
const [{ maxPos }] = await trx
.select({
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
})
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
const [insertedRow] = await trx
.insert(userTableRows)
.values({
@@ -1044,7 +1106,7 @@ export async function upsertRow(
tableId: data.tableId,
workspaceId: data.workspaceId,
data: data.data,
position: maxPos + 1,
position: await nextAutoPosition(trx, data.tableId),
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
@@ -1073,6 +1135,14 @@ export async function upsertRow(
/**
* Queries rows from a table with filtering, sorting, and pagination.
*
* Filter cost model: equality filters (`$eq`, `$in`) compile to JSONB
* containment (`@>`) and hit the GIN (jsonb_path_ops) index on
* `user_table_rows.data`. Range operators (`$gt`, `$gte`, `$lt`, `$lte`) and
* `$contains` compile to `data->>'field'` text extraction and bypass the GIN
* index — they fall back to a sequential scan of the rows for the table
* (bounded only by the btree on `table_id`). Prefer equality on hot paths; set
* `includeTotal: false` when the caller does not need the `COUNT(*)`.
*
* @param tableId - Table ID to query
* @param workspaceId - Workspace ID for access control
* @param options - Query options (filter, sort, limit, offset)
@@ -1085,7 +1155,13 @@ export async function queryRows(
options: QueryOptions,
requestId: string
): Promise<QueryResult> {
const { filter, sort, limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT, offset = 0 } = options
const {
filter,
sort,
limit = TABLE_LIMITS.DEFAULT_QUERY_LIMIT,
offset = 0,
includeTotal = true,
} = options
const tableName = USER_TABLE_ROWS_SQL_NAME
@@ -1103,13 +1179,14 @@ export async function queryRows(
}
}
// Get total count
const countResult = await db
.select({ count: count() })
.from(userTableRows)
.where(whereClause ?? baseConditions)
const totalCount = Number(countResult[0].count)
let totalCount: number | null = null
if (includeTotal) {
const countResult = await db
.select({ count: count() })
.from(userTableRows)
.where(whereClause ?? baseConditions)
totalCount = Number(countResult[0].count)
}
// Build ORDER BY clause (default to position ASC for stable ordering)
let orderByClause
@@ -1273,6 +1350,7 @@ export async function deleteRow(
requestId: string
): Promise<void> {
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx)
const [deleted] = await trx
.delete(userTableRows)
.where(
@@ -1351,49 +1429,45 @@ export async function updateRowsByFilter(
}
const uniqueColumns = getUniqueColumns(table.schema)
if (uniqueColumns.length > 0) {
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data)
if (uniqueColumnsInUpdate.length > 0) {
if (matchingRows.length > 1) {
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data)
if (uniqueColumnsInUpdate.length > 0) {
throw new Error(
`Cannot set unique column values when updating multiple rows. ` +
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`
)
}
throw new Error(
`Cannot set unique column values when updating multiple rows. ` +
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`
)
}
for (const row of matchingRows) {
const existingData = row.data as RowData
const mergedData = { ...existingData, ...data.data }
const uniqueValidation = await checkUniqueConstraintsDb(
data.tableId,
mergedData,
table.schema,
row.id
)
if (!uniqueValidation.valid) {
throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`)
}
// Only one row — only the touched unique columns need re-checking.
const row = matchingRows[0]
const mergedData = { ...(row.data as RowData), ...data.data }
const uniqueValidation = await checkUniqueConstraintsDb(
data.tableId,
mergedData,
table.schema,
row.id
)
if (!uniqueValidation.valid) {
throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`)
}
}
const now = new Date()
const ids = matchingRows.map((r) => r.id)
const patchJson = JSON.stringify(data.data)
await db.transaction(async (trx) => {
for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
const updatePromises = batch.map((row) => {
const existingData = row.data as RowData
return trx
.update(userTableRows)
.set({
data: { ...existingData, ...data.data },
updatedAt: now,
})
.where(eq(userTableRows.id, row.id))
})
await Promise.all(updatePromises)
await setTableTxTimeouts(trx, { statementMs: 60_000 })
for (let i = 0; i < ids.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batchIds = ids.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
await trx
.update(userTableRows)
.set({
data: sql`${userTableRows.data} || ${patchJson}::jsonb`,
updatedAt: now,
})
.where(inArray(userTableRows.id, batchIds))
}
})
@@ -1401,7 +1475,7 @@ export async function updateRowsByFilter(
return {
affectedCount: matchingRows.length,
affectedRowIds: matchingRows.map((r) => r.id),
affectedRowIds: ids,
}
}
@@ -1473,6 +1547,7 @@ export async function batchUpdateRows(
const now = new Date()
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
const updatePromises = batch.map(({ rowId, mergedData }) =>
@@ -1493,20 +1568,38 @@ export async function batchUpdateRows(
}
}
type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
/**
* Recompacts row positions to be contiguous (0, 1, 2, ...) after batch deletions.
* Recompacts row positions to be contiguous after batch deletions.
*
* When `minDeletedPos` is provided, only rows with `position >= minDeletedPos`
* are re-numbered (starting from `minDeletedPos`). Rows before the earliest
* deleted position are untouched since their position is unaffected.
*
* If `minDeletedPos` is omitted, the whole table is recompacted from 0.
* Single-row deletes use the more efficient `position - 1` shift in {@link deleteRow}.
*/
async function recompactPositions(tableId: string, trx: DbTransaction) {
async function recompactPositions(tableId: string, trx: DbTransaction, minDeletedPos?: number) {
if (minDeletedPos === undefined) {
await trx.execute(sql`
UPDATE user_table_rows t
SET position = r.new_pos
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos
FROM user_table_rows
WHERE table_id = ${tableId}
) r
WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos
`)
return
}
await trx.execute(sql`
UPDATE user_table_rows t
SET position = r.new_pos
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos
SELECT id, ${minDeletedPos}::int + ROW_NUMBER() OVER (ORDER BY position) - 1 AS new_pos
FROM user_table_rows
WHERE table_id = ${tableId}
WHERE table_id = ${tableId} AND position >= ${minDeletedPos}
) r
WHERE t.id = r.id AND t.table_id = ${tableId} AND t.position != r.new_pos
`)
@@ -1538,7 +1631,7 @@ export async function deleteRowsByFilter(
)
let query = db
.select({ id: userTableRows.id })
.select({ id: userTableRows.id, position: userTableRows.position })
.from(userTableRows)
.where(and(baseConditions, filterClause))
@@ -1553,8 +1646,13 @@ export async function deleteRowsByFilter(
}
const rowIds = matchingRows.map((r) => r.id)
const minDeletedPos = matchingRows.reduce(
(min, r) => (r.position < min ? r.position : min),
matchingRows[0].position
)
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
await trx.delete(userTableRows).where(
@@ -1569,7 +1667,7 @@ export async function deleteRowsByFilter(
)
}
await recompactPositions(data.tableId, trx)
await recompactPositions(data.tableId, trx, minDeletedPos)
})
logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${data.tableId}`)
@@ -1594,7 +1692,8 @@ export async function deleteRowsByIds(
const uniqueRequestedRowIds = Array.from(new Set(data.rowIds))
const deletedRows = await db.transaction(async (trx) => {
const deleted: { id: string }[] = []
await setTableTxTimeouts(trx, { statementMs: 60_000 })
const deleted: { id: string; position: number }[] = []
for (let i = 0; i < uniqueRequestedRowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
const batch = uniqueRequestedRowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
const rows = await trx
@@ -1609,11 +1708,17 @@ export async function deleteRowsByIds(
)}])`
)
)
.returning({ id: userTableRows.id })
.returning({ id: userTableRows.id, position: userTableRows.position })
deleted.push(...rows)
}
await recompactPositions(data.tableId, trx)
if (deleted.length > 0) {
const minDeletedPos = deleted.reduce(
(min, r) => (r.position < min ? r.position : min),
deleted[0].position
)
await recompactPositions(data.tableId, trx, minDeletedPos)
}
return deleted
})
@@ -1691,8 +1796,13 @@ export async function renameColumn(
}
const now = new Date()
const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
baseMs: 60_000,
perRowMs: 2,
})
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs })
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
@@ -1752,8 +1862,13 @@ export async function deleteColumn(
}
const now = new Date()
const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
baseMs: 60_000,
perRowMs: 2,
})
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs })
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
@@ -1815,8 +1930,13 @@ export async function deleteColumns(
}
const now = new Date()
const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
baseMs: 60_000,
perRowMs: 2 * namesToDelete.size,
})
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs })
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })

View File

@@ -30,6 +30,14 @@ const ALLOWED_OPERATORS = new Set([
* Builds a WHERE clause from a filter object.
* Recursively processes logical operators ($or, $and) and field conditions.
*
* Index behavior: equality ($eq, $in) uses the JSONB containment operator (@>) and
* can leverage the GIN index on `user_table_rows.data` (jsonb_path_ops). Range
* operators ($gt, $gte, $lt, $lte) and pattern match ($contains) fall back to
* text extraction via `data->>'field'`, which defeats the GIN index and produces
* a sequential scan over the table's rows (bounded by a btree prefix on
* `table_id`). Prefer equality filters on hot paths; assume range filters are
* O(rows per table) until a per-column expression index is added.
*
* @param filter - Filter object with field conditions and logical operators
* @param tableName - Table name for the query (e.g., 'user_table_rows')
* @returns SQL WHERE clause or undefined if no filter specified

View File

@@ -139,12 +139,18 @@ export interface QueryOptions {
sort?: Sort
limit?: number
offset?: number
/**
* When true (default), runs a `COUNT(*)` and returns `totalCount` as a number.
* Pass `false` to skip the count query (grid UI doesn't need it); `totalCount`
* is returned as `null` to signal it was not computed.
*/
includeTotal?: boolean
}
export interface QueryResult {
rows: TableRow[]
rowCount: number
totalCount: number
totalCount: number | null
limit: number
offset: number
}

View File

@@ -2,16 +2,18 @@ import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Hex } from '@sim/security/hmac'
import { generateId } from '@sim/utils/id'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Ashby')
@@ -48,17 +50,74 @@ export const ashbyHandler: WebhookProviderHandler = {
input: {
...((b.data as Record<string, unknown>) || {}),
action: b.action,
data: b.data || {},
},
}
},
verifyAuth: createHmacVerifier({
configKey: 'secretToken',
headerName: 'ashby-signature',
validateFn: validateAshbySignature,
providerLabel: 'Ashby',
}),
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null {
const secretToken = (providerConfig.secretToken as string | undefined)?.trim()
if (!secretToken) {
logger.warn(
`[${requestId}] Ashby webhook missing secretToken in providerConfig — rejecting request`
)
return new NextResponse(
'Unauthorized - Ashby webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.',
{ status: 401 }
)
}
const signature = request.headers.get('ashby-signature')
if (!signature) {
logger.warn(`[${requestId}] Ashby webhook missing signature header`)
return new NextResponse('Unauthorized - Missing Ashby signature', { status: 401 })
}
if (!validateAshbySignature(secretToken, signature, rawBody)) {
logger.warn(`[${requestId}] Ashby signature verification failed`, {
signatureLength: signature.length,
secretLength: secretToken.length,
})
return new NextResponse('Unauthorized - Invalid Ashby signature', { status: 401 })
}
return null
},
async matchEvent({
webhook,
body,
requestId,
providerConfig,
}: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record<string, unknown>
const action = typeof obj?.action === 'string' ? obj.action : ''
if (action === 'ping') {
logger.debug(`[${requestId}] Ashby ping event received. Skipping execution.`, {
webhookId: webhook.id,
triggerId,
})
return false
}
if (!triggerId) return true
const { isAshbyEventMatch } = await import('@/triggers/ashby/utils')
if (!isAshbyEventMatch(triggerId, action)) {
logger.debug(
`[${requestId}] Ashby event mismatch for trigger ${triggerId}. Action: ${action || '(missing)'}. Skipping execution.`,
{
webhookId: webhook.id,
triggerId,
receivedAction: action,
}
)
return false
}
return true
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
try {
@@ -78,18 +137,12 @@ export const ashbyHandler: WebhookProviderHandler = {
throw new Error('Trigger ID is required to create Ashby webhook.')
}
const webhookTypeMap: Record<string, string> = {
ashby_application_submit: 'applicationSubmit',
ashby_candidate_stage_change: 'candidateStageChange',
ashby_candidate_hire: 'candidateHire',
ashby_candidate_delete: 'candidateDelete',
ashby_job_create: 'jobCreate',
ashby_offer_create: 'offerCreate',
}
const webhookType = webhookTypeMap[triggerId]
const { ASHBY_TRIGGER_ACTION_MAP } = await import('@/triggers/ashby/utils')
const webhookType = ASHBY_TRIGGER_ACTION_MAP[triggerId]
if (!webhookType) {
throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`)
throw new Error(
`Unknown Ashby triggerId: ${triggerId}. Add it to ASHBY_TRIGGER_ACTION_MAP.`
)
}
const notificationUrl = getNotificationUrl(ctx.webhook)

View File

@@ -34,6 +34,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
ashby: {
emailType: '_removed_emailType',
phoneType: '_removed_phoneType',
filterCandidateId: '_removed_filterCandidateId',
},
rippling: {
action: '_removed_action',

View File

@@ -1,3 +1,5 @@
import type { AshbyCandidate } from '@/tools/ashby/types'
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyAddCandidateTagParams {
@@ -7,9 +9,7 @@ interface AshbyAddCandidateTagParams {
}
interface AshbyAddCandidateTagResponse extends ToolResponse {
output: {
success: boolean
}
output: AshbyCandidate
}
export const addCandidateTagTool: ToolConfig<
@@ -18,7 +18,7 @@ export const addCandidateTagTool: ToolConfig<
> = {
id: 'ashby_add_candidate_tag',
name: 'Ashby Add Candidate Tag',
description: 'Adds a tag to a candidate in Ashby.',
description: 'Adds a tag to a candidate in Ashby and returns the updated candidate.',
version: '1.0.0',
params: {
@@ -50,8 +50,8 @@ export const addCandidateTagTool: ToolConfig<
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
candidateId: params.candidateId,
tagId: params.tagId,
candidateId: params.candidateId.trim(),
tagId: params.tagId.trim(),
}),
},
@@ -64,13 +64,9 @@ export const addCandidateTagTool: ToolConfig<
return {
success: true,
output: {
success: true,
},
output: mapCandidate(data.results),
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the tag was successfully added' },
},
outputs: CANDIDATE_OUTPUTS,
}

View File

@@ -1,3 +1,5 @@
import type { AshbyApplication } from '@/tools/ashby/types'
import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyChangeApplicationStageParams {
@@ -8,10 +10,7 @@ interface AshbyChangeApplicationStageParams {
}
interface AshbyChangeApplicationStageResponse extends ToolResponse {
output: {
applicationId: string
stageId: string | null
}
output: AshbyApplication
}
export const changeApplicationStageTool: ToolConfig<
@@ -61,10 +60,10 @@ export const changeApplicationStageTool: ToolConfig<
}),
body: (params) => {
const body: Record<string, unknown> = {
applicationId: params.applicationId,
interviewStageId: params.interviewStageId,
applicationId: params.applicationId.trim(),
interviewStageId: params.interviewStageId.trim(),
}
if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId
if (params.archiveReasonId) body.archiveReasonId = params.archiveReasonId.trim()
return body
},
},
@@ -76,19 +75,11 @@ export const changeApplicationStageTool: ToolConfig<
throw new Error(data.errorInfo?.message || 'Failed to change application stage')
}
const r = data.results
return {
success: true,
output: {
applicationId: r.id ?? null,
stageId: r.currentInterviewStage?.id ?? null,
},
output: mapApplication(data.results),
}
},
outputs: {
applicationId: { type: 'string', description: 'Application UUID' },
stageId: { type: 'string', description: 'New interview stage UUID' },
},
outputs: APPLICATION_OUTPUTS,
}

View File

@@ -1,3 +1,5 @@
import type { AshbyApplication } from '@/tools/ashby/types'
import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyCreateApplicationParams {
@@ -12,9 +14,7 @@ interface AshbyCreateApplicationParams {
}
interface AshbyCreateApplicationResponse extends ToolResponse {
output: {
applicationId: string
}
output: AshbyApplication
}
export const createApplicationTool: ToolConfig<
@@ -88,13 +88,13 @@ export const createApplicationTool: ToolConfig<
}),
body: (params) => {
const body: Record<string, unknown> = {
candidateId: params.candidateId,
jobId: params.jobId,
candidateId: params.candidateId.trim(),
jobId: params.jobId.trim(),
}
if (params.interviewPlanId) body.interviewPlanId = params.interviewPlanId
if (params.interviewStageId) body.interviewStageId = params.interviewStageId
if (params.sourceId) body.sourceId = params.sourceId
if (params.creditedToUserId) body.creditedToUserId = params.creditedToUserId
if (params.interviewPlanId) body.interviewPlanId = params.interviewPlanId.trim()
if (params.interviewStageId) body.interviewStageId = params.interviewStageId.trim()
if (params.sourceId) body.sourceId = params.sourceId.trim()
if (params.creditedToUserId) body.creditedToUserId = params.creditedToUserId.trim()
if (params.createdAt) body.createdAt = params.createdAt
return body
},
@@ -107,17 +107,11 @@ export const createApplicationTool: ToolConfig<
throw new Error(data.errorInfo?.message || 'Failed to create application')
}
const r = data.results
return {
success: true,
output: {
applicationId: r.applicationId ?? null,
},
output: mapApplication(data.results),
}
},
outputs: {
applicationId: { type: 'string', description: 'Created application UUID' },
},
outputs: APPLICATION_OUTPUTS,
}

View File

@@ -1,5 +1,6 @@
import type { AshbyCreateCandidateParams, AshbyCreateCandidateResponse } from '@/tools/ashby/types'
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
import type { ToolConfig } from '@/tools/types'
import type { AshbyCreateCandidateParams, AshbyCreateCandidateResponse } from './types'
export const createCandidateTool: ToolConfig<
AshbyCreateCandidateParams,
@@ -25,7 +26,7 @@ export const createCandidateTool: ToolConfig<
},
email: {
type: 'string',
required: true,
required: false,
visibility: 'user-or-llm',
description: 'Primary email address for the candidate',
},
@@ -65,12 +66,12 @@ export const createCandidateTool: ToolConfig<
body: (params) => {
const body: Record<string, unknown> = {
name: params.name,
email: params.email,
}
if (params.email) body.email = params.email
if (params.phoneNumber) body.phoneNumber = params.phoneNumber
if (params.linkedInUrl) body.linkedInUrl = params.linkedInUrl
if (params.githubUrl) body.githubUrl = params.githubUrl
if (params.sourceId) body.sourceId = params.sourceId
if (params.sourceId) body.sourceId = params.sourceId.trim()
return body
},
},
@@ -82,55 +83,11 @@ export const createCandidateTool: ToolConfig<
throw new Error(data.errorInfo?.message || 'Failed to create candidate')
}
const r = data.results
return {
success: true,
output: {
id: r.id ?? null,
name: r.name ?? null,
primaryEmailAddress: r.primaryEmailAddress
? {
value: r.primaryEmailAddress.value ?? '',
type: r.primaryEmailAddress.type ?? 'Other',
isPrimary: r.primaryEmailAddress.isPrimary ?? true,
}
: null,
primaryPhoneNumber: r.primaryPhoneNumber
? {
value: r.primaryPhoneNumber.value ?? '',
type: r.primaryPhoneNumber.type ?? 'Other',
isPrimary: r.primaryPhoneNumber.isPrimary ?? true,
}
: null,
createdAt: r.createdAt ?? null,
},
output: mapCandidate(data.results),
}
},
outputs: {
id: { type: 'string', description: 'Created candidate UUID' },
name: { type: 'string', description: 'Full name' },
primaryEmailAddress: {
type: 'object',
description: 'Primary email contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Email address' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
},
},
primaryPhoneNumber: {
type: 'object',
description: 'Primary phone contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Phone number' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
},
outputs: CANDIDATE_OUTPUTS,
}

View File

@@ -1,5 +1,5 @@
import type { AshbyCreateNoteParams, AshbyCreateNoteResponse } from '@/tools/ashby/types'
import type { ToolConfig } from '@/tools/types'
import type { AshbyCreateNoteParams, AshbyCreateNoteResponse } from './types'
export const createNoteTool: ToolConfig<AshbyCreateNoteParams, AshbyCreateNoteResponse> = {
id: 'ashby_create_note',
@@ -51,7 +51,7 @@ export const createNoteTool: ToolConfig<AshbyCreateNoteParams, AshbyCreateNoteRe
}),
body: (params) => {
const body: Record<string, unknown> = {
candidateId: params.candidateId,
candidateId: params.candidateId.trim(),
sendNotifications: params.sendNotifications ?? false,
}
if (params.noteType === 'text/html') {
@@ -74,16 +74,42 @@ export const createNoteTool: ToolConfig<AshbyCreateNoteParams, AshbyCreateNoteRe
}
const r = data.results
const author = r.author
return {
success: true,
output: {
noteId: r.id ?? null,
id: r.id ?? '',
createdAt: r.createdAt ?? null,
isPrivate: r.isPrivate ?? false,
content: r.content ?? null,
author: author
? {
id: author.id ?? '',
firstName: author.firstName ?? null,
lastName: author.lastName ?? null,
email: author.email ?? null,
}
: null,
},
}
},
outputs: {
noteId: { type: 'string', description: 'Created note UUID' },
id: { type: 'string', description: 'Created note UUID' },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
isPrivate: { type: 'boolean', description: 'Whether the note is private' },
content: { type: 'string', description: 'Note content', optional: true },
author: {
type: 'object',
description: 'Author of the note',
optional: true,
properties: {
id: { type: 'string', description: 'Author user UUID' },
firstName: { type: 'string', description: 'Author first name', optional: true },
lastName: { type: 'string', description: 'Author last name', optional: true },
email: { type: 'string', description: 'Author email', optional: true },
},
},
},
}

View File

@@ -1,3 +1,5 @@
import type { AshbyApplication } from '@/tools/ashby/types'
import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyGetApplicationParams {
@@ -6,35 +8,7 @@ interface AshbyGetApplicationParams {
}
interface AshbyGetApplicationResponse extends ToolResponse {
output: {
id: string
status: string
candidate: {
id: string
name: string
}
job: {
id: string
title: string
}
currentInterviewStage: {
id: string
title: string
type: string
} | null
source: {
id: string
title: string
} | null
archiveReason: {
id: string
text: string
reasonType: string
} | null
archivedAt: string | null
createdAt: string
updatedAt: string
}
output: AshbyApplication
}
export const getApplicationTool: ToolConfig<
@@ -69,7 +43,7 @@ export const getApplicationTool: ToolConfig<
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
applicationId: params.applicationId,
applicationId: params.applicationId.trim(),
}),
},
@@ -80,98 +54,11 @@ export const getApplicationTool: ToolConfig<
throw new Error(data.errorInfo?.message || 'Failed to get application')
}
const r = data.results
return {
success: true,
output: {
id: r.id ?? null,
status: r.status ?? null,
candidate: {
id: r.candidate?.id ?? null,
name: r.candidate?.name ?? null,
},
job: {
id: r.job?.id ?? null,
title: r.job?.title ?? null,
},
currentInterviewStage: r.currentInterviewStage
? {
id: r.currentInterviewStage.id ?? null,
title: r.currentInterviewStage.title ?? null,
type: r.currentInterviewStage.type ?? null,
}
: null,
source: r.source
? {
id: r.source.id ?? null,
title: r.source.title ?? null,
}
: null,
archiveReason: r.archiveReason
? {
id: r.archiveReason.id ?? null,
text: r.archiveReason.text ?? null,
reasonType: r.archiveReason.reasonType ?? null,
}
: null,
archivedAt: r.archivedAt ?? null,
createdAt: r.createdAt ?? null,
updatedAt: r.updatedAt ?? null,
},
output: mapApplication(data.results),
}
},
outputs: {
id: { type: 'string', description: 'Application UUID' },
status: { type: 'string', description: 'Application status (Active, Hired, Archived, Lead)' },
candidate: {
type: 'object',
description: 'Associated candidate',
properties: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Candidate name' },
},
},
job: {
type: 'object',
description: 'Associated job',
properties: {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
},
},
currentInterviewStage: {
type: 'object',
description: 'Current interview stage',
optional: true,
properties: {
id: { type: 'string', description: 'Stage UUID' },
title: { type: 'string', description: 'Stage title' },
type: { type: 'string', description: 'Stage type' },
},
},
source: {
type: 'object',
description: 'Application source',
optional: true,
properties: {
id: { type: 'string', description: 'Source UUID' },
title: { type: 'string', description: 'Source title' },
},
},
archiveReason: {
type: 'object',
description: 'Reason for archival',
optional: true,
properties: {
id: { type: 'string', description: 'Reason UUID' },
text: { type: 'string', description: 'Reason text' },
reasonType: { type: 'string', description: 'Reason type' },
},
},
archivedAt: { type: 'string', description: 'ISO 8601 archive timestamp', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
outputs: APPLICATION_OUTPUTS,
}

View File

@@ -1,5 +1,6 @@
import type { AshbyGetCandidateParams, AshbyGetCandidateResponse } from '@/tools/ashby/types'
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
import type { ToolConfig } from '@/tools/types'
import type { AshbyGetCandidateParams, AshbyGetCandidateResponse } from './types'
export const getCandidateTool: ToolConfig<AshbyGetCandidateParams, AshbyGetCandidateResponse> = {
id: 'ashby_get_candidate',
@@ -30,7 +31,7 @@ export const getCandidateTool: ToolConfig<AshbyGetCandidateParams, AshbyGetCandi
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
candidateId: params.candidateId.trim(),
id: params.candidateId.trim(),
}),
},
@@ -41,94 +42,11 @@ export const getCandidateTool: ToolConfig<AshbyGetCandidateParams, AshbyGetCandi
throw new Error(data.errorInfo?.message || 'Failed to get candidate')
}
const r = data.results
return {
success: true,
output: {
id: r.id ?? null,
name: r.name ?? null,
primaryEmailAddress: r.primaryEmailAddress
? {
value: r.primaryEmailAddress.value ?? '',
type: r.primaryEmailAddress.type ?? 'Other',
isPrimary: r.primaryEmailAddress.isPrimary ?? true,
}
: null,
primaryPhoneNumber: r.primaryPhoneNumber
? {
value: r.primaryPhoneNumber.value ?? '',
type: r.primaryPhoneNumber.type ?? 'Other',
isPrimary: r.primaryPhoneNumber.isPrimary ?? true,
}
: null,
profileUrl: r.profileUrl ?? null,
position: r.position ?? null,
company: r.company ?? null,
linkedInUrl:
(r.socialLinks ?? []).find((l: { type: string }) => l.type === 'LinkedIn')?.url ?? null,
githubUrl:
(r.socialLinks ?? []).find((l: { type: string }) => l.type === 'GitHub')?.url ?? null,
tags: (r.tags ?? []).map((t: { id: string; title: string }) => ({
id: t.id,
title: t.title,
})),
applicationIds: r.applicationIds ?? [],
createdAt: r.createdAt ?? null,
updatedAt: r.updatedAt ?? null,
},
output: mapCandidate(data.results),
}
},
outputs: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Full name' },
primaryEmailAddress: {
type: 'object',
description: 'Primary email contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Email address' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
},
},
primaryPhoneNumber: {
type: 'object',
description: 'Primary phone contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Phone number' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
},
},
profileUrl: {
type: 'string',
description: 'URL to the candidate Ashby profile',
optional: true,
},
position: { type: 'string', description: 'Current position or title', optional: true },
company: { type: 'string', description: 'Current company', optional: true },
linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true },
githubUrl: { type: 'string', description: 'GitHub profile URL', optional: true },
tags: {
type: 'array',
description: 'Tags applied to the candidate',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Tag UUID' },
title: { type: 'string', description: 'Tag title' },
},
},
},
applicationIds: {
type: 'array',
description: 'IDs of associated applications',
items: { type: 'string', description: 'Application UUID' },
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
outputs: CANDIDATE_OUTPUTS,
}

View File

@@ -1,5 +1,6 @@
import type { AshbyGetJobParams, AshbyGetJobResponse } from '@/tools/ashby/types'
import { JOB_OUTPUTS, mapJob } from '@/tools/ashby/utils'
import type { ToolConfig } from '@/tools/types'
import type { AshbyGetJobParams, AshbyGetJobResponse } from './types'
export const getJobTool: ToolConfig<AshbyGetJobParams, AshbyGetJobResponse> = {
id: 'ashby_get_job',
@@ -30,7 +31,7 @@ export const getJobTool: ToolConfig<AshbyGetJobParams, AshbyGetJobResponse> = {
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
jobId: params.jobId.trim(),
id: params.jobId.trim(),
}),
},
@@ -41,43 +42,11 @@ export const getJobTool: ToolConfig<AshbyGetJobParams, AshbyGetJobResponse> = {
throw new Error(data.errorInfo?.message || 'Failed to get job')
}
const r = data.results
return {
success: true,
output: {
id: r.id ?? null,
title: r.title ?? null,
status: r.status ?? null,
employmentType: r.employmentType ?? null,
departmentId: r.departmentId ?? null,
locationId: r.locationId ?? null,
descriptionPlain: r.descriptionPlain ?? null,
isArchived: r.isArchived ?? false,
createdAt: r.createdAt ?? null,
updatedAt: r.updatedAt ?? null,
},
output: mapJob(data.results),
}
},
outputs: {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' },
employmentType: {
type: 'string',
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
optional: true,
},
departmentId: { type: 'string', description: 'Department UUID', optional: true },
locationId: { type: 'string', description: 'Location UUID', optional: true },
descriptionPlain: {
type: 'string',
description: 'Job description in plain text',
optional: true,
},
isArchived: { type: 'boolean', description: 'Whether the job is archived' },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
outputs: JOB_OUTPUTS,
}

View File

@@ -3,20 +3,80 @@ import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyGetJobPostingParams {
apiKey: string
jobPostingId: string
expandApplicationFormDefinition?: boolean
expandSurveyFormDefinitions?: boolean
}
interface AshbyDescriptionPart {
html: string | null
plain: string | null
}
interface AshbyJobPosting {
id: string
title: string
descriptionPlain: string | null
descriptionHtml: string | null
descriptionSocial: string | null
descriptionParts: {
descriptionOpening: AshbyDescriptionPart | null
descriptionBody: AshbyDescriptionPart | null
descriptionClosing: AshbyDescriptionPart | null
} | null
departmentName: string | null
teamName: string | null
teamNameHierarchy: string[]
jobId: string | null
locationName: string | null
locationIds: {
primaryLocationId: string | null
secondaryLocationIds: string[]
} | null
address: {
postalAddress: {
addressCountry: string | null
addressRegion: string | null
addressLocality: string | null
postalCode: string | null
streetAddress: string | null
} | null
} | null
isRemote: boolean
workplaceType: string | null
employmentType: string | null
isListed: boolean
suppressDescriptionOpening: boolean
suppressDescriptionClosing: boolean
publishedDate: string | null
applicationDeadline: string | null
externalLink: string | null
applyLink: string | null
compensation: {
compensationTierSummary: string | null
summaryComponents: Array<{
summary: string | null
compensationTypeLabel: string | null
interval: string | null
currencyCode: string | null
minValue: number | null
maxValue: number | null
}>
shouldDisplayCompensationOnJobBoard: boolean
} | null
applicationLimitCalloutHtml: string | null
updatedAt: string | null
}
interface AshbyGetJobPostingResponse extends ToolResponse {
output: {
id: string
title: string
jobId: string | null
locationName: string | null
departmentName: string | null
employmentType: string | null
descriptionPlain: string | null
isListed: boolean
publishedDate: string | null
externalLink: string | null
output: AshbyJobPosting
}
function mapDescriptionPart(raw: unknown): AshbyDescriptionPart | null {
if (!raw || typeof raw !== 'object') return null
const p = raw as Record<string, unknown>
return {
html: (p.html as string) ?? null,
plain: (p.plain as string) ?? null,
}
}
@@ -39,6 +99,18 @@ export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJob
visibility: 'user-or-llm',
description: 'The UUID of the job posting to fetch',
},
expandApplicationFormDefinition: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Include application form definition in the response',
},
expandSurveyFormDefinitions: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Include survey form definitions in the response',
},
},
request: {
@@ -48,9 +120,18 @@ export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJob
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
jobPostingId: params.jobPostingId,
}),
body: (params) => {
const body: Record<string, unknown> = {
jobPostingId: params.jobPostingId.trim(),
}
if (params.expandApplicationFormDefinition !== undefined) {
body.expandApplicationFormDefinition = params.expandApplicationFormDefinition
}
if (params.expandSurveyFormDefinitions !== undefined) {
body.expandSurveyFormDefinitions = params.expandSurveyFormDefinitions
}
return body
},
},
transformResponse: async (response: Response) => {
@@ -60,21 +141,90 @@ export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJob
throw new Error(data.errorInfo?.message || 'Failed to get job posting')
}
const r = data.results
const r = (data.results ?? {}) as Record<string, unknown> & {
descriptionParts?: Record<string, unknown>
locationIds?: { primaryLocationId?: string; secondaryLocationIds?: string[] }
address?: { postalAddress?: Record<string, unknown> }
compensation?: Record<string, unknown> & {
summaryComponents?: Array<Record<string, unknown>>
}
}
const pa = r.address?.postalAddress
const comp = r.compensation
const summaryComponents = Array.isArray(comp?.summaryComponents) ? comp.summaryComponents : []
const descParts = r.descriptionParts
return {
success: true,
output: {
id: r.id ?? null,
title: r.jobTitle ?? r.title ?? null,
jobId: r.jobId ?? null,
locationName: r.locationName ?? null,
departmentName: r.departmentName ?? null,
employmentType: r.employmentType ?? null,
descriptionPlain: r.descriptionPlain ?? r.description ?? null,
isListed: r.isListed ?? false,
publishedDate: r.publishedDate ?? null,
externalLink: r.externalLink ?? null,
id: (r.id as string) ?? '',
title: (r.title as string) ?? '',
descriptionPlain: (r.descriptionPlain as string) ?? null,
descriptionHtml: (r.descriptionHtml as string) ?? null,
descriptionSocial: (r.descriptionSocial as string) ?? null,
descriptionParts: descParts
? {
descriptionOpening: mapDescriptionPart(descParts.descriptionOpening),
descriptionBody: mapDescriptionPart(descParts.descriptionBody),
descriptionClosing: mapDescriptionPart(descParts.descriptionClosing),
}
: null,
departmentName: (r.departmentName as string) ?? null,
teamName: (r.teamName as string) ?? null,
teamNameHierarchy: Array.isArray(r.teamNameHierarchy)
? (r.teamNameHierarchy as string[])
: [],
jobId: (r.jobId as string) ?? null,
locationName: (r.locationName as string) ?? null,
locationIds: r.locationIds
? {
primaryLocationId: r.locationIds.primaryLocationId ?? null,
secondaryLocationIds: Array.isArray(r.locationIds.secondaryLocationIds)
? r.locationIds.secondaryLocationIds
: [],
}
: null,
address: r.address
? {
postalAddress: pa
? {
addressCountry: (pa.addressCountry as string) ?? null,
addressRegion: (pa.addressRegion as string) ?? null,
addressLocality: (pa.addressLocality as string) ?? null,
postalCode: (pa.postalCode as string) ?? null,
streetAddress: (pa.streetAddress as string) ?? null,
}
: null,
}
: null,
isRemote: (r.isRemote as boolean) ?? false,
workplaceType: (r.workplaceType as string) ?? null,
employmentType: (r.employmentType as string) ?? null,
isListed: (r.isListed as boolean) ?? false,
suppressDescriptionOpening: (r.suppressDescriptionOpening as boolean) ?? false,
suppressDescriptionClosing: (r.suppressDescriptionClosing as boolean) ?? false,
publishedDate: (r.publishedDate as string) ?? null,
applicationDeadline: (r.applicationDeadline as string) ?? null,
externalLink: (r.externalLink as string) ?? null,
applyLink: (r.applyLink as string) ?? null,
compensation: comp
? {
compensationTierSummary: (comp.compensationTierSummary as string) ?? null,
summaryComponents: summaryComponents.map((c) => ({
summary: (c.summary as string) ?? null,
compensationTypeLabel: (c.compensationTypeLabel as string) ?? null,
interval: (c.interval as string) ?? null,
currencyCode: (c.currencyCode as string) ?? null,
minValue: (c.minValue as number) ?? null,
maxValue: (c.maxValue as number) ?? null,
})),
shouldDisplayCompensationOnJobBoard:
(comp.shouldDisplayCompensationOnJobBoard as boolean) ?? false,
}
: null,
applicationLimitCalloutHtml: (r.applicationLimitCalloutHtml as string) ?? null,
updatedAt: (r.updatedAt as string) ?? null,
},
}
},
@@ -82,25 +232,188 @@ export const getJobPostingTool: ToolConfig<AshbyGetJobPostingParams, AshbyGetJob
outputs: {
id: { type: 'string', description: 'Job posting UUID' },
title: { type: 'string', description: 'Job posting title' },
jobId: { type: 'string', description: 'Associated job UUID', optional: true },
locationName: { type: 'string', description: 'Location name', optional: true },
departmentName: { type: 'string', description: 'Department name', optional: true },
employmentType: {
type: 'string',
description: 'Employment type (e.g. FullTime, PartTime, Contract)',
optional: true,
},
descriptionPlain: {
type: 'string',
description: 'Job posting description in plain text',
description: 'Full description in plain text',
optional: true,
},
isListed: { type: 'boolean', description: 'Whether the posting is publicly listed' },
descriptionHtml: {
type: 'string',
description: 'Full description in HTML',
optional: true,
},
descriptionSocial: {
type: 'string',
description: 'Shortened description for social sharing (max 200 chars)',
optional: true,
},
descriptionParts: {
type: 'object',
description: 'Description broken into opening, body, and closing sections',
optional: true,
properties: {
descriptionOpening: {
type: 'object',
description: 'Opening (from Job Boards theme settings)',
optional: true,
properties: {
html: { type: 'string', description: 'HTML content', optional: true },
plain: { type: 'string', description: 'Plain text content', optional: true },
},
},
descriptionBody: {
type: 'object',
description: 'Main description body',
optional: true,
properties: {
html: { type: 'string', description: 'HTML content', optional: true },
plain: { type: 'string', description: 'Plain text content', optional: true },
},
},
descriptionClosing: {
type: 'object',
description: 'Closing (from Job Boards theme settings)',
optional: true,
properties: {
html: { type: 'string', description: 'HTML content', optional: true },
plain: { type: 'string', description: 'Plain text content', optional: true },
},
},
},
},
departmentName: { type: 'string', description: 'Department name', optional: true },
teamName: { type: 'string', description: 'Team name', optional: true },
teamNameHierarchy: {
type: 'array',
description: 'Hierarchy of team names from root to team',
items: { type: 'string', description: 'Team name' },
},
jobId: { type: 'string', description: 'Associated job UUID', optional: true },
locationName: { type: 'string', description: 'Primary location name', optional: true },
locationIds: {
type: 'object',
description: 'Primary and secondary location UUIDs',
optional: true,
properties: {
primaryLocationId: {
type: 'string',
description: 'Primary location UUID',
optional: true,
},
secondaryLocationIds: {
type: 'array',
description: 'Secondary location UUIDs',
items: { type: 'string', description: 'Location UUID' },
},
},
},
address: {
type: 'object',
description: 'Postal address of the posting location',
optional: true,
properties: {
postalAddress: {
type: 'object',
description: 'Structured postal address',
optional: true,
properties: {
addressCountry: { type: 'string', description: 'Country', optional: true },
addressRegion: { type: 'string', description: 'State or region', optional: true },
addressLocality: { type: 'string', description: 'City or locality', optional: true },
postalCode: { type: 'string', description: 'Postal code', optional: true },
streetAddress: { type: 'string', description: 'Street address', optional: true },
},
},
},
},
isRemote: { type: 'boolean', description: 'Whether the posting is remote' },
workplaceType: {
type: 'string',
description: 'Workplace type (OnSite, Remote, Hybrid)',
optional: true,
},
employmentType: {
type: 'string',
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
optional: true,
},
isListed: { type: 'boolean', description: 'Whether publicly listed on the job board' },
suppressDescriptionOpening: {
type: 'boolean',
description: 'Whether the theme opening is hidden on this posting',
},
suppressDescriptionClosing: {
type: 'boolean',
description: 'Whether the theme closing is hidden on this posting',
},
publishedDate: { type: 'string', description: 'ISO 8601 published date', optional: true },
applicationDeadline: {
type: 'string',
description: 'ISO 8601 application deadline',
optional: true,
},
externalLink: {
type: 'string',
description: 'External link to the job posting',
optional: true,
},
applyLink: {
type: 'string',
description: 'Direct apply link',
optional: true,
},
compensation: {
type: 'object',
description: 'Compensation details for the posting',
optional: true,
properties: {
compensationTierSummary: {
type: 'string',
description: 'Human-readable tier summary',
optional: true,
},
summaryComponents: {
type: 'array',
description: 'Structured compensation components',
items: {
type: 'object',
properties: {
summary: { type: 'string', description: 'Component summary', optional: true },
compensationTypeLabel: {
type: 'string',
description: 'Component type label (Salary, Commission, Bonus, Equity, etc.)',
optional: true,
},
interval: {
type: 'string',
description: 'Payment interval (e.g. annual, hourly)',
optional: true,
},
currencyCode: {
type: 'string',
description: 'ISO 4217 currency code',
optional: true,
},
minValue: { type: 'number', description: 'Minimum value', optional: true },
maxValue: { type: 'number', description: 'Maximum value', optional: true },
},
},
},
shouldDisplayCompensationOnJobBoard: {
type: 'boolean',
description: 'Whether compensation is shown on the job board',
},
},
},
applicationLimitCalloutHtml: {
type: 'string',
description: 'HTML callout shown when application limit is reached',
optional: true,
},
updatedAt: {
type: 'string',
description: 'ISO 8601 last update timestamp',
optional: true,
},
},
}

View File

@@ -1,3 +1,5 @@
import type { AshbyOffer } from '@/tools/ashby/types'
import { mapOffer, OFFER_OUTPUTS } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyGetOfferParams {
@@ -6,19 +8,7 @@ interface AshbyGetOfferParams {
}
interface AshbyGetOfferResponse extends ToolResponse {
output: {
id: string
offerStatus: string
acceptanceStatus: string | null
applicationId: string | null
startDate: string | null
salary: {
currencyCode: string
value: number
} | null
openingId: string | null
createdAt: string | null
}
output: AshbyOffer
}
export const getOfferTool: ToolConfig<AshbyGetOfferParams, AshbyGetOfferResponse> = {
@@ -50,7 +40,7 @@ export const getOfferTool: ToolConfig<AshbyGetOfferParams, AshbyGetOfferResponse
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
offerId: params.offerId,
offerId: params.offerId.trim(),
}),
},
@@ -61,56 +51,11 @@ export const getOfferTool: ToolConfig<AshbyGetOfferParams, AshbyGetOfferResponse
throw new Error(data.errorInfo?.message || 'Failed to get offer')
}
const r = data.results
const v = r.latestVersion
return {
success: true,
output: {
id: r.id ?? null,
offerStatus: r.offerStatus ?? null,
acceptanceStatus: r.acceptanceStatus ?? null,
applicationId: r.applicationId ?? null,
startDate: v?.startDate ?? null,
salary: v?.salary
? {
currencyCode: v.salary.currencyCode ?? null,
value: v.salary.value ?? null,
}
: null,
openingId: v?.openingId ?? null,
createdAt: v?.createdAt ?? null,
},
output: mapOffer(data.results),
}
},
outputs: {
id: { type: 'string', description: 'Offer UUID' },
offerStatus: {
type: 'string',
description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)',
},
acceptanceStatus: {
type: 'string',
description: 'Acceptance status (e.g. Accepted, Declined, Pending)',
optional: true,
},
applicationId: { type: 'string', description: 'Associated application UUID', optional: true },
startDate: { type: 'string', description: 'Offer start date', optional: true },
salary: {
type: 'object',
description: 'Salary details',
optional: true,
properties: {
currencyCode: { type: 'string', description: 'ISO 4217 currency code' },
value: { type: 'number', description: 'Salary amount' },
},
},
openingId: { type: 'string', description: 'Associated opening UUID', optional: true },
createdAt: {
type: 'string',
description: 'ISO 8601 creation timestamp (from latest version)',
optional: true,
},
},
outputs: OFFER_OUTPUTS,
}

View File

@@ -1,5 +1,9 @@
import type {
AshbyListApplicationsParams,
AshbyListApplicationsResponse,
} from '@/tools/ashby/types'
import { APPLICATION_OUTPUTS, mapApplication } from '@/tools/ashby/utils'
import type { ToolConfig } from '@/tools/types'
import type { AshbyListApplicationsParams, AshbyListApplicationsResponse } from './types'
export const listApplicationsTool: ToolConfig<
AshbyListApplicationsParams,
@@ -8,7 +12,7 @@ export const listApplicationsTool: ToolConfig<
id: 'ashby_list_applications',
name: 'Ashby List Applications',
description:
'Lists all applications in an Ashby organization with pagination and optional filters for status, job, candidate, and creation date.',
'Lists all applications in an Ashby organization with pagination and optional filters for status, job, and creation date.',
version: '1.0.0',
params: {
@@ -42,12 +46,6 @@ export const listApplicationsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Filter applications by a specific job UUID',
},
candidateId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter applications by a specific candidate UUID',
},
createdAfter: {
type: 'string',
required: false,
@@ -68,10 +66,12 @@ export const listApplicationsTool: ToolConfig<
const body: Record<string, unknown> = {}
if (params.cursor) body.cursor = params.cursor
if (params.perPage) body.limit = params.perPage
if (params.status) body.status = [params.status]
if (params.jobId) body.jobId = params.jobId
if (params.candidateId) body.candidateId = params.candidateId
if (params.createdAfter) body.createdAfter = new Date(params.createdAfter).getTime()
if (params.status) body.status = params.status
if (params.jobId) body.jobId = params.jobId.trim()
if (params.createdAfter) {
const ms = new Date(params.createdAfter).getTime()
if (!Number.isNaN(ms)) body.createdAfter = ms
}
return body
},
},
@@ -86,42 +86,7 @@ export const listApplicationsTool: ToolConfig<
return {
success: true,
output: {
applications: (data.results ?? []).map(
(
a: Record<string, unknown> & {
candidate?: { id?: string; name?: string }
job?: { id?: string; title?: string }
currentInterviewStage?: { id?: string; title?: string; type?: string } | null
source?: { id?: string; title?: string } | null
}
) => ({
id: a.id ?? null,
status: a.status ?? null,
candidate: {
id: a.candidate?.id ?? null,
name: a.candidate?.name ?? null,
},
job: {
id: a.job?.id ?? null,
title: a.job?.title ?? null,
},
currentInterviewStage: a.currentInterviewStage
? {
id: a.currentInterviewStage.id ?? null,
title: a.currentInterviewStage.title ?? null,
type: a.currentInterviewStage.type ?? null,
}
: null,
source: a.source
? {
id: a.source.id ?? null,
title: a.source.title ?? null,
}
: null,
createdAt: a.createdAt ?? null,
updatedAt: a.updatedAt ?? null,
})
),
applications: (data.results ?? []).map(mapApplication),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
@@ -134,50 +99,7 @@ export const listApplicationsTool: ToolConfig<
description: 'List of applications',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Application UUID' },
status: {
type: 'string',
description: 'Application status (Active, Hired, Archived, Lead)',
},
candidate: {
type: 'object',
description: 'Associated candidate',
properties: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Candidate name' },
},
},
job: {
type: 'object',
description: 'Associated job',
properties: {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
},
},
currentInterviewStage: {
type: 'object',
description: 'Current interview stage',
optional: true,
properties: {
id: { type: 'string', description: 'Stage UUID' },
title: { type: 'string', description: 'Stage title' },
type: { type: 'string', description: 'Stage type' },
},
},
source: {
type: 'object',
description: 'Application source',
optional: true,
properties: {
id: { type: 'string', description: 'Source UUID' },
title: { type: 'string', description: 'Source title' },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
properties: APPLICATION_OUTPUTS,
},
},
moreDataAvailable: {

View File

@@ -2,16 +2,19 @@ import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListArchiveReasonsParams {
apiKey: string
includeArchived?: boolean
}
interface AshbyArchiveReason {
id: string
text: string
reasonType: string
isArchived: boolean
}
interface AshbyListArchiveReasonsResponse extends ToolResponse {
output: {
archiveReasons: Array<{
id: string
text: string
reasonType: string
isArchived: boolean
}>
archiveReasons: AshbyArchiveReason[]
}
}
@@ -31,6 +34,12 @@ export const listArchiveReasonsTool: ToolConfig<
visibility: 'user-only',
description: 'Ashby API Key',
},
includeArchived: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to include archived archive reasons in the response (default false)',
},
},
request: {
@@ -40,7 +49,11 @@ export const listArchiveReasonsTool: ToolConfig<
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.includeArchived !== undefined) body.includeArchived = params.includeArchived
return body
},
},
transformResponse: async (response: Response) => {
@@ -54,10 +67,10 @@ export const listArchiveReasonsTool: ToolConfig<
success: true,
output: {
archiveReasons: (data.results ?? []).map((r: Record<string, unknown>) => ({
id: r.id ?? null,
text: r.text ?? null,
reasonType: r.reasonType ?? null,
isArchived: r.isArchived ?? false,
id: (r.id as string) ?? '',
text: (r.text as string) ?? '',
reasonType: (r.reasonType as string) ?? '',
isArchived: (r.isArchived as boolean) ?? false,
})),
},
}
@@ -72,7 +85,10 @@ export const listArchiveReasonsTool: ToolConfig<
properties: {
id: { type: 'string', description: 'Archive reason UUID' },
text: { type: 'string', description: 'Archive reason text' },
reasonType: { type: 'string', description: 'Reason type' },
reasonType: {
type: 'string',
description: 'Reason type (RejectedByCandidate, RejectedByOrg, Other)',
},
isArchived: { type: 'boolean', description: 'Whether the reason is archived' },
},
},

View File

@@ -2,15 +2,24 @@ import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListCandidateTagsParams {
apiKey: string
includeArchived?: boolean
cursor?: string
syncToken?: string
perPage?: number
}
interface AshbyCandidateTag {
id: string
title: string
isArchived: boolean
}
interface AshbyListCandidateTagsResponse extends ToolResponse {
output: {
tags: Array<{
id: string
title: string
isArchived: boolean
}>
tags: AshbyCandidateTag[]
moreDataAvailable: boolean
nextCursor: string | null
syncToken: string | null
}
}
@@ -30,6 +39,30 @@ export const listCandidateTagsTool: ToolConfig<
visibility: 'user-only',
description: 'Ashby API Key',
},
includeArchived: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to include archived candidate tags (default false)',
},
cursor: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Opaque pagination cursor from a previous response nextCursor value',
},
syncToken: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Sync token from a previous response to fetch only changed results',
},
perPage: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of results per page (default 100)',
},
},
request: {
@@ -39,7 +72,14 @@ export const listCandidateTagsTool: ToolConfig<
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: () => ({}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.includeArchived !== undefined) body.includeArchived = params.includeArchived
if (params.cursor) body.cursor = params.cursor
if (params.syncToken) body.syncToken = params.syncToken
if (params.perPage) body.limit = params.perPage
return body
},
},
transformResponse: async (response: Response) => {
@@ -53,10 +93,13 @@ export const listCandidateTagsTool: ToolConfig<
success: true,
output: {
tags: (data.results ?? []).map((t: Record<string, unknown>) => ({
id: t.id ?? null,
title: t.title ?? null,
isArchived: t.isArchived ?? false,
id: (t.id as string) ?? '',
title: (t.title as string) ?? '',
isArchived: (t.isArchived as boolean) ?? false,
})),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
syncToken: data.syncToken ?? null,
},
}
},
@@ -74,5 +117,19 @@ export const listCandidateTagsTool: ToolConfig<
},
},
},
moreDataAvailable: {
type: 'boolean',
description: 'Whether more pages of results exist',
},
nextCursor: {
type: 'string',
description: 'Opaque cursor for fetching the next page',
optional: true,
},
syncToken: {
type: 'string',
description: 'Sync token to use for incremental updates in future requests',
optional: true,
},
},
}

View File

@@ -1,5 +1,6 @@
import type { AshbyListCandidatesParams, AshbyListCandidatesResponse } from '@/tools/ashby/types'
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
import type { ToolConfig } from '@/tools/types'
import type { AshbyListCandidatesParams, AshbyListCandidatesResponse } from './types'
export const listCandidatesTool: ToolConfig<
AshbyListCandidatesParams,
@@ -56,33 +57,7 @@ export const listCandidatesTool: ToolConfig<
return {
success: true,
output: {
candidates: (data.results ?? []).map(
(
c: Record<string, unknown> & {
primaryEmailAddress?: { value?: string; type?: string; isPrimary?: boolean }
primaryPhoneNumber?: { value?: string; type?: string; isPrimary?: boolean }
}
) => ({
id: c.id ?? null,
name: c.name ?? null,
primaryEmailAddress: c.primaryEmailAddress
? {
value: c.primaryEmailAddress.value ?? '',
type: c.primaryEmailAddress.type ?? 'Other',
isPrimary: c.primaryEmailAddress.isPrimary ?? true,
}
: null,
primaryPhoneNumber: c.primaryPhoneNumber
? {
value: c.primaryPhoneNumber.value ?? '',
type: c.primaryPhoneNumber.type ?? 'Other',
isPrimary: c.primaryPhoneNumber.isPrimary ?? true,
}
: null,
createdAt: c.createdAt ?? null,
updatedAt: c.updatedAt ?? null,
})
),
candidates: (data.results ?? []).map(mapCandidate),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
@@ -95,32 +70,7 @@ export const listCandidatesTool: ToolConfig<
description: 'List of candidates',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Full name' },
primaryEmailAddress: {
type: 'object',
description: 'Primary email contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Email address' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
},
},
primaryPhoneNumber: {
type: 'object',
description: 'Primary phone contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Phone number' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
properties: CANDIDATE_OUTPUTS,
},
},
moreDataAvailable: {

View File

@@ -4,15 +4,24 @@ interface AshbyListCustomFieldsParams {
apiKey: string
}
interface AshbyCustomFieldDefinition {
id: string
title: string
isPrivate: boolean
fieldType: string
objectType: string
isArchived: boolean
isRequired: boolean
selectableValues: Array<{
label: string
value: string
isArchived: boolean
}>
}
interface AshbyListCustomFieldsResponse extends ToolResponse {
output: {
customFields: Array<{
id: string
title: string
fieldType: string
objectType: string
isArchived: boolean
}>
customFields: AshbyCustomFieldDefinition[]
}
}
@@ -54,13 +63,24 @@ export const listCustomFieldsTool: ToolConfig<
return {
success: true,
output: {
customFields: (data.results ?? []).map((f: Record<string, unknown>) => ({
id: f.id ?? null,
title: f.title ?? null,
fieldType: f.fieldType ?? null,
objectType: f.objectType ?? null,
isArchived: f.isArchived ?? false,
})),
customFields: (data.results ?? []).map(
(f: Record<string, unknown> & { selectableValues?: Array<Record<string, unknown>> }) => ({
id: (f.id as string) ?? '',
title: (f.title as string) ?? '',
isPrivate: (f.isPrivate as boolean) ?? false,
fieldType: (f.fieldType as string) ?? '',
objectType: (f.objectType as string) ?? '',
isArchived: (f.isArchived as boolean) ?? false,
isRequired: (f.isRequired as boolean) ?? false,
selectableValues: Array.isArray(f.selectableValues)
? f.selectableValues.map((v) => ({
label: (v.label as string) ?? '',
value: (v.value as string) ?? '',
isArchived: (v.isArchived as boolean) ?? false,
}))
: [],
})
),
},
}
},
@@ -74,12 +94,35 @@ export const listCustomFieldsTool: ToolConfig<
properties: {
id: { type: 'string', description: 'Custom field UUID' },
title: { type: 'string', description: 'Custom field title' },
fieldType: { type: 'string', description: 'Field type (e.g. String, Number, Boolean)' },
isPrivate: {
type: 'boolean',
description: 'Whether the custom field is private',
},
fieldType: {
type: 'string',
description:
'Field data type (MultiValueSelect, NumberRange, String, Date, ValueSelect, Number, Currency, Boolean, LongText, CompensationRange)',
},
objectType: {
type: 'string',
description: 'Object type the field applies to (e.g. Candidate, Application, Job)',
description:
'Object type the field applies to (Application, Candidate, Employee, Job, Offer, Opening, Talent_Project)',
},
isArchived: { type: 'boolean', description: 'Whether the custom field is archived' },
isRequired: { type: 'boolean', description: 'Whether a value is required' },
selectableValues: {
type: 'array',
description:
'Selectable values for MultiValueSelect fields (empty for other field types)',
items: {
type: 'object',
properties: {
label: { type: 'string', description: 'Display label' },
value: { type: 'string', description: 'Stored value' },
isArchived: { type: 'boolean', description: 'Whether archived' },
},
},
},
},
},
},

View File

@@ -4,14 +4,19 @@ interface AshbyListDepartmentsParams {
apiKey: string
}
interface AshbyDepartment {
id: string
name: string
externalName: string | null
isArchived: boolean
parentId: string | null
createdAt: string | null
updatedAt: string | null
}
interface AshbyListDepartmentsResponse extends ToolResponse {
output: {
departments: Array<{
id: string
name: string
isArchived: boolean
parentId: string | null
}>
departments: AshbyDepartment[]
}
}
@@ -54,10 +59,13 @@ export const listDepartmentsTool: ToolConfig<
success: true,
output: {
departments: (data.results ?? []).map((d: Record<string, unknown>) => ({
id: d.id ?? null,
name: d.name ?? null,
isArchived: d.isArchived ?? false,
id: (d.id as string) ?? '',
name: (d.name as string) ?? '',
externalName: (d.externalName as string) ?? null,
isArchived: (d.isArchived as boolean) ?? false,
parentId: (d.parentId as string) ?? null,
createdAt: (d.createdAt as string) ?? null,
updatedAt: (d.updatedAt as string) ?? null,
})),
},
}
@@ -72,12 +80,27 @@ export const listDepartmentsTool: ToolConfig<
properties: {
id: { type: 'string', description: 'Department UUID' },
name: { type: 'string', description: 'Department name' },
externalName: {
type: 'string',
description: 'Candidate-facing name used on job boards',
optional: true,
},
isArchived: { type: 'boolean', description: 'Whether the department is archived' },
parentId: {
type: 'string',
description: 'Parent department UUID',
optional: true,
},
createdAt: {
type: 'string',
description: 'ISO 8601 creation timestamp',
optional: true,
},
updatedAt: {
type: 'string',
description: 'ISO 8601 last update timestamp',
optional: true,
},
},
},
},

View File

@@ -1,3 +1,5 @@
import type { AshbyUserSummary } from '@/tools/ashby/types'
import { mapUserSummary, USER_SUMMARY_OUTPUT } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListInterviewSchedulesParams {
@@ -8,20 +10,81 @@ interface AshbyListInterviewSchedulesParams {
perPage?: number
}
interface AshbyInterviewEvent {
id: string
interviewId: string | null
interviewScheduleId: string | null
interviewerUserIds: string[]
createdAt: string | null
updatedAt: string | null
startTime: string | null
endTime: string | null
feedbackLink: string | null
location: string | null
meetingLink: string | null
hasSubmittedFeedback: boolean
}
interface AshbyInterviewSchedule {
id: string
status: string | null
applicationId: string
interviewStageId: string | null
scheduledBy: AshbyUserSummary | null
createdAt: string | null
updatedAt: string | null
interviewEvents: AshbyInterviewEvent[]
}
interface AshbyListInterviewSchedulesResponse extends ToolResponse {
output: {
interviewSchedules: Array<{
id: string
applicationId: string
interviewStageId: string | null
status: string | null
createdAt: string
}>
interviewSchedules: AshbyInterviewSchedule[]
moreDataAvailable: boolean
nextCursor: string | null
}
}
type UnknownRecord = Record<string, unknown>
function mapInterviewEvent(raw: unknown): AshbyInterviewEvent | null {
if (!raw || typeof raw !== 'object') return null
const e = raw as UnknownRecord
return {
id: (e.id as string) ?? '',
interviewId: (e.interviewId as string) ?? null,
interviewScheduleId: (e.interviewScheduleId as string) ?? null,
interviewerUserIds: Array.isArray(e.interviewerUserIds)
? (e.interviewerUserIds as string[])
: [],
createdAt: (e.createdAt as string) ?? null,
updatedAt: (e.updatedAt as string) ?? null,
startTime: (e.startTime as string) ?? null,
endTime: (e.endTime as string) ?? null,
feedbackLink: (e.feedbackLink as string) ?? null,
location: (e.location as string) ?? null,
meetingLink: (e.meetingLink as string) ?? null,
hasSubmittedFeedback: (e.hasSubmittedFeedback as boolean) ?? false,
}
}
function mapInterviewSchedule(raw: unknown): AshbyInterviewSchedule {
const s = (raw ?? {}) as UnknownRecord
return {
id: (s.id as string) ?? '',
status: (s.status as string) ?? null,
applicationId: (s.applicationId as string) ?? '',
interviewStageId: (s.interviewStageId as string) ?? null,
scheduledBy: mapUserSummary(s.scheduledBy),
createdAt: (s.createdAt as string) ?? null,
updatedAt: (s.updatedAt as string) ?? null,
interviewEvents: Array.isArray(s.interviewEvents)
? (s.interviewEvents as unknown[])
.map(mapInterviewEvent)
.filter((e): e is AshbyInterviewEvent => e !== null)
: [],
}
}
export const listInterviewsTool: ToolConfig<
AshbyListInterviewSchedulesParams,
AshbyListInterviewSchedulesResponse
@@ -74,8 +137,8 @@ export const listInterviewsTool: ToolConfig<
}),
body: (params) => {
const body: Record<string, unknown> = {}
if (params.applicationId) body.applicationId = params.applicationId
if (params.interviewStageId) body.interviewStageId = params.interviewStageId
if (params.applicationId) body.applicationId = params.applicationId.trim()
if (params.interviewStageId) body.interviewStageId = params.interviewStageId.trim()
if (params.cursor) body.cursor = params.cursor
if (params.perPage) body.limit = params.perPage
return body
@@ -92,13 +155,7 @@ export const listInterviewsTool: ToolConfig<
return {
success: true,
output: {
interviewSchedules: (data.results ?? []).map((s: Record<string, unknown>) => ({
id: s.id ?? null,
applicationId: s.applicationId ?? null,
interviewStageId: s.interviewStageId ?? null,
status: s.status ?? null,
createdAt: s.createdAt ?? null,
})),
interviewSchedules: (data.results ?? []).map(mapInterviewSchedule),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
@@ -113,14 +170,92 @@ export const listInterviewsTool: ToolConfig<
type: 'object',
properties: {
id: { type: 'string', description: 'Interview schedule UUID' },
status: {
type: 'string',
description:
'Schedule status (NeedsScheduling, WaitingOnCandidateBooking, Scheduled, Complete, Cancelled, OnHold, etc.)',
optional: true,
},
applicationId: { type: 'string', description: 'Associated application UUID' },
interviewStageId: {
type: 'string',
description: 'Interview stage UUID',
optional: true,
},
status: { type: 'string', description: 'Schedule status', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
scheduledBy: {
...USER_SUMMARY_OUTPUT,
description: 'User who scheduled the interview (null if not yet scheduled)',
},
createdAt: {
type: 'string',
description: 'ISO 8601 creation timestamp',
optional: true,
},
updatedAt: {
type: 'string',
description: 'ISO 8601 last update timestamp',
optional: true,
},
interviewEvents: {
type: 'array',
description: 'Scheduled interview events on this schedule',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Event UUID' },
interviewId: {
type: 'string',
description: 'Interview template UUID',
optional: true,
},
interviewScheduleId: {
type: 'string',
description: 'Parent schedule UUID',
optional: true,
},
interviewerUserIds: {
type: 'array',
description: 'User UUIDs of interviewers assigned to the event',
items: { type: 'string', description: 'User UUID' },
},
createdAt: {
type: 'string',
description: 'Event creation timestamp',
optional: true,
},
updatedAt: {
type: 'string',
description: 'Event last updated timestamp',
optional: true,
},
startTime: {
type: 'string',
description: 'Event start time',
optional: true,
},
endTime: { type: 'string', description: 'Event end time', optional: true },
feedbackLink: {
type: 'string',
description: 'URL to submit feedback for the event',
optional: true,
},
location: {
type: 'string',
description: 'Physical location',
optional: true,
},
meetingLink: {
type: 'string',
description: 'Virtual meeting URL',
optional: true,
},
hasSubmittedFeedback: {
type: 'boolean',
description: 'Whether any feedback has been submitted',
},
},
},
},
},
},
},

View File

@@ -4,18 +4,32 @@ interface AshbyListJobPostingsParams {
apiKey: string
}
interface AshbyJobPostingSummary {
id: string
title: string
jobId: string | null
departmentName: string | null
teamName: string | null
locationName: string | null
locationIds: {
primaryLocationId: string | null
secondaryLocationIds: string[]
} | null
workplaceType: string | null
employmentType: string | null
isListed: boolean
publishedDate: string | null
applicationDeadline: string | null
externalLink: string | null
applyLink: string | null
compensationTierSummary: string | null
shouldDisplayCompensationOnJobBoard: boolean
updatedAt: string | null
}
interface AshbyListJobPostingsResponse extends ToolResponse {
output: {
jobPostings: Array<{
id: string
title: string
jobId: string | null
locationName: string | null
departmentName: string | null
employmentType: string | null
isListed: boolean
publishedDate: string | null
}>
jobPostings: AshbyJobPostingSummary[]
}
}
@@ -57,16 +71,39 @@ export const listJobPostingsTool: ToolConfig<
return {
success: true,
output: {
jobPostings: (data.results ?? []).map((jp: Record<string, unknown>) => ({
id: jp.id ?? null,
title: (jp.jobTitle as string) ?? (jp.title as string) ?? null,
jobId: jp.jobId ?? null,
locationName: jp.locationName ?? null,
departmentName: jp.departmentName ?? null,
employmentType: jp.employmentType ?? null,
isListed: jp.isListed ?? false,
publishedDate: jp.publishedDate ?? null,
})),
jobPostings: (data.results ?? []).map(
(
jp: Record<string, unknown> & {
locationIds?: { primaryLocationId?: string; secondaryLocationIds?: string[] }
}
) => ({
id: (jp.id as string) ?? '',
title: (jp.title as string) ?? '',
jobId: (jp.jobId as string) ?? null,
departmentName: (jp.departmentName as string) ?? null,
teamName: (jp.teamName as string) ?? null,
locationName: (jp.locationName as string) ?? null,
locationIds: jp.locationIds
? {
primaryLocationId: jp.locationIds.primaryLocationId ?? null,
secondaryLocationIds: Array.isArray(jp.locationIds.secondaryLocationIds)
? jp.locationIds.secondaryLocationIds
: [],
}
: null,
workplaceType: (jp.workplaceType as string) ?? null,
employmentType: (jp.employmentType as string) ?? null,
isListed: (jp.isListed as boolean) ?? false,
publishedDate: (jp.publishedDate as string) ?? null,
applicationDeadline: (jp.applicationDeadline as string) ?? null,
externalLink: (jp.externalLink as string) ?? null,
applyLink: (jp.applyLink as string) ?? null,
compensationTierSummary: (jp.compensationTierSummary as string) ?? null,
shouldDisplayCompensationOnJobBoard:
(jp.shouldDisplayCompensationOnJobBoard as boolean) ?? false,
updatedAt: (jp.updatedAt as string) ?? null,
})
),
},
}
},
@@ -81,15 +118,75 @@ export const listJobPostingsTool: ToolConfig<
id: { type: 'string', description: 'Job posting UUID' },
title: { type: 'string', description: 'Job posting title' },
jobId: { type: 'string', description: 'Associated job UUID', optional: true },
locationName: { type: 'string', description: 'Location name', optional: true },
departmentName: { type: 'string', description: 'Department name', optional: true },
teamName: { type: 'string', description: 'Team name', optional: true },
locationName: {
type: 'string',
description: 'Primary location display name',
optional: true,
},
locationIds: {
type: 'object',
description: 'Primary and secondary location UUIDs',
optional: true,
properties: {
primaryLocationId: {
type: 'string',
description: 'Primary location UUID',
optional: true,
},
secondaryLocationIds: {
type: 'array',
description: 'Secondary location UUIDs',
items: { type: 'string', description: 'Location UUID' },
},
},
},
workplaceType: {
type: 'string',
description: 'Workplace type (OnSite, Remote, Hybrid)',
optional: true,
},
employmentType: {
type: 'string',
description: 'Employment type (e.g. FullTime, PartTime, Contract)',
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
optional: true,
},
isListed: { type: 'boolean', description: 'Whether the posting is publicly listed' },
publishedDate: { type: 'string', description: 'ISO 8601 published date', optional: true },
publishedDate: {
type: 'string',
description: 'ISO 8601 published date',
optional: true,
},
applicationDeadline: {
type: 'string',
description: 'ISO 8601 application deadline',
optional: true,
},
externalLink: {
type: 'string',
description: 'External link to the job posting',
optional: true,
},
applyLink: {
type: 'string',
description: 'Direct apply link for the job posting',
optional: true,
},
compensationTierSummary: {
type: 'string',
description: 'Compensation tier summary for job boards',
optional: true,
},
shouldDisplayCompensationOnJobBoard: {
type: 'boolean',
description: 'Whether compensation is shown on the job board',
},
updatedAt: {
type: 'string',
description: 'ISO 8601 last update timestamp',
optional: true,
},
},
},
},

View File

@@ -1,5 +1,6 @@
import type { AshbyListJobsParams, AshbyListJobsResponse } from '@/tools/ashby/types'
import { JOB_OUTPUTS, mapJob } from '@/tools/ashby/utils'
import type { ToolConfig } from '@/tools/types'
import type { AshbyListJobsParams, AshbyListJobsResponse } from './types'
export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse> = {
id: 'ashby_list_jobs',
@@ -61,16 +62,7 @@ export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse
return {
success: true,
output: {
jobs: (data.results ?? []).map((j: Record<string, unknown>) => ({
id: j.id ?? null,
title: j.title ?? null,
status: j.status ?? null,
employmentType: j.employmentType ?? null,
departmentId: j.departmentId ?? null,
locationId: j.locationId ?? null,
createdAt: j.createdAt ?? null,
updatedAt: j.updatedAt ?? null,
})),
jobs: (data.results ?? []).map(mapJob),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
@@ -83,20 +75,7 @@ export const listJobsTool: ToolConfig<AshbyListJobsParams, AshbyListJobsResponse
description: 'List of jobs',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
status: { type: 'string', description: 'Job status (Open, Closed, Archived, Draft)' },
employmentType: {
type: 'string',
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
optional: true,
},
departmentId: { type: 'string', description: 'Department UUID', optional: true },
locationId: { type: 'string', description: 'Location UUID', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
properties: JOB_OUTPUTS,
},
},
moreDataAvailable: {

View File

@@ -4,19 +4,27 @@ interface AshbyListLocationsParams {
apiKey: string
}
interface AshbyLocation {
id: string
name: string
externalName: string | null
isArchived: boolean
isRemote: boolean
workplaceType: string | null
parentLocationId: string | null
type: string | null
address: {
addressCountry: string | null
addressRegion: string | null
addressLocality: string | null
postalCode: string | null
streetAddress: string | null
} | null
}
interface AshbyListLocationsResponse extends ToolResponse {
output: {
locations: Array<{
id: string
name: string
isArchived: boolean
isRemote: boolean
address: {
city: string | null
region: string | null
country: string | null
} | null
}>
locations: AshbyLocation[]
}
}
@@ -58,27 +66,30 @@ export const listLocationsTool: ToolConfig<AshbyListLocationsParams, AshbyListLo
locations: (data.results ?? []).map(
(
l: Record<string, unknown> & {
address?: {
postalAddress?: {
addressLocality?: string
addressRegion?: string
addressCountry?: string
}
}
address?: { postalAddress?: Record<string, unknown> }
}
) => ({
id: l.id ?? null,
name: l.name ?? null,
isArchived: l.isArchived ?? false,
isRemote: l.isRemote ?? false,
address: l.address?.postalAddress
? {
city: l.address.postalAddress.addressLocality ?? null,
region: l.address.postalAddress.addressRegion ?? null,
country: l.address.postalAddress.addressCountry ?? null,
}
: null,
})
) => {
const pa = l.address?.postalAddress
return {
id: (l.id as string) ?? '',
name: (l.name as string) ?? '',
externalName: (l.externalName as string) ?? null,
isArchived: (l.isArchived as boolean) ?? false,
isRemote: (l.isRemote as boolean) ?? false,
workplaceType: (l.workplaceType as string) ?? null,
parentLocationId: (l.parentLocationId as string) ?? null,
type: (l.type as string) ?? null,
address: pa
? {
addressCountry: (pa.addressCountry as string) ?? null,
addressRegion: (pa.addressRegion as string) ?? null,
addressLocality: (pa.addressLocality as string) ?? null,
postalCode: (pa.postalCode as string) ?? null,
streetAddress: (pa.streetAddress as string) ?? null,
}
: null,
}
}
),
},
}
@@ -93,16 +104,49 @@ export const listLocationsTool: ToolConfig<AshbyListLocationsParams, AshbyListLo
properties: {
id: { type: 'string', description: 'Location UUID' },
name: { type: 'string', description: 'Location name' },
externalName: {
type: 'string',
description: 'Candidate-facing name used on job boards',
optional: true,
},
isArchived: { type: 'boolean', description: 'Whether the location is archived' },
isRemote: { type: 'boolean', description: 'Whether this is a remote location' },
isRemote: {
type: 'boolean',
description: 'Whether the location is remote (use workplaceType instead)',
},
workplaceType: {
type: 'string',
description: 'Workplace type (OnSite, Hybrid, Remote)',
optional: true,
},
parentLocationId: {
type: 'string',
description: 'Parent location UUID',
optional: true,
},
type: {
type: 'string',
description: 'Location component type (Location, LocationHierarchy)',
optional: true,
},
address: {
type: 'object',
description: 'Location address',
description: 'Location postal address',
optional: true,
properties: {
city: { type: 'string', description: 'City', optional: true },
region: { type: 'string', description: 'State or region', optional: true },
country: { type: 'string', description: 'Country', optional: true },
addressCountry: { type: 'string', description: 'Country', optional: true },
addressRegion: {
type: 'string',
description: 'State or region',
optional: true,
},
addressLocality: {
type: 'string',
description: 'City or locality',
optional: true,
},
postalCode: { type: 'string', description: 'Postal code', optional: true },
streetAddress: { type: 'string', description: 'Street address', optional: true },
},
},
},

View File

@@ -11,14 +11,15 @@ interface AshbyListNotesResponse extends ToolResponse {
output: {
notes: Array<{
id: string
content: string
content: string | null
isPrivate: boolean
author: {
id: string
firstName: string
lastName: string
email: string
firstName: string | null
lastName: string | null
email: string | null
} | null
createdAt: string
createdAt: string | null
}>
moreDataAvailable: boolean
nextCursor: string | null
@@ -67,7 +68,7 @@ export const listNotesTool: ToolConfig<AshbyListNotesParams, AshbyListNotesRespo
}),
body: (params) => {
const body: Record<string, unknown> = {
candidateId: params.candidateId,
candidateId: params.candidateId.trim(),
}
if (params.cursor) body.cursor = params.cursor
if (params.perPage) body.limit = params.perPage
@@ -91,17 +92,18 @@ export const listNotesTool: ToolConfig<AshbyListNotesParams, AshbyListNotesRespo
author?: { id?: string; firstName?: string; lastName?: string; email?: string }
}
) => ({
id: n.id ?? null,
content: n.content ?? null,
id: (n.id as string) ?? '',
content: (n.content as string) ?? null,
isPrivate: (n.isPrivate as boolean) ?? false,
author: n.author
? {
id: n.author.id ?? null,
id: n.author.id ?? '',
firstName: n.author.firstName ?? null,
lastName: n.author.lastName ?? null,
email: n.author.email ?? null,
}
: null,
createdAt: n.createdAt ?? null,
createdAt: (n.createdAt as string) ?? null,
})
),
moreDataAvailable: data.moreDataAvailable ?? false,
@@ -118,19 +120,20 @@ export const listNotesTool: ToolConfig<AshbyListNotesParams, AshbyListNotesRespo
type: 'object',
properties: {
id: { type: 'string', description: 'Note UUID' },
content: { type: 'string', description: 'Note content' },
content: { type: 'string', description: 'Note content', optional: true },
isPrivate: { type: 'boolean', description: 'Whether the note is private' },
author: {
type: 'object',
description: 'Note author',
optional: true,
properties: {
id: { type: 'string', description: 'Author user UUID' },
firstName: { type: 'string', description: 'First name' },
lastName: { type: 'string', description: 'Last name' },
email: { type: 'string', description: 'Email address' },
firstName: { type: 'string', description: 'First name', optional: true },
lastName: { type: 'string', description: 'Last name', optional: true },
email: { type: 'string', description: 'Email address', optional: true },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
},
},
},

View File

@@ -1,3 +1,5 @@
import type { AshbyOffer } from '@/tools/ashby/types'
import { mapOffer, OFFER_OUTPUTS } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListOffersParams {
@@ -8,19 +10,7 @@ interface AshbyListOffersParams {
interface AshbyListOffersResponse extends ToolResponse {
output: {
offers: Array<{
id: string
offerStatus: string
acceptanceStatus: string | null
applicationId: string | null
startDate: string | null
salary: {
currencyCode: string
value: number
} | null
openingId: string | null
createdAt: string | null
}>
offers: AshbyOffer[]
moreDataAvailable: boolean
nextCursor: string | null
}
@@ -78,35 +68,7 @@ export const listOffersTool: ToolConfig<AshbyListOffersParams, AshbyListOffersRe
return {
success: true,
output: {
offers: (data.results ?? []).map(
(
o: Record<string, unknown> & {
latestVersion?: {
startDate?: string
salary?: { currencyCode?: string; value?: number }
openingId?: string
createdAt?: string
}
}
) => {
const v = o.latestVersion
return {
id: o.id ?? null,
offerStatus: o.offerStatus ?? null,
acceptanceStatus: o.acceptanceStatus ?? null,
applicationId: o.applicationId ?? null,
startDate: v?.startDate ?? null,
salary: v?.salary
? {
currencyCode: v.salary.currencyCode ?? null,
value: v.salary.value ?? null,
}
: null,
openingId: v?.openingId ?? null,
createdAt: v?.createdAt ?? null,
}
}
),
offers: (data.results ?? []).map(mapOffer),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
@@ -119,28 +81,7 @@ export const listOffersTool: ToolConfig<AshbyListOffersParams, AshbyListOffersRe
description: 'List of offers',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Offer UUID' },
offerStatus: { type: 'string', description: 'Offer status' },
acceptanceStatus: { type: 'string', description: 'Acceptance status', optional: true },
applicationId: {
type: 'string',
description: 'Associated application UUID',
optional: true,
},
startDate: { type: 'string', description: 'Offer start date', optional: true },
salary: {
type: 'object',
description: 'Salary details',
optional: true,
properties: {
currencyCode: { type: 'string', description: 'ISO 4217 currency code' },
value: { type: 'number', description: 'Salary amount' },
},
},
openingId: { type: 'string', description: 'Associated opening UUID', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
},
properties: OFFER_OUTPUTS,
},
},
moreDataAvailable: {

View File

@@ -1,3 +1,5 @@
import type { AshbyOpening } from '@/tools/ashby/types'
import { mapOpenings, OPENINGS_OUTPUT } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListOpeningsParams {
@@ -8,13 +10,7 @@ interface AshbyListOpeningsParams {
interface AshbyListOpeningsResponse extends ToolResponse {
output: {
openings: Array<{
id: string
openingState: string | null
isArchived: boolean
openedAt: string | null
closedAt: string | null
}>
openings: AshbyOpening[]
moreDataAvailable: boolean
nextCursor: string | null
}
@@ -72,13 +68,7 @@ export const listOpeningsTool: ToolConfig<AshbyListOpeningsParams, AshbyListOpen
return {
success: true,
output: {
openings: (data.results ?? []).map((o: Record<string, unknown>) => ({
id: o.id ?? null,
openingState: o.openingState ?? null,
isArchived: o.isArchived ?? false,
openedAt: o.openedAt ?? null,
closedAt: o.closedAt ?? null,
})),
openings: mapOpenings(data.results),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
@@ -86,24 +76,7 @@ export const listOpeningsTool: ToolConfig<AshbyListOpeningsParams, AshbyListOpen
},
outputs: {
openings: {
type: 'array',
description: 'List of openings',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Opening UUID' },
openingState: {
type: 'string',
description: 'Opening state (Approved, Closed, Draft, Filled, Open)',
optional: true,
},
isArchived: { type: 'boolean', description: 'Whether the opening is archived' },
openedAt: { type: 'string', description: 'ISO 8601 opened timestamp', optional: true },
closedAt: { type: 'string', description: 'ISO 8601 closed timestamp', optional: true },
},
},
},
openings: OPENINGS_OUTPUT,
moreDataAvailable: {
type: 'boolean',
description: 'Whether more pages of results exist',

View File

@@ -1,3 +1,4 @@
import type { AshbySourceSummary } from '@/tools/ashby/types'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListSourcesParams {
@@ -6,11 +7,7 @@ interface AshbyListSourcesParams {
interface AshbyListSourcesResponse extends ToolResponse {
output: {
sources: Array<{
id: string
title: string
isArchived: boolean
}>
sources: AshbySourceSummary[]
}
}
@@ -49,11 +46,23 @@ export const listSourcesTool: ToolConfig<AshbyListSourcesParams, AshbyListSource
return {
success: true,
output: {
sources: (data.results ?? []).map((s: Record<string, unknown>) => ({
id: s.id ?? null,
title: s.title ?? null,
isArchived: s.isArchived ?? false,
})),
sources: (data.results ?? []).map(
(s: Record<string, unknown> & { sourceType?: Record<string, unknown> }) => {
const sourceType = s.sourceType
return {
id: (s.id as string) ?? '',
title: (s.title as string) ?? '',
isArchived: (s.isArchived as boolean) ?? false,
sourceType: sourceType
? {
id: (sourceType.id as string) ?? '',
title: (sourceType.title as string) ?? '',
isArchived: (sourceType.isArchived as boolean) ?? false,
}
: null,
}
}
),
},
}
},
@@ -68,6 +77,16 @@ export const listSourcesTool: ToolConfig<AshbyListSourcesParams, AshbyListSource
id: { type: 'string', description: 'Source UUID' },
title: { type: 'string', description: 'Source title' },
isArchived: { type: 'boolean', description: 'Whether the source is archived' },
sourceType: {
type: 'object',
description: 'Source type grouping',
optional: true,
properties: {
id: { type: 'string', description: 'Source type UUID' },
title: { type: 'string', description: 'Source type title' },
isArchived: { type: 'boolean', description: 'Whether archived' },
},
},
},
},
},

View File

@@ -1,3 +1,5 @@
import type { AshbyUserSummary } from '@/tools/ashby/types'
import { mapUserSummary, USER_SUMMARY_OUTPUT } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyListUsersParams {
@@ -8,14 +10,7 @@ interface AshbyListUsersParams {
interface AshbyListUsersResponse extends ToolResponse {
output: {
users: Array<{
id: string
firstName: string
lastName: string
email: string
isEnabled: boolean
globalRole: string | null
}>
users: AshbyUserSummary[]
moreDataAvailable: boolean
nextCursor: string | null
}
@@ -73,14 +68,9 @@ export const listUsersTool: ToolConfig<AshbyListUsersParams, AshbyListUsersRespo
return {
success: true,
output: {
users: (data.results ?? []).map((u: Record<string, unknown>) => ({
id: u.id ?? null,
firstName: u.firstName ?? null,
lastName: u.lastName ?? null,
email: u.email ?? null,
isEnabled: u.isEnabled ?? false,
globalRole: u.globalRole ?? null,
})),
users: (data.results ?? [])
.map(mapUserSummary)
.filter((u: AshbyUserSummary | null): u is AshbyUserSummary => u !== null),
moreDataAvailable: data.moreDataAvailable ?? false,
nextCursor: data.nextCursor ?? null,
},
@@ -93,19 +83,7 @@ export const listUsersTool: ToolConfig<AshbyListUsersParams, AshbyListUsersRespo
description: 'List of users',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'User UUID' },
firstName: { type: 'string', description: 'First name' },
lastName: { type: 'string', description: 'Last name' },
email: { type: 'string', description: 'Email address' },
isEnabled: { type: 'boolean', description: 'Whether the user account is enabled' },
globalRole: {
type: 'string',
description:
'User role (Organization Admin, Elevated Access, Limited Access, External Recruiter)',
optional: true,
},
},
properties: USER_SUMMARY_OUTPUT.properties,
},
},
moreDataAvailable: {

View File

@@ -1,3 +1,5 @@
import type { AshbyCandidate } from '@/tools/ashby/types'
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
interface AshbyRemoveCandidateTagParams {
@@ -7,9 +9,7 @@ interface AshbyRemoveCandidateTagParams {
}
interface AshbyRemoveCandidateTagResponse extends ToolResponse {
output: {
success: boolean
}
output: AshbyCandidate
}
export const removeCandidateTagTool: ToolConfig<
@@ -18,7 +18,7 @@ export const removeCandidateTagTool: ToolConfig<
> = {
id: 'ashby_remove_candidate_tag',
name: 'Ashby Remove Candidate Tag',
description: 'Removes a tag from a candidate in Ashby.',
description: 'Removes a tag from a candidate in Ashby and returns the updated candidate.',
version: '1.0.0',
params: {
@@ -50,8 +50,8 @@ export const removeCandidateTagTool: ToolConfig<
Authorization: `Basic ${btoa(`${params.apiKey}:`)}`,
}),
body: (params) => ({
candidateId: params.candidateId,
tagId: params.tagId,
candidateId: params.candidateId.trim(),
tagId: params.tagId.trim(),
}),
},
@@ -64,13 +64,9 @@ export const removeCandidateTagTool: ToolConfig<
return {
success: true,
output: {
success: true,
},
output: mapCandidate(data.results),
}
},
outputs: {
success: { type: 'boolean', description: 'Whether the tag was successfully removed' },
},
outputs: CANDIDATE_OUTPUTS,
}

View File

@@ -1,5 +1,9 @@
import type {
AshbySearchCandidatesParams,
AshbySearchCandidatesResponse,
} from '@/tools/ashby/types'
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
import type { ToolConfig } from '@/tools/types'
import type { AshbySearchCandidatesParams, AshbySearchCandidatesResponse } from './types'
export const searchCandidatesTool: ToolConfig<
AshbySearchCandidatesParams,
@@ -57,33 +61,7 @@ export const searchCandidatesTool: ToolConfig<
return {
success: true,
output: {
candidates: (data.results ?? []).map(
(
c: Record<string, unknown> & {
primaryEmailAddress?: { value?: string; type?: string; isPrimary?: boolean }
primaryPhoneNumber?: { value?: string; type?: string; isPrimary?: boolean }
}
) => ({
id: c.id ?? null,
name: c.name ?? null,
primaryEmailAddress: c.primaryEmailAddress
? {
value: c.primaryEmailAddress.value ?? '',
type: c.primaryEmailAddress.type ?? 'Other',
isPrimary: c.primaryEmailAddress.isPrimary ?? true,
}
: null,
primaryPhoneNumber: c.primaryPhoneNumber
? {
value: c.primaryPhoneNumber.value ?? '',
type: c.primaryPhoneNumber.type ?? 'Other',
isPrimary: c.primaryPhoneNumber.isPrimary ?? true,
}
: null,
createdAt: c.createdAt ?? null,
updatedAt: c.updatedAt ?? null,
})
),
candidates: (data.results ?? []).map(mapCandidate),
},
}
},
@@ -94,32 +72,7 @@ export const searchCandidatesTool: ToolConfig<
description: 'Matching candidates (max 100 results)',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Full name' },
primaryEmailAddress: {
type: 'object',
description: 'Primary email contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Email address' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
},
},
primaryPhoneNumber: {
type: 'object',
description: 'Primary phone contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Phone number' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
},
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
properties: CANDIDATE_OUTPUTS,
},
},
},

View File

@@ -10,6 +10,87 @@ export interface AshbyContactInfo {
isPrimary: boolean
}
export interface AshbySocialLink {
type: string
url: string
}
export interface AshbyTag {
id: string
title: string
isArchived: boolean
}
export interface AshbyFileHandle {
id: string
name: string
handle: string
}
export interface AshbyCustomField {
id: string | null
title: string
isPrivate: boolean
valueLabel: string | null
value: unknown
}
export interface AshbyUserSummary {
id: string
firstName: string | null
lastName: string | null
email: string | null
globalRole: string | null
isEnabled: boolean
updatedAt: string | null
managerId: string | null
}
export interface AshbySourceSummary {
id: string
title: string
isArchived: boolean
sourceType: {
id: string
title: string
isArchived: boolean
} | null
}
export interface AshbyCandidateLocation {
id: string | null
locationSummary: string | null
locationComponents: Array<{ type: string; name: string }>
}
export interface AshbyCandidate {
id: string
name: string
primaryEmailAddress: AshbyContactInfo | null
primaryPhoneNumber: AshbyContactInfo | null
emailAddresses: AshbyContactInfo[]
phoneNumbers: AshbyContactInfo[]
socialLinks: AshbySocialLink[]
linkedInUrl: string | null
githubUrl: string | null
profileUrl: string | null
position: string | null
company: string | null
school: string | null
timezone: string | null
location: AshbyCandidateLocation | null
tags: AshbyTag[]
applicationIds: string[]
customFields: AshbyCustomField[]
resumeFileHandle: AshbyFileHandle | null
fileHandles: AshbyFileHandle[]
source: AshbySourceSummary | null
creditedToUser: AshbyUserSummary | null
fraudStatus: string | null
createdAt: string | null
updatedAt: string | null
}
export interface AshbyListCandidatesParams extends AshbyBaseParams {
cursor?: string
perPage?: number
@@ -21,7 +102,7 @@ export interface AshbyGetCandidateParams extends AshbyBaseParams {
export interface AshbyCreateCandidateParams extends AshbyBaseParams {
name: string
email: string
email?: string
phoneNumber?: string
linkedInUrl?: string
githubUrl?: string
@@ -55,130 +136,223 @@ export interface AshbyListApplicationsParams extends AshbyBaseParams {
perPage?: number
status?: string
jobId?: string
candidateId?: string
createdAfter?: string
}
export interface AshbyListCandidatesResponse extends ToolResponse {
output: {
candidates: Array<{
id: string
name: string
primaryEmailAddress: AshbyContactInfo | null
primaryPhoneNumber: AshbyContactInfo | null
createdAt: string
updatedAt: string
}>
candidates: AshbyCandidate[]
moreDataAvailable: boolean
nextCursor: string | null
}
}
export interface AshbyGetCandidateResponse extends ToolResponse {
output: {
id: string
name: string
primaryEmailAddress: AshbyContactInfo | null
primaryPhoneNumber: AshbyContactInfo | null
profileUrl: string | null
position: string | null
company: string | null
linkedInUrl: string | null
githubUrl: string | null
tags: Array<{ id: string; title: string }>
applicationIds: string[]
createdAt: string
updatedAt: string
}
output: AshbyCandidate
}
export interface AshbyCreateCandidateResponse extends ToolResponse {
output: {
id: string
name: string
primaryEmailAddress: AshbyContactInfo | null
primaryPhoneNumber: AshbyContactInfo | null
createdAt: string
}
output: AshbyCandidate
}
export interface AshbySearchCandidatesResponse extends ToolResponse {
output: {
candidates: Array<{
id: string
name: string
primaryEmailAddress: AshbyContactInfo | null
primaryPhoneNumber: AshbyContactInfo | null
createdAt: string
updatedAt: string
}>
candidates: AshbyCandidate[]
}
}
export interface AshbyJobLocation {
id: string | null
name: string | null
externalName: string | null
isArchived: boolean
isRemote: boolean
workplaceType: string | null
parentLocationId: string | null
type: string | null
address: {
addressCountry: string | null
addressRegion: string | null
addressLocality: string | null
postalCode: string | null
streetAddress: string | null
} | null
}
export interface AshbyHiringTeamMember {
email: string | null
firstName: string | null
lastName: string | null
role: string | null
userId: string | null
}
export interface AshbyOpeningLatestVersion {
id: string | null
identifier: string | null
description: string | null
authorId: string | null
createdAt: string | null
teamId: string | null
jobIds: string[]
targetHireDate: string | null
targetStartDate: string | null
isBackfill: boolean
employmentType: string | null
locationIds: string[]
hiringTeam: AshbyHiringTeamMember[]
customFields: AshbyCustomField[]
}
export interface AshbyOpening {
id: string
openedAt: string | null
closedAt: string | null
isArchived: boolean
archivedAt: string | null
closeReasonId: string | null
openingState: string | null
latestVersion: AshbyOpeningLatestVersion | null
}
export interface AshbyJobCompensationTier {
id: string | null
title: string | null
additionalInformation: string | null
tierSummary: string | null
}
export interface AshbyJob {
id: string
title: string
confidential: boolean
status: string | null
employmentType: string | null
locationId: string | null
departmentId: string | null
defaultInterviewPlanId: string | null
interviewPlanIds: string[]
customFields: AshbyCustomField[]
jobPostingIds: string[]
customRequisitionId: string | null
brandId: string | null
hiringTeam: AshbyHiringTeamMember[]
author: AshbyUserSummary | null
createdAt: string | null
updatedAt: string | null
openedAt: string | null
closedAt: string | null
location: AshbyJobLocation | null
openings: AshbyOpening[]
compensation: {
compensationTiers: AshbyJobCompensationTier[]
} | null
}
export interface AshbyListJobsResponse extends ToolResponse {
output: {
jobs: Array<{
id: string
title: string
status: string
employmentType: string | null
departmentId: string | null
locationId: string | null
createdAt: string
updatedAt: string
}>
jobs: AshbyJob[]
moreDataAvailable: boolean
nextCursor: string | null
}
}
export interface AshbyGetJobResponse extends ToolResponse {
output: {
output: AshbyJob
}
export interface AshbyNote {
id: string
createdAt: string | null
isPrivate: boolean
content: string | null
author: {
id: string
title: string
status: string
employmentType: string | null
departmentId: string | null
locationId: string | null
descriptionPlain: string | null
isArchived: boolean
createdAt: string
updatedAt: string
}
firstName: string | null
lastName: string | null
email: string | null
} | null
}
export interface AshbyCreateNoteResponse extends ToolResponse {
output: {
noteId: string
}
output: AshbyNote
}
export interface AshbyApplicationCandidate {
id: string
name: string | null
primaryEmailAddress: AshbyContactInfo | null
primaryPhoneNumber: AshbyContactInfo | null
}
export interface AshbyApplicationJob {
id: string
title: string | null
locationId: string | null
departmentId: string | null
}
export interface AshbyApplicationStage {
id: string
title: string | null
type: string | null
orderInInterviewPlan: number | null
interviewStageGroupId: string | null
interviewPlanId: string | null
}
export interface AshbyApplicationArchiveReason {
id: string
text: string | null
reasonType: string | null
isArchived: boolean
customFields: AshbyCustomField[]
}
export interface AshbyApplication {
id: string
createdAt: string | null
updatedAt: string | null
status: string
customFields: AshbyCustomField[]
candidate: AshbyApplicationCandidate
currentInterviewStage: AshbyApplicationStage | null
source: AshbySourceSummary | null
archiveReason: AshbyApplicationArchiveReason | null
archivedAt: string | null
job: AshbyApplicationJob
creditedToUser: AshbyUserSummary | null
hiringTeam: AshbyHiringTeamMember[]
appliedViaJobPostingId: string | null
submitterClientIp: string | null
submitterUserAgent: string | null
}
export interface AshbyListApplicationsResponse extends ToolResponse {
output: {
applications: Array<{
id: string
status: string
candidate: {
id: string
name: string
}
job: {
id: string
title: string
}
currentInterviewStage: {
id: string
title: string
type: string
} | null
source: {
id: string
title: string
} | null
createdAt: string
updatedAt: string
}>
applications: AshbyApplication[]
moreDataAvailable: boolean
nextCursor: string | null
}
}
export interface AshbyOfferVersion {
id: string | null
startDate: string | null
salary: { currencyCode: string | null; value: number | null } | null
createdAt: string | null
openingId: string | null
customFields: AshbyCustomField[]
fileHandles: AshbyFileHandle[]
author: AshbyUserSummary | null
approvalStatus: string | null
}
export interface AshbyOffer {
id: string
decidedAt: string | null
applicationId: string | null
acceptanceStatus: string | null
offerStatus: string | null
latestVersion: AshbyOfferVersion | null
}

View File

@@ -1,5 +1,6 @@
import type { AshbyGetCandidateResponse } from '@/tools/ashby/types'
import { CANDIDATE_OUTPUTS, mapCandidate } from '@/tools/ashby/utils'
import type { ToolConfig } from '@/tools/types'
import type { AshbyGetCandidateResponse } from './types'
interface AshbyUpdateCandidateParams {
apiKey: string
@@ -88,7 +89,7 @@ export const updateCandidateTool: ToolConfig<
}),
body: (params) => {
const body: Record<string, unknown> = {
candidateId: params.candidateId,
candidateId: params.candidateId.trim(),
}
if (params.name) body.name = params.name
if (params.email) body.email = params.email
@@ -96,7 +97,7 @@ export const updateCandidateTool: ToolConfig<
if (params.linkedInUrl) body.linkedInUrl = params.linkedInUrl
if (params.githubUrl) body.githubUrl = params.githubUrl
if (params.websiteUrl) body.websiteUrl = params.websiteUrl
if (params.sourceId) body.sourceId = params.sourceId
if (params.sourceId) body.sourceId = params.sourceId.trim()
return body
},
},
@@ -108,94 +109,11 @@ export const updateCandidateTool: ToolConfig<
throw new Error(data.errorInfo?.message || 'Failed to update candidate')
}
const r = data.results
return {
success: true,
output: {
id: r.id ?? null,
name: r.name ?? null,
primaryEmailAddress: r.primaryEmailAddress
? {
value: r.primaryEmailAddress.value ?? '',
type: r.primaryEmailAddress.type ?? 'Other',
isPrimary: r.primaryEmailAddress.isPrimary ?? true,
}
: null,
primaryPhoneNumber: r.primaryPhoneNumber
? {
value: r.primaryPhoneNumber.value ?? '',
type: r.primaryPhoneNumber.type ?? 'Other',
isPrimary: r.primaryPhoneNumber.isPrimary ?? true,
}
: null,
profileUrl: r.profileUrl ?? null,
position: r.position ?? null,
company: r.company ?? null,
linkedInUrl:
(r.socialLinks ?? []).find((l: { type: string }) => l.type === 'LinkedIn')?.url ?? null,
githubUrl:
(r.socialLinks ?? []).find((l: { type: string }) => l.type === 'GitHub')?.url ?? null,
tags: (r.tags ?? []).map((t: { id: string; title: string }) => ({
id: t.id,
title: t.title,
})),
applicationIds: r.applicationIds ?? [],
createdAt: r.createdAt ?? null,
updatedAt: r.updatedAt ?? null,
},
output: mapCandidate(data.results),
}
},
outputs: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Full name' },
primaryEmailAddress: {
type: 'object',
description: 'Primary email contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Email address' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary email' },
},
},
primaryPhoneNumber: {
type: 'object',
description: 'Primary phone contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Phone number' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary phone' },
},
},
profileUrl: {
type: 'string',
description: 'URL to the candidate Ashby profile',
optional: true,
},
position: { type: 'string', description: 'Current position or title', optional: true },
company: { type: 'string', description: 'Current company', optional: true },
linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true },
githubUrl: { type: 'string', description: 'GitHub profile URL', optional: true },
tags: {
type: 'array',
description: 'Tags applied to the candidate',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Tag UUID' },
title: { type: 'string', description: 'Tag title' },
},
},
},
applicationIds: {
type: 'array',
description: 'IDs of associated applications',
items: { type: 'string', description: 'Application UUID' },
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
},
outputs: CANDIDATE_OUTPUTS,
}

View File

@@ -0,0 +1,848 @@
import type {
AshbyApplication,
AshbyCandidate,
AshbyContactInfo,
AshbyCustomField,
AshbyFileHandle,
AshbyHiringTeamMember,
AshbyJob,
AshbyOffer,
AshbyOfferVersion,
AshbyOpening,
AshbyOpeningLatestVersion,
AshbySourceSummary,
AshbyUserSummary,
} from '@/tools/ashby/types'
import type { OutputProperty } from '@/tools/types'
type Unknown = Record<string, unknown>
function mapContact(raw: unknown): AshbyContactInfo | null {
if (!raw || typeof raw !== 'object') return null
const c = raw as Unknown
return {
value: (c.value as string) ?? '',
type: (c.type as string) ?? 'Other',
isPrimary: (c.isPrimary as boolean) ?? true,
}
}
function mapContactArray(raw: unknown): AshbyContactInfo[] {
if (!Array.isArray(raw)) return []
return raw.map((c) => mapContact(c)).filter((c): c is AshbyContactInfo => c !== 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,
}
})
}
function mapFileHandle(raw: unknown): AshbyFileHandle | null {
if (!raw || typeof raw !== 'object') return null
const f = raw as Unknown
return {
id: (f.id as string) ?? '',
name: (f.name as string) ?? '',
handle: (f.handle as string) ?? '',
}
}
function mapFileHandles(raw: unknown): AshbyFileHandle[] {
if (!Array.isArray(raw)) return []
return raw.map((f) => mapFileHandle(f)).filter((f): f is AshbyFileHandle => f !== null)
}
export function mapUserSummary(raw: unknown): AshbyUserSummary | null {
if (!raw || typeof raw !== 'object') return null
const u = raw as Unknown
return {
id: (u.id as string) ?? '',
firstName: (u.firstName as string) ?? null,
lastName: (u.lastName as string) ?? null,
email: (u.email as string) ?? null,
globalRole: (u.globalRole as string) ?? null,
isEnabled: (u.isEnabled as boolean) ?? false,
updatedAt: (u.updatedAt as string) ?? null,
managerId: (u.managerId as string) ?? null,
}
}
function mapSource(raw: unknown): AshbySourceSummary | null {
if (!raw || typeof raw !== 'object') return null
const s = raw as Unknown
const sourceType = s.sourceType as Unknown | undefined
return {
id: (s.id as string) ?? '',
title: (s.title as string) ?? '',
isArchived: (s.isArchived as boolean) ?? false,
sourceType: sourceType
? {
id: (sourceType.id as string) ?? '',
title: (sourceType.title as string) ?? '',
isArchived: (sourceType.isArchived as boolean) ?? false,
}
: null,
}
}
export function mapCandidate(raw: unknown): AshbyCandidate {
const c = (raw ?? {}) as Unknown
const socialLinks = Array.isArray(c.socialLinks)
? (c.socialLinks as Array<{ type?: string; url?: string }>)
: []
const location = c.location as Unknown | undefined
const locationComponents = Array.isArray(location?.locationComponents)
? (location?.locationComponents as Array<{ type?: string; name?: string }>)
: []
return {
id: (c.id as string) ?? '',
name: (c.name as string) ?? '',
primaryEmailAddress: mapContact(c.primaryEmailAddress),
primaryPhoneNumber: mapContact(c.primaryPhoneNumber),
emailAddresses: mapContactArray(c.emailAddresses),
phoneNumbers: mapContactArray(c.phoneNumbers),
socialLinks: socialLinks.map((l) => ({
type: l.type ?? '',
url: l.url ?? '',
})),
linkedInUrl: socialLinks.find((l) => l.type === 'LinkedIn')?.url ?? null,
githubUrl: socialLinks.find((l) => l.type === 'GitHub')?.url ?? null,
profileUrl: (c.profileUrl as string) ?? null,
position: (c.position as string) ?? null,
company: (c.company as string) ?? null,
school: (c.school as string) ?? null,
timezone: (c.timezone as string) ?? null,
location: location
? {
id: (location.id as string) ?? null,
locationSummary: (location.locationSummary as string) ?? null,
locationComponents: locationComponents.map((lc) => ({
type: lc.type ?? '',
name: lc.name ?? '',
})),
}
: null,
tags: Array.isArray(c.tags)
? (c.tags as Array<{ id?: string; title?: string; isArchived?: boolean }>).map((t) => ({
id: t.id ?? '',
title: t.title ?? '',
isArchived: t.isArchived ?? false,
}))
: [],
applicationIds: Array.isArray(c.applicationIds) ? (c.applicationIds as string[]) : [],
customFields: mapCustomFields(c.customFields),
resumeFileHandle: mapFileHandle(c.resumeFileHandle),
fileHandles: mapFileHandles(c.fileHandles),
source: mapSource(c.source),
creditedToUser: mapUserSummary(c.creditedToUser),
fraudStatus: (c.fraudStatus as string) ?? null,
createdAt: (c.createdAt as string) ?? null,
updatedAt: (c.updatedAt as string) ?? null,
}
}
function mapHiringTeam(raw: unknown): AshbyHiringTeamMember[] {
if (!Array.isArray(raw)) return []
return raw.map((m) => {
const mem = m as Unknown
return {
email: (mem.email as string) ?? null,
firstName: (mem.firstName as string) ?? null,
lastName: (mem.lastName as string) ?? null,
role: (mem.role as string) ?? null,
userId: (mem.userId as string) ?? null,
}
})
}
function mapOpeningLatestVersion(raw: unknown): AshbyOpeningLatestVersion | null {
if (!raw || typeof raw !== 'object') return null
const v = raw as Unknown
return {
id: (v.id as string) ?? null,
identifier: (v.identifier as string) ?? null,
description: (v.description as string) ?? null,
authorId: (v.authorId as string) ?? null,
createdAt: (v.createdAt as string) ?? null,
teamId: (v.teamId as string) ?? null,
jobIds: Array.isArray(v.jobIds) ? (v.jobIds as string[]) : [],
targetHireDate: (v.targetHireDate as string) ?? null,
targetStartDate: (v.targetStartDate as string) ?? null,
isBackfill: (v.isBackfill as boolean) ?? false,
employmentType: (v.employmentType as string) ?? null,
locationIds: Array.isArray(v.locationIds) ? (v.locationIds as string[]) : [],
hiringTeam: mapHiringTeam(v.hiringTeam),
customFields: mapCustomFields(v.customFields),
}
}
export function mapOpenings(raw: unknown): AshbyOpening[] {
if (!Array.isArray(raw)) return []
return raw.map((o) => {
const op = o as Unknown
return {
id: (op.id as string) ?? '',
openedAt: (op.openedAt as string) ?? null,
closedAt: (op.closedAt as string) ?? null,
isArchived: (op.isArchived as boolean) ?? false,
archivedAt: (op.archivedAt as string) ?? null,
closeReasonId: (op.closeReasonId as string) ?? null,
openingState: (op.openingState as string) ?? null,
latestVersion: mapOpeningLatestVersion(op.latestVersion),
}
})
}
export function mapJob(raw: unknown): AshbyJob {
const j = (raw ?? {}) as Unknown
const location = j.location as Unknown | undefined
const address = location?.address as Unknown | undefined
const postalAddress = address?.postalAddress as Unknown | undefined
const compensation = j.compensation as Unknown | undefined
return {
id: (j.id as string) ?? '',
title: (j.title as string) ?? '',
confidential: (j.confidential as boolean) ?? false,
status: (j.status as string) ?? null,
employmentType: (j.employmentType as string) ?? null,
locationId: (j.locationId as string) ?? null,
departmentId: (j.departmentId as string) ?? null,
defaultInterviewPlanId: (j.defaultInterviewPlanId as string) ?? null,
interviewPlanIds: Array.isArray(j.interviewPlanIds) ? (j.interviewPlanIds as string[]) : [],
customFields: mapCustomFields(j.customFields),
jobPostingIds: Array.isArray(j.jobPostingIds) ? (j.jobPostingIds as string[]) : [],
customRequisitionId: (j.customRequisitionId as string) ?? null,
brandId: (j.brandId as string) ?? null,
hiringTeam: mapHiringTeam(j.hiringTeam),
author: mapUserSummary(j.author),
createdAt: (j.createdAt as string) ?? null,
updatedAt: (j.updatedAt as string) ?? null,
openedAt: (j.openedAt as string) ?? null,
closedAt: (j.closedAt as string) ?? null,
location: location
? {
id: (location.id as string) ?? null,
name: (location.name as string) ?? null,
externalName: (location.externalName as string) ?? null,
isArchived: (location.isArchived as boolean) ?? false,
isRemote: (location.isRemote as boolean) ?? false,
workplaceType: (location.workplaceType as string) ?? null,
parentLocationId: (location.parentLocationId as string) ?? null,
type: (location.type as string) ?? null,
address: postalAddress
? {
addressCountry: (postalAddress.addressCountry as string) ?? null,
addressRegion: (postalAddress.addressRegion as string) ?? null,
addressLocality: (postalAddress.addressLocality as string) ?? null,
postalCode: (postalAddress.postalCode as string) ?? null,
streetAddress: (postalAddress.streetAddress as string) ?? null,
}
: null,
}
: null,
openings: mapOpenings(j.openings),
compensation: compensation
? {
compensationTiers: Array.isArray(compensation.compensationTiers)
? (
compensation.compensationTiers as Array<{
id?: string
title?: string
additionalInformation?: string
tierSummary?: string
}>
).map((t) => ({
id: t.id ?? null,
title: t.title ?? null,
additionalInformation: t.additionalInformation ?? null,
tierSummary: t.tierSummary ?? null,
}))
: [],
}
: null,
}
}
export function mapApplication(raw: unknown): AshbyApplication {
const a = (raw ?? {}) as Unknown
const candidate = a.candidate as Unknown | undefined
const job = a.job as Unknown | undefined
const stage = a.currentInterviewStage as Unknown | undefined
const archiveReason = a.archiveReason as Unknown | undefined
return {
id: (a.id as string) ?? '',
createdAt: (a.createdAt as string) ?? null,
updatedAt: (a.updatedAt as string) ?? null,
status: (a.status as string) ?? '',
customFields: mapCustomFields(a.customFields),
candidate: {
id: (candidate?.id as string) ?? '',
name: (candidate?.name as string) ?? null,
primaryEmailAddress: mapContact(candidate?.primaryEmailAddress),
primaryPhoneNumber: mapContact(candidate?.primaryPhoneNumber),
},
currentInterviewStage: stage
? {
id: (stage.id as string) ?? '',
title: (stage.title as string) ?? null,
type: (stage.type as string) ?? null,
orderInInterviewPlan: (stage.orderInInterviewPlan as number) ?? null,
interviewStageGroupId: (stage.interviewStageGroupId as string) ?? null,
interviewPlanId: (stage.interviewPlanId as string) ?? null,
}
: null,
source: mapSource(a.source),
archiveReason: archiveReason
? {
id: (archiveReason.id as string) ?? '',
text: (archiveReason.text as string) ?? null,
reasonType: (archiveReason.reasonType as string) ?? null,
isArchived: (archiveReason.isArchived as boolean) ?? false,
customFields: mapCustomFields(archiveReason.customFields),
}
: null,
archivedAt: (a.archivedAt as string) ?? null,
job: {
id: (job?.id as string) ?? '',
title: (job?.title as string) ?? null,
locationId: (job?.locationId as string) ?? null,
departmentId: (job?.departmentId as string) ?? null,
},
creditedToUser: mapUserSummary(a.creditedToUser),
hiringTeam: mapHiringTeam(a.hiringTeam),
appliedViaJobPostingId: (a.appliedViaJobPostingId as string) ?? null,
submitterClientIp: (a.submitterClientIp as string) ?? null,
submitterUserAgent: (a.submitterUserAgent as string) ?? null,
}
}
export const CONTACT_INFO_OUTPUT = {
type: 'object',
description: 'Contact info',
optional: true,
properties: {
value: { type: 'string', description: 'Value (email or phone number)' },
type: { type: 'string', description: 'Contact type (Personal, Work, Other)' },
isPrimary: { type: 'boolean', description: 'Whether this is the primary contact' },
},
} as const satisfies 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)' },
},
},
} as const satisfies OutputProperty
export const FILE_HANDLE_OUTPUT = {
type: 'object',
description: 'File reference',
optional: true,
properties: {
id: { type: 'string', description: 'File UUID' },
name: { type: 'string', description: 'File name' },
handle: { type: 'string', description: 'File handle used with file.info' },
},
} as const satisfies OutputProperty
export const FILE_HANDLES_OUTPUT = {
type: 'array',
description: 'File references',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'File UUID' },
name: { type: 'string', description: 'File name' },
handle: { type: 'string', description: 'File handle used with file.info' },
},
},
} as const satisfies OutputProperty
export const USER_SUMMARY_OUTPUT = {
type: 'object',
description: 'User summary',
optional: true,
properties: {
id: { type: 'string', description: 'User UUID' },
firstName: { type: 'string', description: 'First name', optional: true },
lastName: { type: 'string', description: 'Last name', optional: true },
email: { type: 'string', description: 'Email', optional: true },
globalRole: { type: 'string', description: 'Role', optional: true },
isEnabled: { type: 'boolean', description: 'Whether enabled' },
updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
managerId: { type: 'string', description: 'Manager user UUID', optional: true },
},
} as const satisfies OutputProperty
export const SOURCE_SUMMARY_OUTPUT = {
type: 'object',
description: 'Attribution source',
optional: true,
properties: {
id: { type: 'string', description: 'Source UUID' },
title: { type: 'string', description: 'Source title' },
isArchived: { type: 'boolean', description: 'Whether archived' },
sourceType: {
type: 'object',
description: 'Source type grouping',
optional: true,
properties: {
id: { type: 'string', description: 'Source type UUID' },
title: { type: 'string', description: 'Source type title' },
isArchived: { type: 'boolean', description: 'Whether archived' },
},
},
},
} as const satisfies OutputProperty
export const HIRING_TEAM_OUTPUT = {
type: 'array',
description: 'Hiring team members',
items: {
type: 'object',
properties: {
userId: { type: 'string', description: 'User UUID' },
firstName: { type: 'string', description: 'First name' },
lastName: { type: 'string', description: 'Last name' },
email: { type: 'string', description: 'Email' },
role: { type: 'string', description: 'Hiring team role' },
},
},
} as const satisfies OutputProperty
export const CANDIDATE_OUTPUTS = {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Full name' },
primaryEmailAddress: { ...CONTACT_INFO_OUTPUT, description: 'Primary email contact info' },
primaryPhoneNumber: { ...CONTACT_INFO_OUTPUT, description: 'Primary phone contact info' },
emailAddresses: {
type: 'array',
description: 'All email addresses',
items: {
type: 'object',
properties: {
value: { type: 'string', description: 'Email address' },
type: { type: 'string', description: 'Contact type' },
isPrimary: { type: 'boolean', description: 'Whether primary' },
},
},
},
phoneNumbers: {
type: 'array',
description: 'All phone numbers',
items: {
type: 'object',
properties: {
value: { type: 'string', description: 'Phone number' },
type: { type: 'string', description: 'Contact type' },
isPrimary: { type: 'boolean', description: 'Whether primary' },
},
},
},
socialLinks: {
type: 'array',
description: 'Social network links',
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Link type (LinkedIn, GitHub, Twitter, etc.)' },
url: { type: 'string', description: 'Profile URL' },
},
},
},
linkedInUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true },
githubUrl: { type: 'string', description: 'GitHub profile URL', optional: true },
profileUrl: { type: 'string', description: 'URL to the candidate Ashby profile', optional: true },
position: { type: 'string', description: 'Current position or title', optional: true },
company: { type: 'string', description: 'Current company', optional: true },
school: { type: 'string', description: 'Most recent school', optional: true },
timezone: { type: 'string', description: 'Candidate timezone', optional: true },
location: {
type: 'object',
description: 'Candidate location',
optional: true,
properties: {
id: { type: 'string', description: 'Location UUID', optional: true },
locationSummary: { type: 'string', description: 'Human-readable location summary' },
locationComponents: {
type: 'array',
description: 'Structured location parts (city, region, country, etc.)',
items: {
type: 'object',
properties: {
type: { type: 'string', description: 'Component type' },
name: { type: 'string', description: 'Component value' },
},
},
},
},
},
tags: {
type: 'array',
description: 'Tags applied to the candidate',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Tag UUID' },
title: { type: 'string', description: 'Tag title' },
isArchived: { type: 'boolean', description: 'Whether archived' },
},
},
},
applicationIds: {
type: 'array',
description: 'IDs of associated applications',
items: { type: 'string', description: 'Application UUID' },
},
customFields: CUSTOM_FIELDS_OUTPUT,
resumeFileHandle: { ...FILE_HANDLE_OUTPUT, description: 'Resume file reference' },
fileHandles: { ...FILE_HANDLES_OUTPUT, description: 'All uploaded file references' },
source: SOURCE_SUMMARY_OUTPUT,
creditedToUser: { ...USER_SUMMARY_OUTPUT, description: 'User credited with sourcing' },
fraudStatus: { type: 'string', description: 'Fraud detection status', optional: true },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
} as const satisfies Record<string, OutputProperty>
export const APPLICATION_OUTPUTS = {
id: { type: 'string', description: 'Application UUID' },
status: { type: 'string', description: 'Status (Active, Hired, Archived, Lead)' },
customFields: CUSTOM_FIELDS_OUTPUT,
candidate: {
type: 'object',
description: 'Associated candidate summary',
properties: {
id: { type: 'string', description: 'Candidate UUID' },
name: { type: 'string', description: 'Candidate name' },
primaryEmailAddress: { ...CONTACT_INFO_OUTPUT, description: 'Primary email' },
primaryPhoneNumber: { ...CONTACT_INFO_OUTPUT, description: 'Primary phone' },
},
},
currentInterviewStage: {
type: 'object',
description: 'Current interview stage',
optional: true,
properties: {
id: { type: 'string', description: 'Stage UUID' },
title: { type: 'string', description: 'Stage title' },
type: { type: 'string', description: 'Stage type' },
orderInInterviewPlan: {
type: 'number',
description: 'Position in plan',
optional: true,
},
interviewStageGroupId: { type: 'string', description: 'Stage group UUID', optional: true },
interviewPlanId: { type: 'string', description: 'Interview plan UUID', optional: true },
},
},
source: SOURCE_SUMMARY_OUTPUT,
archiveReason: {
type: 'object',
description: 'Reason for archival (when archived)',
optional: true,
properties: {
id: { type: 'string', description: 'Reason UUID' },
text: { type: 'string', description: 'Reason text' },
reasonType: { type: 'string', description: 'Reason category' },
isArchived: { type: 'boolean', description: 'Whether the reason is archived' },
customFields: CUSTOM_FIELDS_OUTPUT,
},
},
archivedAt: { type: 'string', description: 'ISO 8601 archive timestamp', optional: true },
job: {
type: 'object',
description: 'Associated job summary',
properties: {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
locationId: { type: 'string', description: 'Location UUID', optional: true },
departmentId: { type: 'string', description: 'Department UUID', optional: true },
},
},
creditedToUser: { ...USER_SUMMARY_OUTPUT, description: 'User credited with the application' },
hiringTeam: HIRING_TEAM_OUTPUT,
appliedViaJobPostingId: {
type: 'string',
description: 'Job posting UUID the candidate applied through',
optional: true,
},
submitterClientIp: { type: 'string', description: 'Submitter IP address', optional: true },
submitterUserAgent: {
type: 'string',
description: 'Submitter browser user agent',
optional: true,
},
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp' },
} as const satisfies Record<string, OutputProperty>
export const OPENINGS_OUTPUT = {
type: 'array',
description: 'Headcount openings associated with the job',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Opening UUID' },
openedAt: { type: 'string', description: 'Opening open timestamp', optional: true },
closedAt: { type: 'string', description: 'Opening close timestamp', optional: true },
isArchived: { type: 'boolean', description: 'Whether archived' },
archivedAt: { type: 'string', description: 'Archive timestamp', optional: true },
closeReasonId: { type: 'string', description: 'Close reason UUID', optional: true },
openingState: {
type: 'string',
description: 'Opening state (Approved, Open, Filled, Closed, Draft)',
optional: true,
},
latestVersion: {
type: 'object',
description: 'Latest opening version',
optional: true,
properties: {
id: { type: 'string', description: 'Version UUID', optional: true },
identifier: { type: 'string', description: 'Human-readable identifier' },
description: { type: 'string', description: 'Opening description' },
authorId: { type: 'string', description: 'Author user UUID', optional: true },
createdAt: { type: 'string', description: 'Version creation timestamp', optional: true },
teamId: { type: 'string', description: 'Team UUID', optional: true },
jobIds: {
type: 'array',
description: 'Associated job UUIDs',
items: { type: 'string', description: 'Job UUID' },
},
targetHireDate: { type: 'string', description: 'Target hire date', optional: true },
targetStartDate: { type: 'string', description: 'Target start date', optional: true },
isBackfill: { type: 'boolean', description: 'Whether this is a backfill opening' },
employmentType: { type: 'string', description: 'Employment type', optional: true },
locationIds: {
type: 'array',
description: 'Location UUIDs',
items: { type: 'string', description: 'Location UUID' },
},
hiringTeam: HIRING_TEAM_OUTPUT,
customFields: CUSTOM_FIELDS_OUTPUT,
},
},
},
},
} as const satisfies OutputProperty
function mapOfferVersion(raw: unknown): AshbyOfferVersion | null {
if (!raw || typeof raw !== 'object') return null
const v = raw as Unknown
const salary = v.salary as Unknown | undefined
return {
id: (v.id as string) ?? null,
startDate: (v.startDate as string) ?? null,
salary: salary
? {
currencyCode: (salary.currencyCode as string) ?? null,
value: (salary.value as number) ?? null,
}
: null,
createdAt: (v.createdAt as string) ?? null,
openingId: (v.openingId as string) ?? null,
customFields: mapCustomFields(v.customFields),
fileHandles: mapFileHandles(v.fileHandles),
author: mapUserSummary(v.author),
approvalStatus: (v.approvalStatus as string) ?? null,
}
}
export function mapOffer(raw: unknown): AshbyOffer {
const r = (raw ?? {}) as Unknown
return {
id: (r.id as string) ?? '',
decidedAt: (r.decidedAt as string) ?? null,
applicationId: (r.applicationId as string) ?? null,
acceptanceStatus: (r.acceptanceStatus as string) ?? null,
offerStatus: (r.offerStatus as string) ?? null,
latestVersion: mapOfferVersion(r.latestVersion),
}
}
export const OFFER_OUTPUTS = {
id: { type: 'string', description: 'Offer UUID' },
decidedAt: {
type: 'string',
description: 'Timestamp the offer was decided',
optional: true,
},
applicationId: {
type: 'string',
description: 'Associated application UUID',
optional: true,
},
acceptanceStatus: {
type: 'string',
description: 'Acceptance status (Accepted, Declined, Pending, etc.)',
optional: true,
},
offerStatus: {
type: 'string',
description: 'Offer status (e.g. WaitingOnCandidateResponse, CandidateAccepted)',
optional: true,
},
latestVersion: {
type: 'object',
description: 'Most recent version of the offer with pricing and metadata',
optional: true,
properties: {
id: { type: 'string', description: 'Version UUID', optional: true },
startDate: { type: 'string', description: 'Offer start date', optional: true },
salary: {
type: 'object',
description: 'Salary details',
optional: true,
properties: {
currencyCode: {
type: 'string',
description: 'ISO 4217 currency code',
optional: true,
},
value: { type: 'number', description: 'Salary amount', optional: true },
},
},
createdAt: {
type: 'string',
description: 'Version creation timestamp',
optional: true,
},
openingId: {
type: 'string',
description: 'Associated opening UUID',
optional: true,
},
customFields: CUSTOM_FIELDS_OUTPUT,
fileHandles: {
...FILE_HANDLES_OUTPUT,
description:
'Offer letter file handles (unsigned .pdf, .docx, and signed .pdf when generated)',
},
author: { ...USER_SUMMARY_OUTPUT, description: 'User who authored the version' },
approvalStatus: {
type: 'string',
description: 'Approval workflow status',
optional: true,
},
},
},
} as const satisfies Record<string, OutputProperty>
export const JOB_OUTPUTS = {
id: { type: 'string', description: 'Job UUID' },
title: { type: 'string', description: 'Job title' },
confidential: { type: 'boolean', description: 'Whether the job is confidential' },
status: { type: 'string', description: 'Status (Open, Closed, Draft, Archived)', optional: true },
employmentType: {
type: 'string',
description: 'Employment type (FullTime, PartTime, Intern, Contract, Temporary)',
optional: true,
},
locationId: { type: 'string', description: 'Primary location UUID', optional: true },
departmentId: { type: 'string', description: 'Department UUID', optional: true },
defaultInterviewPlanId: {
type: 'string',
description: 'Default interview plan UUID',
optional: true,
},
interviewPlanIds: {
type: 'array',
description: 'All interview plan UUIDs',
items: { type: 'string', description: 'Interview plan UUID' },
},
customFields: CUSTOM_FIELDS_OUTPUT,
jobPostingIds: {
type: 'array',
description: 'Associated job posting UUIDs',
items: { type: 'string', description: 'Job posting UUID' },
},
customRequisitionId: {
type: 'string',
description: 'Custom requisition identifier',
optional: true,
},
brandId: { type: 'string', description: 'Brand UUID', optional: true },
hiringTeam: HIRING_TEAM_OUTPUT,
author: { ...USER_SUMMARY_OUTPUT, description: 'Job author (creator)' },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp', optional: true },
updatedAt: { type: 'string', description: 'ISO 8601 last update timestamp', optional: true },
openedAt: { type: 'string', description: 'ISO 8601 opened timestamp', optional: true },
closedAt: { type: 'string', description: 'ISO 8601 closed timestamp', optional: true },
location: {
type: 'object',
description: 'Primary location details',
optional: true,
properties: {
id: { type: 'string', description: 'Location UUID', optional: true },
name: { type: 'string', description: 'Location name', optional: true },
externalName: { type: 'string', description: 'External display name', optional: true },
isArchived: { type: 'boolean', description: 'Whether archived' },
isRemote: { type: 'boolean', description: 'Whether remote' },
workplaceType: {
type: 'string',
description: 'Workplace type (OnSite, Remote, Hybrid)',
optional: true,
},
parentLocationId: {
type: 'string',
description: 'Parent location UUID',
optional: true,
},
type: { type: 'string', description: 'Location type', optional: true },
address: {
type: 'object',
description: 'Postal address',
optional: true,
properties: {
addressCountry: { type: 'string', description: 'Country', optional: true },
addressRegion: { type: 'string', description: 'State or region', optional: true },
addressLocality: { type: 'string', description: 'City or locality', optional: true },
postalCode: { type: 'string', description: 'Postal code', optional: true },
streetAddress: { type: 'string', description: 'Street address', optional: true },
},
},
},
},
openings: OPENINGS_OUTPUT,
compensation: {
type: 'object',
description: 'Job compensation structure',
optional: true,
properties: {
compensationTiers: {
type: 'array',
description: 'Compensation tiers',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Tier UUID', optional: true },
title: { type: 'string', description: 'Tier title', optional: true },
additionalInformation: {
type: 'string',
description: 'Additional info',
optional: true,
},
tierSummary: { type: 'string', description: 'Tier summary', optional: true },
},
},
},
},
},
} as const satisfies Record<string, OutputProperty>

View File

@@ -13,6 +13,30 @@ export const ashbyTriggerOptions = [
{ label: 'Offer Created', id: 'ashby_offer_create' },
]
/**
* Maps Sim trigger IDs to Ashby webhookType / event action values.
* Used by webhook.create body and matchEvent filtering.
*/
export const ASHBY_TRIGGER_ACTION_MAP: Record<string, string> = {
ashby_application_submit: 'applicationSubmit',
ashby_candidate_stage_change: 'candidateStageChange',
ashby_candidate_hire: 'candidateHire',
ashby_candidate_delete: 'candidateDelete',
ashby_job_create: 'jobCreate',
ashby_offer_create: 'offerCreate',
}
/**
* Checks if an Ashby webhook event matches the configured trigger.
* Ashby sends a ping event on webhook create/edit; this filter rejects
* any event whose `action` does not equal the expected webhookType.
*/
export function isAshbyEventMatch(triggerId: string, action: string): boolean {
const expected = ASHBY_TRIGGER_ACTION_MAP[triggerId]
if (!expected) return false
return expected === action
}
/**
* Generates setup instructions for Ashby webhooks.
* Webhooks are automatically created/deleted via the Ashby API.
@@ -168,9 +192,8 @@ export function buildCandidateStageChangeOutputs(): Record<string, TriggerOutput
/**
* Build outputs for candidateHire events.
* Payload: { action, data: { application: { id, createdAt, updatedAt, status,
* candidate: { id, name }, currentInterviewStage: { id, title },
* job: { id, title } } } }
* Per Ashby docs, candidateHire payloads include application details and most
* recent accepted offer information.
*/
export function buildCandidateHireOutputs(): Record<string, TriggerOutput> {
return {
@@ -196,6 +219,19 @@ export function buildCandidateHireOutputs(): Record<string, TriggerOutput> {
title: { type: 'string', description: 'Job title' },
},
},
offer: {
id: { type: 'string', description: 'Accepted offer UUID' },
applicationId: { type: 'string', description: 'Associated application UUID' },
acceptanceStatus: { type: 'string', description: 'Offer acceptance status' },
offerStatus: { type: 'string', description: 'Offer process status' },
decidedAt: {
type: 'string',
description: 'Offer decision timestamp (ISO 8601)',
},
latestVersion: {
id: { type: 'string', description: 'Latest offer version UUID' },
},
},
} as Record<string, TriggerOutput>
}

View File

@@ -1,7 +1,7 @@
# ========================================
# Base Stage: Debian-based Bun with Node.js 22
# ========================================
FROM oven/bun:1.3.11-slim AS base
FROM oven/bun:1.3.13-slim AS base
# Install Node.js 22 and common dependencies once in base stage
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \

View File

@@ -1,7 +1,7 @@
# ========================================
# Base Stage: Alpine Linux with Bun
# ========================================
FROM oven/bun:1.3.11-alpine AS base
FROM oven/bun:1.3.13-alpine AS base
# ========================================
# Dependencies Stage: Install Dependencies

View File

@@ -1,7 +1,7 @@
# ========================================
# Base Stage: Alpine Linux with Bun
# ========================================
FROM oven/bun:1.3.11-alpine AS base
FROM oven/bun:1.3.13-alpine AS base
RUN apk add --no-cache libc6-compat curl

View File

@@ -1,6 +1,6 @@
{
"name": "simstudio",
"packageManager": "bun@1.3.11",
"packageManager": "bun@1.3.13",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",

View File

@@ -0,0 +1,67 @@
-- Replace increment_user_table_row_count() with a race-free conditional UPDATE.
-- The prior version did SELECT row_count -> IF check -> UPDATE, which is
-- TOCTOU-vulnerable: two concurrent inserts near max_rows could both pass the
-- check and push row_count past the cap. Application code compensated with
-- SELECT ... FOR UPDATE on user_table_definitions, creating a serialization
-- hotspot on every insert.
--
-- The new version performs capacity check and increment as a single
-- conditional UPDATE. The UPDATE takes a row-level exclusive lock atomically,
-- so concurrent inserts serialize on that lock; once row_count reaches
-- max_rows, the WHERE clause returns zero rows and we RAISE. This lets the
-- application drop its FOR UPDATE without losing correctness.
CREATE OR REPLACE FUNCTION increment_user_table_row_count()
RETURNS TRIGGER AS $$
DECLARE
updated_count INTEGER;
max_allowed INTEGER;
BEGIN
UPDATE user_table_definitions
SET row_count = row_count + 1,
updated_at = now()
WHERE id = NEW.table_id
AND row_count < max_rows
RETURNING row_count, max_rows INTO updated_count, max_allowed;
IF NOT FOUND THEN
SELECT max_rows INTO max_allowed
FROM user_table_definitions
WHERE id = NEW.table_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Table % not found', NEW.table_id
USING ERRCODE = 'foreign_key_violation';
END IF;
RAISE EXCEPTION 'Maximum row limit (%) reached for table %',
max_allowed, NEW.table_id
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
-- One-shot reconcile: ensure user_table_definitions.row_count matches the
-- actual count of user_table_rows. Guards against any historical drift before
-- downstream readers (listTables, getTableById) start trusting the column.
UPDATE user_table_definitions d
SET row_count = sub.c
FROM (
SELECT table_id, count(*)::integer AS c
FROM user_table_rows
GROUP BY table_id
) sub
WHERE d.id = sub.table_id
AND d.row_count IS DISTINCT FROM sub.c;
--> statement-breakpoint
-- Tables that exist but have zero rows should report row_count = 0 (not a
-- stale non-zero value left from rows deleted while the trigger was broken).
UPDATE user_table_definitions d
SET row_count = 0
WHERE d.row_count <> 0
AND NOT EXISTS (
SELECT 1 FROM user_table_rows r WHERE r.table_id = d.id
);

File diff suppressed because it is too large Load Diff

View File

@@ -1380,6 +1380,13 @@
"when": 1776980739421,
"tag": "0197_unknown_the_captain",
"breakpoints": true
},
{
"idx": 198,
"version": "7",
"when": 1777054484443,
"tag": "0198_tables_race_free_trigger",
"breakpoints": true
}
]
}

View File

@@ -17,6 +17,7 @@ export const redisConfigMockFns = {
mockOnRedisReconnect: vi.fn(),
mockAcquireLock: vi.fn().mockResolvedValue(true),
mockReleaseLock: vi.fn().mockResolvedValue(true),
mockExtendLock: vi.fn().mockResolvedValue(true),
mockCloseRedisConnection: vi.fn().mockResolvedValue(undefined),
mockResetForTesting: vi.fn(),
}
@@ -34,6 +35,7 @@ export const redisConfigMock = {
onRedisReconnect: redisConfigMockFns.mockOnRedisReconnect,
acquireLock: redisConfigMockFns.mockAcquireLock,
releaseLock: redisConfigMockFns.mockReleaseLock,
extendLock: redisConfigMockFns.mockExtendLock,
closeRedisConnection: redisConfigMockFns.mockCloseRedisConnection,
resetForTesting: redisConfigMockFns.mockResetForTesting,
}

View File

@@ -56,6 +56,9 @@ export function createMockRedis() {
exec: vi.fn().mockResolvedValue([]),
})),
// Scripting
eval: vi.fn().mockResolvedValue(0),
// Connection
ping: vi.fn().mockResolvedValue('PONG'),
quit: vi.fn().mockResolvedValue('OK'),