Imported from HappydanceDev/TestClient3_v17 (
apps/umbraco/Ph.Ats.Ashby/AGENTS.md). Install upstream withnpx skills add HappydanceDev/TestClient3_v17 --skill Ph.Ats.Ashby. Copyright stays with the author.
AGENTS.md
This project integrates Happydance with the Ashby ATS — RPC-style API, Basic auth, JSON envelopes ({success, results, errors}).
Use README.md for setup, API examples, and product-level context. This file should stay short and code-oriented.
What Matters Here
-
HttpRestAshbyRestClient(extendsRestClientBase) — JSON helper for all read endpointsAshbyTokenService— adds the Basic auth header (API key as username, blank password)
-
ModelsAshbyJob/AshbyJobPosting/AshbyJobBase— typed DTOs forjobPosting.info/jobPosting.listAshbyJobDetail/AshbyCustomField/AshbyHiringTeamMember— the expanded parent job (see Custom Fields)AshbyJobBoard— a board fromjobBoard.list(see Internal vs External Jobs)AshbyJobForm— focused subset returned byIAshbyFormService.GetJobForm(basic job + application form definition + survey definitions)AshbyApplicationFormDefinition,AshbyFormField,AshbyFieldDefinition,AshbySelectableValue,AshbySurveyFormDefinition— Easy Apply form-shape DTOsAshbyApplicationSubmission(and friends:AshbyApplicationFormPayload,AshbyFileUploadPart,AshbySurveyPayload) — mapper outputAshbySubmitResponse,AshbyApplicationInfo,AshbySurveySubmitResponse— typed responses for the three Easy Apply write endpointsAshbyResponse<T>— the standard envelope; never bypass it
-
ServicesAshbyJobBoardService— fetches job postings + locations, used by the job import pipelineAshbyFormService— owns the Easy Apply HTTP surface:GetJobForm,SubmitApplicationForm(multipart),GetApplicationInfo,SubmitSurveyAshbyDryRunFormService— dev-onlyIAshbyFormServicethat suppresses writes (see README)AshbyEnvelope.Unwrap<TInner, TOut>— translates the Ashby{success, results, errors}envelope ontoResponseWrapper<T>. Reuse it; do not hand-roll envelope parsing.AshbyCustomFieldExtensions— generic typed accessors for job custom fieldsAshbyPayRangeParser— parses pay ranges out of string-valued custom fields. Provided but not wired in; titles are tenant-specific (see README Salary).
-
EasyApply- Contains the Ashby implementation of the generic Easy Apply application pipeline.
AshbyApplicationProviderAdapteris the coordinator (form → submit → survey fan-out). Keep orchestration here; do not push it into the form service.AshbyApplicationFormBuilderowns the generic form/render contract assembly. It deserialisessourceData.RawQuestionnairePayloadback intoAshbyJobForm— the source serialises the typed model so schema drift surfaces in deserialization.AshbyApplicationSubmissionMapperowns generic-submission → Ashby payload mapping. Both application-form and survey payloads are keyed by Ashbypath/name in practice.
Custom Fields
Custom fields live on the parent job (requisition), not on the job posting. jobPosting.info only returns
them when the request includes expand: ["job"], which GetJobInternal sends by default (gated by
Ashby:ExpandJob, default true). The expanded object lands on AshbyJob.Job (AshbyJobDetail).
Besides customFields, the expand also brings customRequisitionId (e.g. REQ11515), the requisition
status (Open/Closed — not the posting's Published status), confidential, brandId, and
hiringTeam. Hiring team members carry staff names and emails — treat as private, do not publish.
Read values through the accessors, never by walking JsonElement directly:
job.CustomFields().GetLabel("Business Unit"); // "Sales"
job.CustomFields().GetLabels("Job Family"); // ["Field Sales"] — multi-select
job.CustomFields().GetDecimal("Scheduled Weekly Hours");
job.CustomFields().PublicFields().ToLabelDictionary();
- Ashby's
valueis polymorphic (string / bool / number / array of select keys), so it's held asJsonElementand every accessor returns null/default rather than throwing on an unexpected kind. - Fields are matched on title, case-insensitively — Ashby ids are per-tenant UUIDs.
- For select fields prefer
GetLabel; the rawvalueis an opaque key.GetStringreturns that key when you actually need it. isPrivatefields are excluded byPublicFields(). Filter through it before surfacing anything publicly.- Tenant-specific named accessors belong in
Ph.Import.Ats.Ashby/AshbyCustomFieldMappings.cs, not here. That file is a worked example — its titles come from one tenant and onlyGetRequisitionIdis wired into the mapper. Repoint the rest before mapping them onto index fields. - The mapper already folds every public custom field into
AdditionalText, so values are searchable without a dedicated index field each.
Internal vs External Jobs
Ashby separates internal and external jobs by job board. Both jobPosting.list and jobPosting.info
accept a jobBoardId, and AshbyConfig.JobBoardId is sent on both:
{ "listedOnly": true, "jobBoardId": "56cf15ce-0e9e-441a-a8af-0f8876f3322c" }
{ "jobPostingId": "ca6cee41-...", "jobBoardId": "56cf15ce-...", "expand": ["job"] }
Board ids come from jobBoard.list (IAshbyJobBoardService.GetJobBoards), which returns id, title and
isInternal per board.
- Both calls must use the same board id.
jobPosting.inforeturns posting data for a given board — apply links, descriptions — so listing from the internal board while fetching detail from the default external board would silently pair internal postings with external data. This is why the id is threaded throughGetJobInternalas well asGetJobPostingsList. - Per the OpenAPI spec, omitting
jobBoardIddoes not mean "all boards":jobPosting.listdefaults to "the primary external job board" andjobPosting.info"defaults to an external job board". Leaving it null is therefore the right setting for a public site, not a no-op. - Filtering is applied by Ashby, server-side. Do not filter by board client-side; postings carry no board reference in the response, so there is nothing to filter on after the fact.
- The Job Board integration check lists every board with its id and
isInternalflag, and errors if the configured id matches no enabled board (which would otherwise silently import zero jobs). Use it to find the id rather than calling the API by hand.
isListed / listedOnly is a separate gate — Ashby says unlisted postings "should not be displayed
publicly", which also covers confidential and draft roles. The two combine: board scoping picks the audience,
listedOnly picks what's publishable within it. Verified live: internal postings are still "listed", so
{ listedOnly: true, jobBoardId: "<internal board>" } does return the internal jobs — there is no need to
relax listedOnly for an internal site.
Description Mapping
AshbyContentMapperService merges Ashby's three description parts into AzureJob.Description and leaves
AdditionalDescription1-3 null. Ashby's descriptionHtml is exactly its three parts concatenated, so
populating both stored the whole description twice. The posting's own suppressDescriptionOpening /
suppressDescriptionClosing flags are honoured, and descriptionHtml is the fallback when a posting
supplies no parts at all.
Submission Endpoint
We use applicationForm.submit, not application.create. Reasons are documented in the README. Do not switch endpoints without revisiting that decision.
Submission is multipart/form-data (one round trip with files inline). The presigned file.createFileUploadHandle JSON path is intentionally not implemented yet.
Ashby matches file submissions on path — the field's name, not our internal field id. ResolveFilePath
prefers a sibling value's question key matched on FieldId, and drops a file whose only candidate path is
indistinguishable from its id, rather than submitting a path Ashby would reject with
invalid_field_provided_for_form.
Location Fields
Ashby declares Location as a single flat field — { "type": "Location", "path": "_systemfield_location" } —
with no child fields. It expects { country, city, region } plain strings; Ashby geocodes them server-side.
- Do not send geo-location ids. The hosted careers page's autocomplete calls an internal
ApiAutocompleteGeoLocationendpoint returningproviderLocationIdvalues (united_states/california/santa_monica). That is presentation only —applicationForm.submittakes plain strings, and the internal endpoint is not part of the public API. - Display names and ISO codes both work. Verified live:
{ country: "GBR", region: "GB-LND", city: "London" }was stored by Ashby asUnited Kingdom/England/London. - Any combination is accepted. Partial values geocode, so a CV giving only city + region is still useful.
BuildLocationObjectreads the structured value from the flat field, and falls back to discrete_systemfield_location.city-style siblings grouped byquestionMetadata.questionKey— the form the mock submission generator emits. Blank parts never overwrite supplied ones, and a non-object value (a bare"Santa Monica, CA") yields no location rather than being coerced into one.
The per-type value shapes for Location, RichText, Score and EducationHistory are not in Ashby's
official reference. They are documented in a third-party spec mirror,
corroborated by the shapes EncodeValue already emits. Check there before assuming a field type is undocumented.
Survey Submission
After applicationForm.submit succeeds and there are survey answers to send, the adapter must:
- Call
application.infowith thesubmittedFormInstanceIdto resolve the realapplicationIdandcandidateId. - For each
AshbySurveyPayload, callsurveySubmission.create.
Survey failures are best-effort — log a warning and continue. Do not fail the whole submit because EEO data didn't post.
Dependency Injection
- DI lives in
Ph.Import.Ats.Ashby/AshbyComposer.cs. AshbyFormServiceis registered as a concrete class so the dry-run wrapper can inject it.IAshbyFormServiceis registered with a factory that swaps inAshbyDryRunFormServicewhenAshbyConfig.DryRunSubmissionsistrueand the environment is not Production.
Tests
- Tests live in
Ph.Ats.Ashby.Tests, notPh.Tests. AshbyTestDatabuilds reusableAshbyJobFormfixtures and a stubICultureDictionaryFactory.Applications/holds the Easy Apply tests: form-builder, submission-mapper, provider-adapter, form-source (matches the Greenhouse layout).- Import-side tests sit at the project root:
AshbyCustomFieldExtensionsTests,AshbyPayRangeParserTests,AshbySalaryMappingTests,AshbyDescriptionMappingTests,AshbyJobBoardRequestTests(which asserts the request bodies forjobBoardId/expand), andAshbyVacancyServiceSignalsTests.
Agent Guidance
- Prefer small, reversible changes in auth, config, or HTTP registration before touching the Easy Apply orchestration.
- Do not guess Ashby request/response shapes. Always check the Ashby reference first; the existing DTOs and the README JSON snippets are the local source of truth.
- The rendered reference pages truncate the request schema, so "the docs don't list parameter X" is not evidence that X doesn't exist. Append
.mdto any reference URL for the full OpenAPI JSON (~130–160KB) and search it directly — e.g.curl -s https://developers.ashbyhq.com/reference/jobpostinginfo.md | grep -A20 '"JobPostingInfoRequest"'. BothjobBoardIdparameters were found this way after the rendered pages appeared to show no such option. - Ashby returns HTTP 200 for almost everything, including business-level errors. Always check
response.Success(orenvelope.Successfor the inner shape), never just the status code. - Survey submissions use the field path/name. Ashby's API reference mentions field ids, but live submissions can reject the job-posting form field UUIDs with
survey_form_field_not_found. - Keep ATS-specific normalization and submission mapping here; keep orchestration and source selection out of this project.
- The form-source (
AshbyDirectApplicationFormSource) lives inPh.Import.Ats.Ashby/EasyApply/— that's where the import-side wiring belongs, not in this project. - Do not add Azure dependencies here for generic application forms. Azure-backed form seed resolution belongs in
Ph.EasyApply. - When adding new Ashby endpoints, prefer
restClient.Post<AshbyResponse<T>>+AshbyEnvelope.Unwrap— not rawHttpClientcalls.applicationForm.submitis the documented exception (multipart).