Skip to content

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

Merged
waleedlatif1 merged 7 commits into
stagingfrom
feat/ashby-sync-and-writes
Aug 14, 2026
Merged

feat(ashby): incremental job sync, custom field writes, and application lifecycle ops#6703
waleedlatif1 merged 7 commits into
stagingfrom
feat/ashby-sync-and-writes

Conversation

@mzxchandra

@mzxchandra mzxchandra commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Extends the Ashby connector so a scheduled job/req sync can run incrementally, and adds the write path needed to annotate reqs and retract applications.

Reads

  • list_jobs accepts Ashby's syncToken and returns it as nextSyncCursor, turning a scheduled sync from O(all reqs) into O(changed reqs). Ashby only returns the token once the last page is drained, which the param description states.
  • list_job_postings gains includeUnpublishedJobPostings, plus the posting status field. Without status a caller cannot tell a returned draft from a published posting, which makes the flag useless.

Writes (new operations)

  • customField.setValue / setValues — the only way to annotate a job or req, since Ashby has no job notes and no job tags. Writing null clears a value, so the annotation is reversible.
  • application.delete — requires candidatesDelete, a module permission separate from candidatesWrite.
  • application.changeSource — corrects source attribution on programmatically created applications.
  • candidate.anonymize — strips PII; the record itself remains, because Ashby exposes no candidate deletion endpoint.

Bug fix found while testing against a real Ashby org

  • ashbyErrorMessage rendered Ashby's documented errors: [{message, parameter}] shape as [object Object], hiding the real cause. That is the form a 403 for a missing module permission arrives in.

Destructive-write safety

null clears a custom field, so every write here requires explicit intent before it can destroy data. This matters because the block's required markers only cover the editor: on the agent path a model calls the tool directly, so tools.config.params never runs and validateRequiredParametersAfterMerge skips a param marked not-required.

  • set_custom_field_value rejects an absent or blank fieldValue; an explicit null still clears.
  • change_application_source requires unsetSource to clear, and rejects a source ID and an unset request together — preferring either one silently discards the other. Ashby has no "leave unchanged" mode, so setting and clearing are the only two intents and exactly one must be expressed. The editor cannot produce the pair: the Source ID field is conditioned off while the clear switch is on.
  • set_custom_field_values rejects an empty array locally rather than relying on Ashby.

The custom field value parser is deliberately narrow rather than a blanket JSON.parse, which corrupts real text: 1e999 becomes Infinity and serializes back out as null — a clear; long numeric ids lose precision past 2^53; prose starting with { turns into an object. Only the literal keywords, {/[/" prefixes, and exactly round-tripping numbers decode.

On the sync cursor's name

Ashby calls it syncToken. Surfacing it under that name renders it [REDACTED] in block output, because syncToken matches the /^.*token$/i deny-list in redaction.ts — and an incremental sync is unusable if the operator cannot read the cursor the next run needs.

An earlier revision of this PR fixed that by exempting syncToken from redaction globally. That was the wrong trade: it would have stopped redacting that field name product-wide, in any current or future integration, across execution traces, block output, the console, and telemetry — and the sharp edge was this block's own Sync Token input, where an operator pasting an API key would have had it stored verbatim.

redaction.ts is untouched by this PR. The output is named nextSyncCursor instead, which is what it actually is — an opaque resumption marker, the same class as the cursor and nextCursor fields the redactor already leaves alone. This connector already translates Ashby's wire vocabulary on the way out (limitperPage, resultsjobs). The wire name and the request param stay syncToken, matching Ashby's docs, and the input keeps being redacted, which is the protective behaviour. A test pins the invariant: syncToken is sensitive by name, nextSyncCursor and nextCursor are not.

Test Coverage

Ashby-surface tests went 4 → 142. Full suite: 25,129 passed, 41 skipped, 0 failures (1,875 files). Every commit typechecks independently.

Verified live against a real Ashby production organization, and end-to-end through the workflow editor:

  • The sync cursor is returned only on the last page (confirmed two ways: a 2-page walk and a perPage=1 walk), and replaying it returns a strictly smaller result set.
  • A custom field value can be written and then cleared with null, confirmed by reading the object back both times.
  • includeUnpublishedJobPostings returns a strict superset, with status reporting Published / Draft.
  • 11 distinct real Ashby error payloads all render readably. Every one was [object Object] before this branch.
  • A 17-block edge-case workflow exercised stale/garbage sync tokens, perPage bounds, all five fieldValue encodings, empty and single-element setValues, and the three permission/not-found error paths.

apps/sim/tools/ashby/ashby.live.test.ts is committed but inert by default:

ASHBY_LIVE=1 ASHBY_API_KEY=... bunx vitest run tools/ashby/ashby.live.test.ts

Add ASHBY_LIVE_WRITES=1 for the write phase. Note that vitest.setup.ts stubs global fetch for every file in the app, so the live file restores the real implementation and asserts the restore worked — without that guard the whole suite silently passes against a mock.

Review

Greptile 5/5. One Cursor Bugbot finding — a source ID and an unset request could be supplied together and the body preferred the source ID, turning an intentional clear into a set — fixed by rejecting the pair rather than picking a winner, and by making the editor unable to produce it.

A Codex prototype-pollution concern was investigated and refuted: the redactor builds a fresh object via Object.entries, and JSON.parse never invokes the __proto__ setter.

Known gaps

  • The API key available for testing lacked candidatesDelete, so delete_application and change_application_source have error-path-only live verification. Their success paths are covered by mocks. In particular change_application_source's explicit-null unset is proven against mocks but not against the live API.
  • delete_application's { applicationId } response shape comes from Ashby's OpenAPI spec, not an observed live response, for the same reason.
  • Eight other Ashby list_* tools still understate perPage as "(default 100)" when Ashby caps at 100 and silently truncates. Pre-existing; only list_jobs was corrected here to avoid widening scope.
  • Five other Ashby list_* tools still expose a syncToken output, which is redacted for the reason described above. Pre-existing, and renaming them would break any workflow already chaining the value, so it is left for a separate change.
  • candidate.anonymize's response is persisted to execution traces like any other block output. If the endpoint echoes the pre-anonymization record, log retention could outlive the erasure. That is a platform-level retention question, not specific to this diff.

Evals

No prompt-related files changed — evals skipped.

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 14, 2026 8:42pm

Request Review

@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Adds destructive Ashby writes (application delete, PII anonymization, custom-field clears) with explicit guards, but misconfigured workflows or weak API key scopes can still cause irreversible recruiting-data changes at runtime.

Overview
Expands the Ashby connector with five new operations (delete application, change application source, anonymize candidate, set custom field value, set custom field values) and richer list jobs / list job postings behavior for scheduled req sync and job-board coverage.

Incremental sync: list_jobs accepts Ashby’s syncToken and returns nextSyncCursor on the final page (not mid-pagination), so workflows can chain incremental runs without rescanning every req. The output is named nextSyncCursor instead of syncToken so block output stays readable under existing credential redaction rules.

Writes & safety: Custom fields are the supported way to annotate jobs/reqs; null clears values. Tools and block param mapping reject ambiguous cases (blank vs explicit null, empty values arrays, simultaneous source ID + unset). change_application_source always sets sourceId in transformed params so a stale sourceId from Create Application cannot leak through executor merge. ashbyErrorMessage now unwraps Ashby’s { message, parameter } errors instead of showing [object Object].

Docs (ashby.mdx), block UI subblocks, wand config, and integrations.json (33 operations) are updated to match.

Reviewed by Cursor Bugbot for commit 2deb0e1. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR extends the Ashby integration with incremental job synchronization, custom-field writes, application lifecycle operations, richer error extraction, and corresponding block, registry, generated metadata, documentation, and test updates.

  • Adds incremental list_jobs synchronization through syncToken and readable nextSyncCursor output.
  • Adds custom-field mutation, application deletion/source changes, and candidate anonymization operations.
  • Adds unpublished job-posting support, posting status, improved Ashby error formatting, and extensive automated/live-test coverage.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains in the reviewed fixes.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/blocks/blocks/ashby.ts Registers the new Ashby operations and maps editor inputs into typed tool parameters, including explicit source-clearing behavior.
apps/sim/tools/ashby/list_jobs.ts Adds incremental synchronization input and maps Ashby's terminal sync token to a readable cursor output.
apps/sim/tools/ashby/change_application_source.ts Adds source-setting and explicit source-clearing requests with mutual-exclusivity validation.
apps/sim/tools/ashby/set_custom_field_value.ts Adds guarded single-field mutation while preserving explicit null as a clearing operation.
apps/sim/tools/ashby/set_custom_field_values.ts Adds non-empty batched custom-field mutations and returns the resulting field values.
apps/sim/tools/ashby/utils.ts Improves structured Ashby error rendering and provides narrow custom-field value parsing.
apps/sim/tools/ashby/ashby.test.ts Covers new request shapes, destructive-write intent validation, response transformations, and error paths.
apps/docs/content/docs/en/integrations/ashby.mdx Documents the expanded operation surface, permission requirements, incremental cursor behavior, and API limitations.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Workflow[Workflow or agent] --> AshbyBlock[Ashby block]
  AshbyBlock --> Params[Operation parameter mapping]
  Params --> Tool[Ashby tool]
  Tool --> API[Ashby API]
  API --> Tool
  Tool --> Output[Normalized block output]
  Output --> Cursor[nextSyncCursor for later scheduled sync]
  Output --> Resources[Jobs, postings, applications, candidates, or custom fields]
Loading

Reviews (5): Last reviewed commit: "fix(blocks): stop a stale create-path so..." | Re-trigger Greptile

Comment thread apps/sim/tools/ashby/change_application_source.ts
@mzxchandra

Copy link
Copy Markdown
Contributor Author

@greptile

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit a2f9156. Configure here.

@mzxchandra
mzxchandra marked this pull request as draft August 14, 2026 20:07
list_jobs accepts Ashby's syncToken and returns it as nextSyncCursor, so a
scheduled sync costs O(changed reqs) instead of rescanning every req. Ashby only
returns the token once the last page is drained, which the param description
states.

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

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

Also widens the custom field valueLabel type, which MultiValueSelect returns as
an array, for the write operations that follow.
Ashby documents two error shapes and uses both. The `errors` array form carries
`{ message, parameter }` objects, which stringified to '[object Object]' and hid
the real cause - including the 403 a key gets when it lacks a module permission.

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

customField.setValue/setValues are the only way to annotate a job or req, since
Ashby has no job notes and no job tags. Writing null clears a value, so the
annotation is reversible.

Because null clears, every one of these operations requires explicit intent
before it can destroy data. The block's required markers do not cover the agent
path - a model calls the tool directly, so tools.config.params never runs and
validateRequiredParametersAfterMerge skips a param marked not-required:

- set_custom_field_value rejects an absent or blank fieldValue; an explicit null
  still clears
- change_application_source requires unsetSource to clear, and rejects a source
  id and an unset request together, since preferring either one silently
  discards the other. Ashby has no 'leave unchanged' mode, so setting and
  clearing are the only two intents and exactly one must be expressed
- set_custom_field_values rejects an empty array locally rather than relying on
  Ashby to reject it

application.delete needs candidatesDelete, a module permission separate from
candidatesWrite. candidate.anonymize strips PII but leaves the record; Ashby
exposes no candidate deletion endpoint.
Includes a gated live harness (ASHBY_LIVE=1) alongside the mocked tests.
vitest.setup.ts stubs global fetch for every file in the app, so the live file
restores the real implementation and asserts the restore worked - without that
guard the whole suite silently passes against a mock.
fieldValue is polymorphic (boolean, number, string, array, object, null), so it
decodes structured input and otherwise passes text through. The decoding is
deliberately narrow rather than a blanket JSON.parse, which corrupts real text:
1e999 becomes Infinity and serializes back out as null, which CLEARS the field;
a long numeric id loses precision past 2^53; and prose starting with { turns into
an object. Only the literal keywords, {, [ or " prefixes, and exactly
round-tripping numbers decode.

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

Setting and clearing an application source are mutually exclusive, so the Source
ID field is conditioned off while the clear switch is on and the params mapping
sends only the intent the switch selects. A value typed before the switch was
flipped cannot reach the tool and surface as an error with no visible cause.
Ashby scopes permissions per module and they fail at runtime, not build time, so
the block docs now carry the permission table. Also records the hard API limits
worth designing around: no note or tag on a job, no pagination on
jobPosting.list, and no delete for jobs, candidates, or custom field definitions.
@mzxchandra
mzxchandra force-pushed the feat/ashby-sync-and-writes branch from a2f9156 to 734a8b6 Compare August 14, 2026 20:26
@mzxchandra

Copy link
Copy Markdown
Contributor Author

@greptile

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@cursor review

Comment thread apps/sim/blocks/blocks/ashby.ts
… change

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

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

sourceId is now always assigned for this operation rather than conditionally,
so it can never inherit. The regression test asserts the merged result rather
than the mapping alone, since the gap between them is where the bug lived.
@mzxchandra

Copy link
Copy Markdown
Contributor Author

@greptile

@mzxchandra

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 2deb0e1. Configure here.

@mzxchandra
mzxchandra marked this pull request as ready for review August 14, 2026 21:19
@waleedlatif1
waleedlatif1 merged commit 5a88ce2 into staging Aug 14, 2026
31 checks passed
@waleedlatif1
waleedlatif1 deleted the feat/ashby-sync-and-writes branch August 14, 2026 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants