---
title: "Host Talent Pool"
slug: host-talent-pool
section: Portals
group: Host Portal
status: stable
description: "How the Host Portal lets a host browse, search and filter candidate participants, and how the portal maps those controls onto the Hub's talent-pool endpoints."
last-updated: 2026-10-07
---

# Host Talent Pool

How the Host Portal lets a host browse, search and filter candidate participants, and how the portal maps those controls onto the Hub's talent-pool endpoints.

## Overview

The Talent Pool is the host-facing candidate browser in HostPortalPrototype. It has an **All Candidates** list (`/api/v2/talent-pool/search`) and a **Recommended** list (`/api/v2/talent-pool/recommended`), both driven by one shared search box and one shared filter state. The portal server is a thin pass-through to the Hub: the facade forwards query parameters to the Globus SDK and returns the Hub page unchanged, so pagination totals and ordering are the Hub's. The Hub-side contract (eligibility, exclusivity, the full filter list) is documented on [Job Orders](/docs/job-orders); this page covers the portal layer on top of it. [1] [2] [3]

> **Scope**
>
> Everything here was read from the HostPortalPrototype `development` branch (and one BackOfficePrototype service for availability). It describes the portal's own behavior. Hub behavior is only stated where portal code or tests depend on it.

## Request Path

| Layer | Component | Role |
| --- | --- | --- |
| Client state | `TalentPoolProvider` (`client/src/context/TalentPool/index.tsx`) | Holds `filters` and `searchQuery`; resets both when the active employer changes. Feeds the filters endpoint's skill list into both list hooks. |
| Client hooks | `useParticipantsSearch`, `useParticipantsRecommended` | Build the query parameters and call the BFF. Both use the same search resolver, so the two tabs respond to the search box identically. |
| Router | `server/routes/v2/talent-pool.ts` | Every route runs `middlewareJwtCheck`. |
| Endpoint | `talent-pool-endpoint.ts` | Requires `hostOrgId` (400 if absent) and an email claim on the JWT (401 if absent), then forwards the remaining query string untouched. |
| Facade | `talent-pool-facade.ts` | Calls `getHostTalentPoolFilters`, `getHostRecommended` or `getHostSearchParticipants` with the `X-Host-User-Email` header. An agreement-gated host gets a typed 403 that the endpoint relays rather than collapsing into a 503. |

Failures on the filters, search and recommended routes are answered 503 with a generic "temporarily unavailable" message, unless the host is agreement-gated, in which case the typed 403 is relayed. The dismiss route does not relay the agreement gate; any failure there is answered 503 with "Unable to update recommendation right now". [2] [3] [4]

## Endpoints

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/v2/talent-pool/filters` | Filter vocabulary: age range, countries, genders, positions, skills, camp skills. |
| `GET` | `/api/v2/talent-pool/search` | All Candidates list. |
| `GET` | `/api/v2/talent-pool/recommended` | Recommended list (`source` of `staff` or `algorithm`). |
| `POST` | `/api/v2/talent-pool/recommended/dismiss` | Dismiss a recommendation by `matchId`, `recommendationId` or `participant_id`; 422 `VALIDATION_ERROR` if none is given. The server injects `host_id` from `hostOrgId`. |

Query parameters accepted on the two list routes are typed in `shared/domain/v2/talent-pool.ts`: `page`, `pageSize`, `name`, `country` (comma-separated ISO codes), `gender`, `skills` (comma-separated, OR logic), `position`, `appliedProgram`, `pastParticipant`, `maxDaysRemaining`. `min_age` and `max_age` are typed on the search params only (`GlobusSearchQueryParams`); the recommended hook sends them too, but the recommended param type does not declare them. The Hub does validate them on the recommended routes (next paragraph). The portal hooks send `appliedProgram` and never `position`. [2] [4] [5]

**Hub-side validation on the recommended list.** This settles the open point in the previous paragraph. The Hub's recommended routes (`GET /api/partner/host/recommended` and `GET /api/partner/host/talent-pool/recommended`, host roles `admin`, `recruiter` and `readonly`) validate `min_age` and `max_age` themselves and return `422` with the code `INVALID_AGE_FILTER` in the envelope `{ error: { code, message, details, traceId } }`, where `details` carries `field` and `reason`. An optional `jobOrderId` (or `job_order_id`) for one of the host's own job orders intersects the requested bounds with that order's age-range rule; an empty intersection returns `200` with an empty page and null `effectiveAgeBounds`, and `effectiveAgeBounds` is returned whenever a bound is in effect. The `source` query parameter accepts only `algorithm` or `staff`; any other value returns `400` with the code `INVALID_SOURCE`. `algorithm` is a legacy alias that runs the same staff-recommendation query and never returns algorithm matches.

## Search Box Resolution

The search box is labelled "Search for participants by name or skill". Input is debounced by 500 ms. The Hub cannot express "name OR skill" in one request. According to the portal resolver's own comments, `name` is a partial, case-insensitive match on first and last name, `skills` matches whole skills (OR across comma-separated values), and sending both ANDs them. The portal therefore sends one request per search and decides client-side which parameter the typed text becomes. [6] [7]

### Resolution Rules

`resolveTalentPoolSearch` (`client/src/lib/talent-pool-search.ts`) applies these rules:

1. The typed text is trimmed and compared, case-insensitively as a substring, against the **labels** of the host's skill vocabulary (from the filters endpoint).
2. **No skill matches:** the text is sent as `name`, and any skills chosen in the Camp Skills dropdown are sent as `skills` (both apply, ANDed by the Hub).
3. **One or more skills match:** `name` is dropped for this request and the matched skills' machine values are sent as `skills`, comma-separated (OR).
4. **Skill match plus dropdown skills:** the request narrows to the skills that satisfy both.
5. **Skill match that no dropdown skill satisfies:** the result is flagged impossible. The request is not sent at all and the list shows its empty state.
6. **Vocabulary not loaded yet:** the text is treated as a name.
7. **Empty box:** no `name` is sent; clearing the box restores the unfiltered list.

### Resolution Examples

| Typed text | Dropdown skills | Sent to the Hub |
| --- | --- | --- |
| `teach` (matches "Teaching") | none | `skills=Teaching`, no `name` |
| `ing` (matches two skills) | none | both skill values, comma-separated |
| `Johnson` (no skill match) | none | `name=Johnson` |
| `Johnson` | Childcare | `name=Johnson` and `skills=Childcare` |
| `ing` | Teaching, Childcare | `skills=Teaching` |
| `teach` | Childcare | nothing sent; empty state |

The examples use the labels from the resolver's unit tests; real vocabularies come from the Hub. [7] [8]

## Filters

### Position Filter

The Positions dropdown no longer lists Hub position strings. It always renders the fixed `TALENT_POOL_POSITION_FILTERS` choices, even when the Hub returns no positions: **Support Staff** (`support`) and **Camp Counsellor** (`counselor`), plus All Positions. The selected value is sent as `appliedProgram`. The active-filter badge is labelled "Position". See the Applied Program Filter section of [Job Orders](/docs/job-orders) for how the Hub side treats this filter. [5] [9]

### Age Filter

The age slider defaults to 0 to 65 and is treated as "not filtering". `min_age` and `max_age` are sent only once the host narrows the range, because sending them unconditionally hid candidates whose Hub-side age is missing or zero. [10] [11]

### Skills Filter

The filters endpoint returns skills as objects with a machine `value` and a human `label`. Older payloads returned plain strings, so every consumer goes through `normalizeSkillOptions`, which accepts both shapes, dedupes by value, drops empty entries, and returns an empty list for any non-array input. The UI shows labels; the filter state and the request carry machine values. [12]

### Gender and Country Options

The facade normalizes two lists in the filters response before returning it:

| List | Normalization |
| --- | --- |
| `genders` | Case-insensitive mapping of `M`, `Male`, `F`, `Female` to `male` and `female`; any other value is dropped; output order is Female, then Male. |
| `countries` | Bare ISO codes (or objects with a `value`) become `value` (ISO code) and `name` (display name), deduplicated and sorted by name; unknown codes fall back to the code as the name. |

The ISO code remains the `value` sent on requests; only the displayed name is resolved. [4]

## Participant Card Payload

List items follow `TalentPoolParticipant` in `shared/domain/v2/talent-pool.ts`: identity and contact fields, `nationality`, `age`, `gender`, `skills`, `englishLevel`, experience and education summaries, `languages`, `certifications`, `applicationId`, `programName`, and the availability fields below. Recommended items add `source`, staff-recommendation fields (`recommendationId`, `recommendedBy`, `note`, `recommendedAt`) and algorithm fields (`confidenceScore`, `matchId`). For the full per-participant view the list points at `GET /api/v2/participants/:applicationId`. [5] That detail record (`ParticipantDetailV2` in `shared/domain/v2/participant.ts`) carries `volunteerWork`, a list of `{ role, organization, startDate, endDate, summary }` entries sourced from the completed Hub About Me step data (`vabout_me`, `step_data.volunteerWork[]`; dates normalized to `YYYY-MM`, empty when the Hub omits them). The candidate dialog's Experience tab lists them under a Volunteer Work heading as a month and year range (`Present` for an open end) and reads `No volunteer work listed` when there are none. `volunteerWork` is not a field of the talent-pool list item.

### Availability Dates

The Hub supplies two flat, nullable ISO date fields on talent-pool and participant records: `availabilityStartDate` and `availabilityEndDate`. Either, both or neither may be set. The portal reads only those two keys (`readParticipantAvailability`). It never derives availability from education, work or volunteer dates, job-order season dates or arrival records.

- Display dates are formatted `MM/DD/YYYY` only when the value is a real ISO date or timestamp; anything malformed is shown exactly as sent and never passed to `new Date`.
- A one-sided window is labelled `From <date>` or `Until <date>`.
- The card always renders the Available row and shows `Not provided` when the Hub sends no window (or only blank values), the same as the expanded profile.

On the Hub side, `talentPoolAvailability.ts` fills these fields only for applications in the `camp_counselor` category, from the applicant's `personal_information_camp_v1` step (earliest camp start and latest camp finish), and emits `null` when a date is not a real `YYYY-MM-DD`. All other applications carry `null` for both. [13] [14] [15]

## Candidate Media

The candidate dialog's Media tab loads photos through JWT-protected relay URLs, because a plain image tag cannot send the bearer token. Showcase gallery photos use `GET /api/v2/participants/:participantId/step-media/:mediaId`, which requires the `hostOrgId` query parameter (400 without it, 401 without an email claim) and accepts the same optional `applicationId` query hint that the profile photo request carries (described below). It streams the bytes from the Hub's host surface `GET /api/partner/host/step-media/:mediaId/file`, not the portal surface, and answers 404 when the media does not exist (or when the participant is not one the host may see). The profile photo request carries `applicationId` as a query parameter when the candidate has one, so the relay authorizes that exact candidate against the Hub instead of finding them in a bulk feed listing (which made media depend on the candidate's position in the pool). When the server could not reach the applicant's media (`mediaUnavailable`), the tab shows a "couldn't be loaded" state rather than "No photos available", so a failure is not read as a product rule. A gallery photo the relay refuses is shown as an error, not as an empty placeholder. [16]

## Freshness and Error Handling

- Both list queries use `staleTime: 0`, `gcTime: 0`, refetch on mount and on window focus, and `retry: 0`, because discovery visibility is determined live by the Hub.
- While a refresh is in flight, old cards are withheld so a candidate whose visibility was revoked does not flash back in.
- If a request fails, the hook returns an empty page and sets a "temporarily unavailable" error rather than throwing.
- A selected candidate is cleared once a later authoritative response no longer contains them (keyed on `participantId` and `applicationId`).
- Changing the active employer resets filters and the search text. [10] [17]

## Known Gotchas

- A typed skill silently replaces any name search for that request. Searching a name that happens to be a substring of a skill label returns skill matches, not people. [7]
- The vocabulary is per-host and loaded asynchronously; before it loads, skill text is sent as a name. [7]
- The search endpoint and recommended endpoint share resolution logic but are separate Hub calls with separate pagination. [10] [11]
- `position` is still a valid typed parameter, but the portal UI no longer sends it. [5]

## Sources

- HostPortalPrototype#464, #517, #523, #538, #588, #596 (talent-pool portions only).
- BackOfficePrototype#2566 (only the availability service was read).

## References

[1]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/context/TalentPool/index.tsx "TalentPoolProvider"
[2]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/server/routes/v2/talent-pool.ts "Talent pool router"
[3]: /docs/job-orders "Job Orders (Hub talent pool contract)"
[4]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/server/facades/v2/talent-pool-facade.ts "Talent pool facade"
[5]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/shared/domain/v2/talent-pool.ts "Talent pool shared domain types"
[6]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/components/talent-pool/search-and-filters/search-and-filters.tsx "Search and filters component"
[7]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/lib/talent-pool-search.ts "Search resolver"
[8]: https://github.com/Alliance-Strategies/HostPortalPrototype/pull/517 "HostPortalPrototype PR 517"
[9]: https://github.com/Alliance-Strategies/HostPortalPrototype/pull/588 "HostPortalPrototype PR 588"
[10]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/hooks/useParticipantsSearch/useParticipantsSearch.tsx "useParticipantsSearch"
[11]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/context/TalentPool/types.ts "Talent pool filter types"
[12]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/lib/talent-pool-skills.ts "Skill option normalizer"
[13]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/shared/domain/v2/participant-availability.ts "Participant availability"
[14]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/docs/hub-requests/participant-availability-dates.md "Availability dates Hub contract note"
[15]: https://github.com/Alliance-Strategies/BackOfficePrototype/blob/development/server/services/talentPoolAvailability.ts "Hub talent pool availability service"
[16]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/components/talent-pool/CandidateProfileDialog/CandidaDialogTabs/MediaTab.tsx "Candidate Media tab"
[17]: https://github.com/Alliance-Strategies/HostPortalPrototype/blob/development/client/src/hooks/useParticipantsRecommended/useParticipantsRecommended.tsx "useParticipantsRecommended"
